// Proves the WebDAV sync ALGORITHM against a FAKE in-memory WebDavClient (no
// real server — the HttpWebDavClient adapter is device/server-validated only).
//
// Covers: the pure per-file decision (decide), vault<->remote path mapping, and
// the full syncNow orchestration — newer-local→upload, newer-remote→download,
// both-changed→.conflict kept (no data lost), new-local→created remotely.

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

import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:shared_preferences/shared_preferences.dart';

import 'package:badnote/services/webdav_client.dart';
import 'package:badnote/services/webdav_sync_service.dart';

/// In-memory fake WebDAV server. Stores bytes + mtimes by remote path
/// (forward-slashed, relative to the remote root). Records uploads so tests can
/// assert what was pushed.
class FakeWebDavClient implements WebDavClient {
  final Map<String, Uint8List> files = {};
  final Map<String, DateTime> mtimes = {};
  final Set<String> collections = {};
  final List<String> uploads = [];
  bool testConnectionThrows = false;

  /// Seed a remote file with bytes + an mtime.
  void seed(String remotePath, String content, DateTime mtime) {
    files[remotePath] = Uint8List.fromList(utf8.encode(content));
    mtimes[remotePath] = mtime.toUtc();
  }

  @override
  Future<List<RemoteEntry>> list(String remoteDir) async {
    return files.keys
        .map((path) => RemoteEntry(
              path: path,
              isDirectory: false,
              modified: mtimes[path],
              size: files[path]!.length,
            ))
        .toList();
  }

  @override
  Future<Uint8List> download(String remotePath) async {
    final bytes = files[remotePath];
    if (bytes == null) {
      throw WebDavException('not found: $remotePath', statusCode: 404);
    }
    return bytes;
  }

  @override
  Future<void> upload(String remotePath, Uint8List bytes) async {
    files[remotePath] = bytes;
    mtimes[remotePath] = DateTime.now().toUtc();
    uploads.add(remotePath);
  }

  @override
  Future<void> makeCollection(String remotePath) async {
    collections.add(remotePath);
  }

  @override
  Future<void> testConnection() async {
    if (testConnectionThrows) throw WebDavException('boom');
  }

  String? contentOf(String remotePath) {
    final b = files[remotePath];
    return b == null ? null : utf8.decode(b);
  }
}

DateTime _utc(int y, int mo, int d, [int h = 0, int mi = 0, int s = 0]) =>
    DateTime.utc(y, mo, d, h, mi, s);

