// lib/services/webdav_sync_service.dart
//
// Two-way sync of the BadNote vault folder <-> a user-configured WebDAV remote.
// The vault is a flat folder of notebook subfolders, each holding a source file
// plus its `<file>.badnote.json` sidecar; sync operates on the FILES only and
// never touches editors or storage formats.
//
// DESIGN: all sync DECISION logic (per-file winner, conflict handling, path
// mapping, last-synced bookkeeping) lives here and is injected with a
// [WebDavClient]. Tests drive it with an in-memory FAKE client; production wires
// an [HttpWebDavClient]. The real network round-trip is device/server-validated
// only (no WebDAV server in CI).
//
// ALGORITHM (per file, keyed by its vault-root-relative path):
//   Let L = local state (exists? mtime), R = remote state (exists? mtime),
//   and B = the per-file LAST-SYNCED baseline we stored after the previous sync
//   (the mtime we last reconciled to, or absent for never-synced files).
//
//   * local-only  (L, !R)                     -> upload L (create remote dirs)
//   * remote-only (!L, R):
//       - known-before (B present)            -> remote was DELETED by peer? We
//                                                do NOT delete locally (conservative);
//                                                we re-UPLOAD to restore. [see note]
//       - never-seen  (B absent)              -> download R
//   * both exist (L, R):
//       - localChanged  = L.mtime  != B.mtime (or B absent)
//       - remoteChanged = R.mtime  != B.mtime (or B absent)
//       - neither changed                      -> skip
//       - only local changed                   -> upload L
//       - only remote changed                  -> download R
//       - BOTH changed (true conflict)         -> last-write-wins by mtime:
//             keep the WINNER as the canonical file, write the LOSER's bytes to
//             `<file>.conflict-<winnerMtimeMillis>` on BOTH sides so NO data is
//             lost, then converge canonical (upload or download as needed).
//   After acting, record the converged mtime as the new baseline B.
//
// Deletes are handled CONSERVATIVELY: we never delete a file on either side as a
// result of sync. A file vanishing on one side is treated as "restore from the
// other side" rather than "propagate the delete", because we cannot distinguish
// an intentional delete from a half-finished transfer without a tombstone log
// (a deliberate TODO — see report).

import 'dart:convert';
import 'dart:io';

import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path/path.dart' as p;
import 'package:shared_preferences/shared_preferences.dart';

import 'webdav_client.dart';

/// Persisted WebDAV connection + sync configuration.
class WebDavConfig {
  const WebDavConfig({
    required this.baseUrl,
    required this.username,
    required this.password,
    this.remoteRoot = '',
    this.autoSync = false,
  });

  final String baseUrl;
  final String username;
  final String password;

  /// Folder under the server's WebDAV root to sync into, e.g. `BadNote`.
  final String remoteRoot;

  /// When true, sync runs on launch/resume (non-blocking, failure-tolerant).
  final bool autoSync;

  /// True once enough is set to attempt a sync (URL present).
  bool get isConfigured => baseUrl.trim().isNotEmpty;

  WebDavConfig copyWith({
    String? baseUrl,
    String? username,
    String? password,
    String? remoteRoot,
    bool? autoSync,
  }) {
    return WebDavConfig(
      baseUrl: baseUrl ?? this.baseUrl,
      username: username ?? this.username,
      password: password ?? this.password,
      remoteRoot: remoteRoot ?? this.remoteRoot,
      autoSync: autoSync ?? this.autoSync,
    );
  }
}

/// Outcome of a [WebDavSyncService.syncNow] run, surfaced to the UI.
class SyncResult {
  const SyncResult({
    this.uploaded = 0,
    this.downloaded = 0,
    this.conflicts = 0,
    this.skipped = 0,
    this.finishedAt,
    this.error,
  });

  final int uploaded;
  final int downloaded;
  final int conflicts;
  final int skipped;
  final DateTime? finishedAt;

  /// Friendly error message when the run failed wholesale; null on success.
  final String? error;

  bool get ok => error == null;
}

/// What to do with one file after comparing local/remote/baseline.
@visibleForTesting
enum SyncAction { skip, upload, download, conflict }

/// A single planned per-file decision (exposed for testing the algorithm).
@visibleForTesting
class SyncDecision {
  const SyncDecision(this.relPath, this.action, {this.conflictWinnerIsLocal});
  final String relPath;
  final SyncAction action;

