// lib/services/webdav_client.dart
//
// A minimal WebDAV client abstraction for vault sync. The [WebDavClient]
// interface is intentionally tiny (the four verbs the sync algorithm needs:
// list / download / upload / mkcol) so that:
//   * the sync ALGORITHM in WebDavSyncService can be unit-tested against a
//     FAKE in-memory implementation (no real server), and
//   * the real network adapter ([HttpWebDavClient]) stays a thin shim over
//     `package:http` + `package:xml` (PROPFIND/GET/PUT/MKCOL).
//
// Paths handled here are REMOTE paths relative to the configured remote root,
// using forward slashes (e.g. `Lecture/Lecture.pdf`). Mapping vault file paths
// to/from these remote paths lives in WebDavSyncService.

import 'dart:convert';
import 'dart:typed_data';

import 'package:http/http.dart' as http;
import 'package:xml/xml.dart';

/// One remote resource returned by a directory listing (PROPFIND).
class RemoteEntry {
  const RemoteEntry({
    required this.path,
    required this.isDirectory,
    this.modified,
    this.size,
    this.etag,
  });

  /// Remote path RELATIVE to the configured remote root, forward-slashed and
  /// WITHOUT a leading slash, e.g. `Lecture/Lecture.pdf`. Directories carry no
  /// trailing slash here (normalized by the client).
  final String path;

  /// Whether this entry is a collection (directory) rather than a file.
  final bool isDirectory;

  /// Server last-modified time (UTC) if the server reported one.
  final DateTime? modified;

  /// Content length in bytes if reported (files only).
  final int? size;

  /// Weak/strong ETag if reported (quotes stripped).
  final String? etag;
}

/// Thrown by [WebDavClient] implementations for any transport/protocol error.
/// Carries a human-readable [message] suitable for surfacing in the UI.
class WebDavException implements Exception {
  WebDavException(this.message, {this.statusCode});

  final String message;
  final int? statusCode;

  @override
  String toString() => 'WebDavException($message'
      '${statusCode != null ? ', status: $statusCode' : ''})';
}

/// The four WebDAV operations the sync algorithm depends on. Inject a fake in
/// tests; inject [HttpWebDavClient] in production.
abstract class WebDavClient {
  /// List the immediate-and-nested files under [remoteDir] (relative to the
  /// remote root, `''` meaning the root itself). Returns every FILE found in
  /// the subtree (directories are created on demand via [makeCollection], so
  /// callers care about files). Implementations PROPFIND with Depth: infinity
  /// and flatten the result. A missing remote dir yields an empty list.
  Future<List<RemoteEntry>> list(String remoteDir);

  /// Download the bytes of the remote file at [remotePath].
  Future<Uint8List> download(String remotePath);

  /// Upload [bytes] to [remotePath], creating/overwriting the remote file.
  /// Parent collections must already exist (use [makeCollection]).
  Future<void> upload(String remotePath, Uint8List bytes);

  /// Create the collection (directory) at [remotePath]. Idempotent: an
  /// already-existing collection is not an error.
  Future<void> makeCollection(String remotePath);

  /// Probe connectivity + credentials cheaply (PROPFIND Depth:0 on the root).
  /// Throws [WebDavException] on failure; returns normally on success.
  Future<void> testConnection();
}

/// Real WebDAV adapter over `package:http`. Thin by design — all the sync
/// decision logic lives in WebDavSyncService, NOT here.
///
/// DEVICE/SERVER-VALIDATED ONLY: this class performs real network round-trips
/// and is not exercised in CI (no WebDAV server). The XML/path plumbing below
/// is best-effort against common servers (Nextcloud, Apache mod_dav). The sync
/// algorithm that consumes it is what the unit tests cover, via a fake client.
class HttpWebDavClient implements WebDavClient {
  HttpWebDavClient({
    required String baseUrl,
    required String username,
    required String password,
    String remoteRoot = '',
    http.Client? httpClient,
    this.timeout = const Duration(seconds: 30),
  })  : _client = httpClient ?? http.Client(),
        _ownsClient = httpClient == null,
        _baseUri = _normalizeBase(baseUrl, remoteRoot),
        _authHeader =
            'Basic ${base64Encode(utf8.encode('$username:$password'))}';