void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  late Directory vaultDir;

  setUp(() async {
    SharedPreferences.setMockInitialValues({});
    vaultDir = await Directory.systemTemp.createTemp('webdav_sync_test');
  });

  tearDown(() async {
    if (await vaultDir.exists()) await vaultDir.delete(recursive: true);
  });

  Future<WebDavSyncService> makeService() async {
    final prefs = await SharedPreferences.getInstance();
    return WebDavSyncService(prefs);
  }

  Future<File> writeLocal(String rel, String content, DateTime mtime) async {
    final f = File(p.join(vaultDir.path, rel));
    await f.parent.create(recursive: true);
    await f.writeAsString(content);
    await f.setLastModified(mtime);
    return f;
  }

  String readLocal(String rel) =>
      File(p.join(vaultDir.path, rel)).readAsStringSync();

  List<String> localConflictCopies(String rel) {
    final folder = Directory(p.join(vaultDir.path, p.dirname(rel)));
    final base = p.basename(rel);
    return folder
        .listSync()
        .whereType<File>()
        .map((f) => p.basename(f.path))
        .where((n) => n.startsWith('$base${WebDavSyncService.conflictMarker}'))
        .toList();
  }

  group('decide (pure per-file decision)', () {
    SyncBaseline both(DateTime l, DateTime r) =>
        SyncBaseline(localMtime: l, remoteMtime: r);

    test('local-only → upload', () {
      final d = WebDavSyncService.decide(FileFacts(
        relPath: 'a/x.pdf',
        localMtime: _utc(2026, 1, 1),
        remoteMtime: null,
        baseline: null,
      ));
      expect(d.action, SyncAction.upload);
    });

    test('remote-only never-seen → download', () {
      final d = WebDavSyncService.decide(FileFacts(
        relPath: 'a/x.pdf',
        localMtime: null,
        remoteMtime: _utc(2026, 1, 1),
        baseline: null,
      ));
      expect(d.action, SyncAction.download);
    });

    test('remote-only but known-before → download (restore, never delete)', () {
      final d = WebDavSyncService.decide(FileFacts(
        relPath: 'a/x.pdf',
        localMtime: null,
        remoteMtime: _utc(2026, 1, 2),
        baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)),
      ));
      expect(d.action, SyncAction.download);
    });

    test('both unchanged since baseline → skip', () {
      final t = _utc(2026, 1, 1, 10);
      final d = WebDavSyncService.decide(FileFacts(
        relPath: 'a/x.pdf',
        localMtime: t,
        remoteMtime: t,
        baseline: both(t, t),
      ));
      expect(d.action, SyncAction.skip);
    });

    test('unchanged with DIFFERENT but-baselined mtimes per side → skip', () {
      // After an upload the server stamps its own mtime; comparing each side to
      // ITS baseline must still read as "no change".
      final localT = _utc(2026, 1, 1, 10);
      final remoteT = _utc(2026, 1, 1, 10, 0, 5); // server clock differs
      final d = WebDavSyncService.decide(FileFacts(
        relPath: 'a/x.pdf',
        localMtime: localT,
        remoteMtime: remoteT,
        baseline: both(localT, remoteT),
      ));
      expect(d.action, SyncAction.skip);
    });

    test('only local changed → upload', () {
      final d = WebDavSyncService.decide(FileFacts(
        relPath: 'a/x.pdf',
        localMtime: _utc(2026, 1, 2),
        remoteMtime: _utc(2026, 1, 1),
        baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)),
      ));
      expect(d.action, SyncAction.upload);
    });

    test('only remote changed → download', () {
      final d = WebDavSyncService.decide(FileFacts(
        relPath: 'a/x.pdf',
        localMtime: _utc(2026, 1, 1),
        remoteMtime: _utc(2026, 1, 2),
        baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)),
      ));
      expect(d.action, SyncAction.download);
    });

    test('both changed → conflict, newer wins (local newer)', () {
      final d = WebDavSyncService.decide(FileFacts(
        relPath: 'a/x.pdf',
        localMtime: _utc(2026, 1, 3),
        remoteMtime: _utc(2026, 1, 2),
        baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)),
      ));
      expect(d.action, SyncAction.conflict);
      expect(d.conflictWinnerIsLocal, isTrue);
    });

    test('both changed → conflict, newer wins (remote newer)', () {
      final d = WebDavSyncService.decide(FileFacts(
        relPath: 'a/x.pdf',
        localMtime: _utc(2026, 1, 2),
        remoteMtime: _utc(2026, 1, 3),
        baseline: both(_utc(2026, 1, 1), _utc(2026, 1, 1)),
      ));
      expect(d.action, SyncAction.conflict);
      expect(d.conflictWinnerIsLocal, isFalse);
    });
  });

  group('path mapping', () {
    test('toRemotePath forward-slashes the relative path', () {
      final rel = p.join('Lecture', 'Lecture.pdf');
      expect(WebDavSyncService.toRemotePath(rel), 'Lecture/Lecture.pdf');
    });

    test('toLocalRelPath round-trips with OS separators', () {
      const remote = 'Lecture/Lecture.pdf';
      final local = WebDavSyncService.toLocalRelPath(remote);
      expect(local, p.join('Lecture', 'Lecture.pdf'));
      expect(WebDavSyncService.toRemotePath(local), remote);
    });

    test('isSyncable filters tmp/bak/hidden', () {
      expect(WebDavSyncService.isSyncable('a/x.pdf'), isTrue);
      expect(WebDavSyncService.isSyncable('a/x.pdf.badnote.json'), isTrue);
      expect(WebDavSyncService.isSyncable('a/x.pdf.tmp'), isFalse);
      expect(WebDavSyncService.isSyncable('a/x.pdf.bak'), isFalse);
      expect(WebDavSyncService.isSyncable('.hidden'), isFalse);
    });
  });

  group('syncNow orchestration (fake client)', () {
    test('new-local → created remotely (upload + MKCOL)', () async {
      final sync = await makeService();
      final fake = FakeWebDavClient();
      await writeLocal('Lecture/Lecture.pdf', 'pdf-bytes', _utc(2026, 1, 1));

      final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake);

      expect(r.ok, isTrue);
      expect(r.uploaded, 1);
      expect(r.downloaded, 0);
      expect(r.conflicts, 0);
      expect(fake.contentOf('Lecture/Lecture.pdf'), 'pdf-bytes');
      // Parent collection was created before the upload.
      expect(fake.collections, contains('Lecture'));
    });

    test('remote-only → downloaded into the vault', () async {
      final sync = await makeService();
      final fake = FakeWebDavClient();
      fake.seed('Deck/Deck.pptx', 'remote-deck', _utc(2026, 2, 1));

      final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake);

      expect(r.downloaded, 1);
      expect(readLocal(p.join('Deck', 'Deck.pptx')), 'remote-deck');
    });

    test('newer-local → upload overwrites remote', () async {
      final sync = await makeService();
      final fake = FakeWebDavClient();
      // First sync establishes a shared baseline.
      await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1));
      await sync.syncNow(vaultRoot: vaultDir.path, client: fake);
      expect(fake.contentOf('N/n.json'), 'v1');

      // Local edited (newer mtime), remote untouched → upload.
      await writeLocal('N/n.json', 'v2-local', _utc(2026, 1, 5));
      final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake);

      expect(r.uploaded, 1);
      expect(r.conflicts, 0);
      expect(fake.contentOf('N/n.json'), 'v2-local');
    });

    test('newer-remote → download overwrites local', () async {
      final sync = await makeService();
      final fake = FakeWebDavClient();
      await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1));
      await sync.syncNow(vaultRoot: vaultDir.path, client: fake);

      // Remote edited (newer), local untouched → download.
      fake.seed('N/n.json', 'v2-remote', _utc(2026, 1, 6));
      final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake);

      expect(r.downloaded, 1);
      expect(r.conflicts, 0);
      expect(readLocal(p.join('N', 'n.json')), 'v2-remote');
    });

    test('both-changed → .conflict kept, no data lost (local wins)', () async {
      final sync = await makeService();
      final fake = FakeWebDavClient();
      await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1));
      await sync.syncNow(vaultRoot: vaultDir.path, client: fake);

      // BOTH sides change after the baseline; local is NEWER → local wins.
      await writeLocal('N/n.json', 'local-edit', _utc(2026, 1, 10));
      fake.seed('N/n.json', 'remote-edit', _utc(2026, 1, 8));

      final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake);

      expect(r.conflicts, 1);
      // Canonical is the winner (local) on BOTH sides.
      expect(readLocal(p.join('N', 'n.json')), 'local-edit');
      expect(fake.contentOf('N/n.json'), 'local-edit');

      // The LOSER (remote bytes) is preserved as a local .conflict-* copy …
      final copies = localConflictCopies(p.join('N', 'n.json'));
      expect(copies, hasLength(1));
      expect(File(p.join(vaultDir.path, 'N', copies.single)).readAsStringSync(),
          'remote-edit');
      // … and pushed remotely too, so neither side loses the loser.
      final remoteConflict = fake.files.keys.firstWhere(
        (k) => k.contains(WebDavSyncService.conflictMarker),
        orElse: () => '',
      );
      expect(remoteConflict, isNotEmpty);
      expect(fake.contentOf(remoteConflict), 'remote-edit');
    });

    test('both-changed → .conflict kept, no data lost (remote wins)', () async {
      final sync = await makeService();
      final fake = FakeWebDavClient();
      await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1));
      await sync.syncNow(vaultRoot: vaultDir.path, client: fake);

      // BOTH change; remote is NEWER → remote wins.
      await writeLocal('N/n.json', 'local-edit', _utc(2026, 1, 8));
      fake.seed('N/n.json', 'remote-edit', _utc(2026, 1, 12));

      final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake);

      expect(r.conflicts, 1);
      // Canonical is the remote winner on both sides.
      expect(readLocal(p.join('N', 'n.json')), 'remote-edit');
      expect(fake.contentOf('N/n.json'), 'remote-edit');

      // The LOSER (local bytes) is preserved as a .conflict-* copy locally.
      final copies = localConflictCopies(p.join('N', 'n.json'));
      expect(copies, hasLength(1));
      expect(File(p.join(vaultDir.path, 'N', copies.single)).readAsStringSync(),
          'local-edit');
    });

    test('unchanged on both sides → skip, nothing re-uploaded', () async {
      final sync = await makeService();
      final fake = FakeWebDavClient();
      await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1));
      await sync.syncNow(vaultRoot: vaultDir.path, client: fake);
      fake.uploads.clear();

      final r = await sync.syncNow(vaultRoot: vaultDir.path, client: fake);
      expect(r.skipped, greaterThanOrEqualTo(1));
      expect(r.uploaded, 0);
      expect(r.downloaded, 0);
      expect(fake.uploads, isEmpty);
    });

    test('missing vault folder → friendly error, no throw', () async {
      final sync = await makeService();
      final fake = FakeWebDavClient();
      final r = await sync.syncNow(
        vaultRoot: p.join(vaultDir.path, 'does-not-exist'),
        client: fake,
      );
      expect(r.ok, isFalse);
      expect(r.error, isNotNull);
    });

    test('tmp/bak artifacts are not uploaded', () async {
      final sync = await makeService();
      final fake = FakeWebDavClient();
      await writeLocal('N/n.json', 'v1', _utc(2026, 1, 1));
      await writeLocal('N/n.json.tmp', 'partial', _utc(2026, 1, 1));
      await writeLocal('N/n.json.bak', 'backup', _utc(2026, 1, 1));

      await sync.syncNow(vaultRoot: vaultDir.path, client: fake);

      expect(fake.files.keys, contains('N/n.json'));
      expect(fake.files.keys.any((k) => k.endsWith('.tmp')), isFalse);
      expect(fake.files.keys.any((k) => k.endsWith('.bak')), isFalse);
    });
  });

  group('config persistence', () {
    test('saveConfig round-trips through SharedPreferences', () async {
      final sync = await makeService();
      await sync.saveConfig(const WebDavConfig(
        baseUrl: 'https://dav.example.com',
        username: 'me',
        password: 'secret',
        remoteRoot: 'BadNote',
        autoSync: true,
      ));
      final c = sync.config;
      expect(c.baseUrl, 'https://dav.example.com');
      expect(c.username, 'me');
      expect(c.password, 'secret');
      expect(c.remoteRoot, 'BadNote');
      expect(c.autoSync, isTrue);
      expect(c.isConfigured, isTrue);
    });

    test('buildClient returns null when unconfigured', () async {
      final sync = await makeService();
      expect(sync.buildClient(), isNull);
      expect(sync.config.isConfigured, isFalse);
    });
  });
}