  /// For [SyncAction.conflict]: true if the LOCAL copy won (newer) and the
  /// remote copy is the loser kept as `.conflict-*`; false if remote won.
  final bool? conflictWinnerIsLocal;
}

/// Per-file last-synced baseline. After each sync we record BOTH sides'
/// observed mtimes, because an upload makes the server stamp its OWN mtime (≠
/// the local one) — a single shared timestamp would then look "changed" on the
/// next run. Comparing each side to its own baseline avoids that false conflict.
@visibleForTesting
class SyncBaseline {
  const SyncBaseline({this.localMtime, this.remoteMtime});
  final DateTime? localMtime;
  final DateTime? remoteMtime;
}

/// Compact local/remote snapshot of a file for the decision function.
@visibleForTesting
class FileFacts {
  const FileFacts({
    required this.relPath,
    required this.localMtime,
    required this.remoteMtime,
    this.baseline,
  });

  final String relPath;

  /// Local file mtime (UTC, whole-second), or null if the file is absent.
  final DateTime? localMtime;

  /// Remote file mtime (UTC, whole-second), or null if absent.
  final DateTime? remoteMtime;

  /// Per-side mtimes recorded after the last successful sync, or null if never.
  final SyncBaseline? baseline;
}

class WebDavSyncService {
  WebDavSyncService(this._prefs);

  final SharedPreferences _prefs;

  static const String _kBaseUrl = 'webdav.baseUrl';
  static const String _kUsername = 'webdav.username';
  static const String _kPassword = 'webdav.password';
  static const String _kRemoteRoot = 'webdav.remoteRoot';
  static const String _kAutoSync = 'webdav.autoSync';
  static const String _kLastSyncMillis = 'webdav.lastSyncMillis';

  /// JSON map { relPath: {l: localMillis, r: remoteMillis} } persisted across
  /// runs — the per-file, per-side baseline that lets us detect which side
  /// changed since the last successful sync.
  static const String _kBaselineJson = 'webdav.baseline';

  /// Suffix marking a kept conflict loser. The trailing timestamp keeps repeated
  /// conflicts from clobbering each other.
  static const String conflictMarker = '.conflict-';

  // ---- Configuration (SharedPreferences-backed) -------------------------

  WebDavConfig get config => WebDavConfig(
        baseUrl: _prefs.getString(_kBaseUrl) ?? '',
        username: _prefs.getString(_kUsername) ?? '',
        password: _prefs.getString(_kPassword) ?? '',
        remoteRoot: _prefs.getString(_kRemoteRoot) ?? '',
        autoSync: _prefs.getBool(_kAutoSync) ?? false,
      );

  Future<void> saveConfig(WebDavConfig c) async {
    await _prefs.setString(_kBaseUrl, c.baseUrl.trim());
    await _prefs.setString(_kUsername, c.username);
    await _prefs.setString(_kPassword, c.password);
    await _prefs.setString(_kRemoteRoot, c.remoteRoot.trim());
    await _prefs.setBool(_kAutoSync, c.autoSync);
  }

  DateTime? get lastSyncTime {
    final millis = _prefs.getInt(_kLastSyncMillis);
    return millis == null ? null : DateTime.fromMillisecondsSinceEpoch(millis);
  }

  // ---- Baseline map -----------------------------------------------------

  Map<String, SyncBaseline> _readBaseline() {
    final raw = _prefs.getString(_kBaselineJson);
    if (raw == null || raw.isEmpty) return {};
    try {
      final decoded = jsonDecode(raw) as Map<String, dynamic>;
      return decoded.map((k, v) {
        final m = v as Map<String, dynamic>;
        final lm = m['l'] as int?;
        final rm = m['r'] as int?;
        return MapEntry(
          k,
          SyncBaseline(
            localMtime:
                lm == null ? null : DateTime.fromMillisecondsSinceEpoch(lm),
            remoteMtime:
                rm == null ? null : DateTime.fromMillisecondsSinceEpoch(rm),
          ),
        );
      });
    } catch (_) {
      return {};
    }
  }