  final http.Client _client;
  final bool _ownsClient;

  /// Absolute base URI INCLUDING the remote root path, always ending in `/`.
  final Uri _baseUri;
  final String _authHeader;
  final Duration timeout;

  /// Combine the server [baseUrl] with the [remoteRoot] folder into a single
  /// absolute base URI ending in a slash. Tolerates trailing/leading slashes.
  static Uri _normalizeBase(String baseUrl, String remoteRoot) {
    var base = baseUrl.trim();
    if (!base.endsWith('/')) base = '$base/';
    var uri = Uri.parse(base);
    final root = remoteRoot.trim().replaceAll(RegExp(r'^/+|/+$'), '');
    if (root.isNotEmpty) {
      uri = uri.resolve('${Uri.encodeFull(root)}/');
    }
    return uri;
  }

  /// Resolve a remote-root-relative [remotePath] to an absolute URI.
  Uri _resolve(String remotePath) {
    final clean = remotePath.replaceAll(RegExp(r'^/+'), '');
    if (clean.isEmpty) return _baseUri;
    // Encode each segment but keep the slashes.
    final encoded = clean.split('/').map(Uri.encodeComponent).join('/');
    return _baseUri.resolve(encoded);
  }

  Map<String, String> get _headers => {'Authorization': _authHeader};

  @override
  Future<void> testConnection() async {
    final res = await _send('PROPFIND', _baseUri, headers: {'Depth': '0'});
    if (res.statusCode < 200 || res.statusCode >= 300) {
      throw WebDavException(
        'Server responded ${res.statusCode}',
        statusCode: res.statusCode,
      );
    }
  }

  @override
  Future<List<RemoteEntry>> list(String remoteDir) async {
    final uri = _resolve(remoteDir.endsWith('/') ? remoteDir : '$remoteDir/');
    final http.Response res;
    try {
      res = await _send('PROPFIND', uri, headers: {'Depth': 'infinity'});
    } on WebDavException {
      rethrow;
    }
    if (res.statusCode == 404) return const [];
    if (res.statusCode < 200 || res.statusCode >= 300) {
      throw WebDavException(
        'PROPFIND failed (${res.statusCode})',
        statusCode: res.statusCode,
      );
    }
    return _parseMultiStatus(res.body);
  }

  @override
  Future<Uint8List> download(String remotePath) async {
    final res = await _get(_resolve(remotePath));
    if (res.statusCode < 200 || res.statusCode >= 300) {
      throw WebDavException(
        'Download failed (${res.statusCode})',
        statusCode: res.statusCode,
      );
    }
    return res.bodyBytes;
  }

  @override
  Future<void> upload(String remotePath, Uint8List bytes) async {
    final res = await _put(_resolve(remotePath), bytes);
    if (res.statusCode < 200 || res.statusCode >= 300) {
      throw WebDavException(
        'Upload failed (${res.statusCode})',
        statusCode: res.statusCode,
      );
    }
  }

  @override
  Future<void> makeCollection(String remotePath) async {
    final uri = _resolve(remotePath.endsWith('/') ? remotePath : '$remotePath/');
    final res = await _send('MKCOL', uri);
    // 201 created; 405 method-not-allowed means it already exists (fine).
    if (res.statusCode == 201 || res.statusCode == 405) return;
    if (res.statusCode < 200 || res.statusCode >= 300) {
      throw WebDavException(
        'MKCOL failed (${res.statusCode})',
        statusCode: res.statusCode,
      );
    }
  }