  Future<void> _writeBaseline(Map<String, SyncBaseline> baseline) async {
    final encoded = jsonEncode(baseline.map((k, v) => MapEntry(k, {
          'l': v.localMtime?.millisecondsSinceEpoch,
          'r': v.remoteMtime?.millisecondsSinceEpoch,
        })));
    await _prefs.setString(_kBaselineJson, encoded);
  }

  // ---- The pure decision function (unit-tested) -------------------------

  /// Decide what to do for one file from its [FileFacts]. Pure: no I/O. This is
  /// the heart of the algorithm and is exercised directly by the tests.
  ///
  /// Mtimes are compared at whole-second granularity (WebDAV `getlastmodified`
  /// has 1-second resolution); callers must truncate before passing facts in.
  @visibleForTesting
  static SyncDecision decide(FileFacts f) {
    final l = f.localMtime;
    final r = f.remoteMtime;
    final bl = f.baseline?.localMtime;
    final br = f.baseline?.remoteMtime;

    if (l != null && r == null) {
      // Local-only: either brand new locally, or remote vanished. Either way we
      // (re)upload — never delete the local file.
      return SyncDecision(f.relPath, SyncAction.upload);
    }
    if (l == null && r != null) {
      // Never synced OR previously known but now gone locally — in both cases we
      // conservatively pull from remote rather than propagating a delete.
      return SyncDecision(f.relPath, SyncAction.download);
    }
    if (l == null && r == null) {
      return SyncDecision(f.relPath, SyncAction.skip);
    }

    // Both sides have the file. Compare each side to ITS OWN baseline so an
    // upload-stamped remote mtime isn't mistaken for a remote edit.
    final localChanged = bl == null || !_sameSecond(l!, bl);
    final remoteChanged = br == null || !_sameSecond(r!, br);

    if (!localChanged && !remoteChanged) {
      return SyncDecision(f.relPath, SyncAction.skip);
    }
    if (localChanged && !remoteChanged) {
      return SyncDecision(f.relPath, SyncAction.upload);
    }
    if (!localChanged && remoteChanged) {
      return SyncDecision(f.relPath, SyncAction.download);
    }
    // Both changed since baseline -> true conflict. Newer mtime wins.
    final localWins = !l!.isBefore(r!); // ties resolve to local (keep working copy)
    return SyncDecision(
      f.relPath,
      SyncAction.conflict,
      conflictWinnerIsLocal: localWins,
    );
  }

  static bool _sameSecond(DateTime a, DateTime b) =>
      a.toUtc().millisecondsSinceEpoch ~/ 1000 ==
      b.toUtc().millisecondsSinceEpoch ~/ 1000;

  // ---- Path mapping (vault <-> remote), unit-tested ---------------------

  /// Map a vault-root-relative path (OS separators) to a forward-slashed remote
  /// path. e.g. on Windows `Lecture\Lecture.pdf` -> `Lecture/Lecture.pdf`.
  @visibleForTesting
  static String toRemotePath(String relPath) =>
      p.split(relPath).where((s) => s.isNotEmpty).join('/');

  /// Map a forward-slashed remote path back to a vault-root-relative path using
  /// OS separators.
  @visibleForTesting
  static String toLocalRelPath(String remotePath) =>
      p.joinAll(remotePath.split('/').where((s) => s.isNotEmpty));

  /// True for files sync must IGNORE: sidecar temp/backup artifacts and our own
  /// conflict copies (conflict copies stay local; they are not re-synced as if
  /// canonical, but ARE uploaded as plain new files if the user keeps them).
  @visibleForTesting
  static bool isSyncable(String relPath) {
    final name = p.basename(relPath);
    if (name.startsWith('.')) return false; // hidden / .badnote metadata
    if (name.endsWith('.tmp') || name.endsWith('.bak')) return false;
    return true;
  }

  // ---- The orchestrator (I/O; device/server-validated) ------------------

  /// Run a full two-way sync of [vaultRoot] against [client]. Pure decisions
  /// from [decide] drive uploads/downloads; conflicts keep the loser as a
  /// `.conflict-*` copy on both sides. Returns counts for the UI; on a wholesale
  /// failure returns a [SyncResult] with [SyncResult.error] set (never throws).
  Future<SyncResult> syncNow({
    required String vaultRoot,
    required WebDavClient client,
  }) async {
    try {
      final root = Directory(vaultRoot);
      if (!await root.exists()) {
        return const SyncResult(error: 'Vault folder not found');
      }

      // 1. Snapshot both sides keyed by vault-relative path.
      final localFiles = await _scanLocal(root);
      final remoteList = await client.list('');
      final remoteFiles = <String, RemoteEntry>{};
      for (final e in remoteList) {
        final rel = toLocalRelPath(e.path);
        if (isSyncable(rel)) remoteFiles[rel] = e;
      }

      final baseline = _readBaseline();

      var uploaded = 0;
      var downloaded = 0;
      var conflicts = 0;
      var skipped = 0;

      // Rel paths that ended up converged (and so deserve a fresh baseline).
      final converged = <String>{};

      final allPaths = <String>{...localFiles.keys, ...remoteFiles.keys};

      for (final rel in allPaths) {
        final localMtime = localFiles[rel];
        final remoteEntry = remoteFiles[rel];
        final facts = FileFacts(
          relPath: rel,
          localMtime: localMtime == null ? null : _truncate(localMtime),
          remoteMtime: remoteEntry?.modified == null
              ? null
              : _truncate(remoteEntry!.modified!),
          baseline: baseline[rel],
        );
        final decision = decide(facts);

        switch (decision.action) {
          case SyncAction.skip:
            skipped++;
            converged.add(rel);
            break;

          case SyncAction.upload:
            await _doUpload(root, client, rel);
            uploaded++;
            converged.add(rel);
            break;

          case SyncAction.download:
            await _doDownload(root, client, rel, remoteEntry!.modified);
            downloaded++;
            converged.add(rel);
            break;

          case SyncAction.conflict:
            await _doConflict(
              root,
              client,
              rel,
              localWins: decision.conflictWinnerIsLocal == true,
              localMtime: localMtime!,
              remoteMtime: remoteEntry!.modified,
            );
            conflicts++;
            converged.add(rel);
            break;
        }
      }

      // Re-snapshot both sides so the new baseline records each side's ACTUAL
      // post-sync mtime (uploads make the server stamp its own mtime). Comparing
      // each side to its own baseline next time avoids a false "remote changed".
      final finalLocal = await _scanLocal(root);
      final finalRemote = <String, DateTime?>{};
      for (final e in await client.list('')) {
        final r = toLocalRelPath(e.path);
        if (isSyncable(r)) finalRemote[r] = e.modified;
      }
      final newBaseline = <String, SyncBaseline>{};
      for (final rel in converged) {
        final lm = finalLocal[rel];
        final rm = finalRemote[rel];
        // Only keep a baseline once a file exists on BOTH sides; a one-sided
        // file (mid-restore) stays "new" so the next run finishes converging it.
        if (lm != null && rm != null) {
          newBaseline[rel] = SyncBaseline(
            localMtime: _truncate(lm),
            remoteMtime: _truncate(rm),
          );
        }
      }

      await _writeBaseline(newBaseline);
      final finishedAt = DateTime.now();
      await _prefs.setInt(_kLastSyncMillis, finishedAt.millisecondsSinceEpoch);

      return SyncResult(
        uploaded: uploaded,
        downloaded: downloaded,
        conflicts: conflicts,
        skipped: skipped,
        finishedAt: finishedAt,
      );
    } on WebDavException catch (e) {
      return SyncResult(error: e.message);
    } catch (e) {
      return SyncResult(error: e.toString());
    }
  }

  /// Recursively collect every syncable file under [root], keyed by its
  /// vault-root-relative path, mapped to its mtime.
  Future<Map<String, DateTime>> _scanLocal(Directory root) async {
    final out = <String, DateTime>{};
    await for (final entity in root.list(recursive: true, followLinks: false)) {
      if (entity is! File) continue;
      final rel = p.relative(entity.path, from: root.path);
      // Skip anything inside a hidden folder (e.g. .badnote) or hidden file.
      if (p.split(rel).any((seg) => seg.startsWith('.'))) continue;
      if (!isSyncable(rel)) continue;
      final stat = await entity.stat();
      out[rel] = stat.modified;
    }
    return out;
  }