  /// Parse a WebDAV multistatus (PROPFIND) body into FILE entries, dropping
  /// collections. Paths are made relative to [_baseUri]'s path and stripped of
  /// a leading slash.
  List<RemoteEntry> _parseMultiStatus(String body) {
    final doc = XmlDocument.parse(body);
    final basePath = _baseUri.path; // ends with '/'
    final entries = <RemoteEntry>[];

    for (final response in doc.findAllElements('response', namespace: '*')) {
      final href = response
          .findElements('href', namespace: '*')
          .map((e) => e.innerText.trim())
          .firstWhere((_) => true, orElse: () => '');
      if (href.isEmpty) continue;

      // href may be absolute (http://host/dav/Lecture/x.pdf) or root-relative
      // (/dav/Lecture/x.pdf). Reduce to the server path, then strip basePath.
      var hrefPath = Uri.parse(href).path;
      hrefPath = Uri.decodeFull(hrefPath);
      final decodedBase = Uri.decodeFull(basePath);
      if (!hrefPath.startsWith(decodedBase)) {
        // Some servers omit the app prefix; try a looser suffix match.
        final idx = hrefPath.indexOf(decodedBase);
        if (idx < 0) continue;
        hrefPath = hrefPath.substring(idx);
      }
      var rel = hrefPath.substring(decodedBase.length);
      final isDir = rel.endsWith('/');
      rel = rel.replaceAll(RegExp(r'^/+|/+$'), '');
      if (rel.isEmpty) continue; // the root collection itself

      final propstat = response.findElements('propstat', namespace: '*');
      DateTime? modified;
      int? size;
      String? etag;
      var collection = isDir;
      for (final ps in propstat) {
        for (final prop in ps.findElements('prop', namespace: '*')) {
          final lm = prop
              .findElements('getlastmodified', namespace: '*')
              .map((e) => e.innerText.trim())
              .firstWhere((_) => true, orElse: () => '');
          if (lm.isNotEmpty) modified = _parseHttpDate(lm);
          final cl = prop
              .findElements('getcontentlength', namespace: '*')
              .map((e) => e.innerText.trim())
              .firstWhere((_) => true, orElse: () => '');
          if (cl.isNotEmpty) size = int.tryParse(cl);
          final et = prop
              .findElements('getetag', namespace: '*')
              .map((e) => e.innerText.trim())
              .firstWhere((_) => true, orElse: () => '');
          if (et.isNotEmpty) etag = et.replaceAll('"', '');
          if (prop.findAllElements('collection', namespace: '*').isNotEmpty) {
            collection = true;
          }
        }
      }
      if (collection) continue; // sync only cares about files
      entries.add(RemoteEntry(
        path: rel,
        isDirectory: false,
        modified: modified?.toUtc(),
        size: size,
        etag: etag,
      ));
    }
    return entries;
  }

  static DateTime? _parseHttpDate(String s) {
    try {
      return parseHttpDate(s);
    } catch (_) {
      return null;
    }
  }

  Future<http.Response> _send(
    String method,
    Uri uri, {
    Map<String, String>? headers,
  }) async {
    final req = http.Request(method, uri)..headers.addAll(_headers);
    if (headers != null) req.headers.addAll(headers);
    try {
      final streamed = await _client.send(req).timeout(timeout);
      return http.Response.fromStream(streamed);
    } on WebDavException {
      rethrow;
    } catch (e) {
      throw WebDavException('Network error: $e');
    }
  }

  Future<http.Response> _get(Uri uri) async {
    try {
      return await _client.get(uri, headers: _headers).timeout(timeout);
    } catch (e) {
      throw WebDavException('Network error: $e');
    }
  }

  Future<http.Response> _put(Uri uri, Uint8List bytes) async {
    try {
      return await _client
          .put(uri, headers: _headers, body: bytes)
          .timeout(timeout);
    } catch (e) {
      throw WebDavException('Network error: $e');
    }
  }

  /// Release the underlying [http.Client] if this instance created it.
  void close() {
    if (_ownsClient) _client.close();
  }
}

/// Parse an RFC 1123 / RFC 850 / asctime HTTP-date into UTC. Kept local (rather
/// than pulling `http_parser`) since only `getlastmodified` needs it.
DateTime? parseHttpDate(String input) {
  final s = input.trim();
  // RFC 1123: "Sun, 06 Nov 1994 08:49:37 GMT"
  final months = {
    'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
    'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12,
  };
  final m = RegExp(
    r'(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})',
  ).firstMatch(s);
  if (m == null) return null;
  final day = int.parse(m.group(1)!);
  final month = months[m.group(2)!];
  if (month == null) return null;
  final year = int.parse(m.group(3)!);
  final hour = int.parse(m.group(4)!);
  final min = int.parse(m.group(5)!);
  final sec = int.parse(m.group(6)!);
  return DateTime.utc(year, month, day, hour, min, sec);
}