  /// Upload the local file at [rel], creating remote parent collections.
  Future<void> _doUpload(Directory root, WebDavClient client, String rel) async {
    final file = File(p.join(root.path, rel));
    final bytes = await file.readAsBytes();
    await _ensureRemoteDirs(client, rel);
    await client.upload(toRemotePath(rel), bytes);
  }

  /// Download the remote file at [rel] into the vault, creating local parent
  /// dirs. Sets the local mtime to the remote's so the next run sees no drift.
  Future<void> _doDownload(
    Directory root,
    WebDavClient client,
    String rel,
    DateTime? remoteMtime,
  ) async {
    final bytes = await client.download(toRemotePath(rel));
    final file = File(p.join(root.path, rel));
    await file.parent.create(recursive: true);
    await file.writeAsBytes(bytes, flush: true);
    if (remoteMtime != null) {
      try {
        await file.setLastModified(remoteMtime);
      } catch (_) {
        // Some filesystems reject setLastModified; the post-sync re-snapshot
        // captures whatever mtime landed, so convergence still holds.
      }
    }
  }

  /// Resolve a true conflict: keep the loser as `<file>.conflict-<winnerMillis>`
  /// on BOTH sides (no data lost), then converge the canonical to the winner.
  Future<void> _doConflict(
    Directory root,
    WebDavClient client,
    String rel, {
    required bool localWins,
    required DateTime localMtime,
    required DateTime? remoteMtime,
  }) async {
    final winnerMtime = localWins ? localMtime : (remoteMtime ?? localMtime);
    final stamp = _truncate(winnerMtime).millisecondsSinceEpoch;
    final conflictRel = '$rel$conflictMarker$stamp';

    final localFile = File(p.join(root.path, rel));
    final remoteBytes = await client.download(toRemotePath(rel));

    if (localWins) {
      // Local is canonical. Save the REMOTE bytes as the local conflict copy,
      // upload that conflict copy remotely too, then push local up as canonical.
      final conflictFile = File(p.join(root.path, conflictRel));
      await conflictFile.parent.create(recursive: true);
      await conflictFile.writeAsBytes(remoteBytes, flush: true);

      await _ensureRemoteDirs(client, conflictRel);
      await client.upload(toRemotePath(conflictRel), remoteBytes);

      final localBytes = await localFile.readAsBytes();
      await _ensureRemoteDirs(client, rel);
      await client.upload(toRemotePath(rel), localBytes);
    } else {
      // Remote is canonical. Save the LOCAL bytes as the local conflict copy
      // and push it remotely, then overwrite local with the remote (winner).
      final localBytes = await localFile.readAsBytes();
      final conflictFile = File(p.join(root.path, conflictRel));
      await conflictFile.parent.create(recursive: true);
      await conflictFile.writeAsBytes(localBytes, flush: true);

      await _ensureRemoteDirs(client, conflictRel);
      await client.upload(toRemotePath(conflictRel), localBytes);

      await localFile.writeAsBytes(remoteBytes, flush: true);
      if (remoteMtime != null) {
        try {
          await localFile.setLastModified(remoteMtime);
        } catch (_) {}
      }
    }
  }

  /// Create each remote parent collection of [rel] from the root down (MKCOL is
  /// idempotent), so an upload never 409s on a missing directory.
  Future<void> _ensureRemoteDirs(WebDavClient client, String rel) async {
    final segments = p.split(rel).where((s) => s.isNotEmpty).toList();
    if (segments.length <= 1) return; // file at root, no dirs needed
    var acc = '';
    for (var i = 0; i < segments.length - 1; i++) {
      acc = acc.isEmpty ? segments[i] : '$acc/${segments[i]}';
      await client.makeCollection(acc);
    }
  }

  static DateTime _truncate(DateTime t) => DateTime.fromMillisecondsSinceEpoch(
        (t.toUtc().millisecondsSinceEpoch ~/ 1000) * 1000,
        isUtc: true,
      );

  // ---- Convenience: build a real client from the saved config -----------

  /// Construct an [HttpWebDavClient] from the persisted [config], or null when
  /// unconfigured (caller disables the sync button). Caller owns close().
  HttpWebDavClient? buildClient() {
    final c = config;
    if (!c.isConfigured) return null;
    return HttpWebDavClient(
      baseUrl: c.baseUrl,
      username: c.username,
      password: c.password,
      remoteRoot: c.remoteRoot,
    );
  }
}
