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

import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/export_import/inventory_json_codec.dart';
import 'package:tane/db/database.dart';
import 'package:tane/security/secure_key_store.dart';
import 'package:tane/services/export_import_service.dart';
import 'package:tane/services/file_service.dart';

import '../support/test_support.dart';

class _FakeFiles implements FileService {
  Uint8List? saved;
  String? savedName;
  Uint8List? toPick;

  @override
  Future<String?> saveFile({
    required String suggestedName,
    required Uint8List bytes,
  }) async {
    saved = bytes;
    savedName = suggestedName;
    return '/picked/$suggestedName';
  }

  @override
  Future<Uint8List?> pickFileBytes({List<String>? allowedExtensions}) async =>
      toPick;
}

void main() {
  late AppDatabase dbA;
  late AppDatabase dbB;

  setUp(() {
    dbA = newTestDatabase();
    dbB = newTestDatabase();
  });
  tearDown(() async {
    await dbA.close();
    await dbB.close();
  });

  (ExportImportService, _FakeFiles, SecureKeyStore) newService(
    AppDatabase db, {
    String nodeId = 'node',
  }) {
    final files = _FakeFiles();
    final keys = SecureKeyStore(store: InMemorySecretStore());
    final service = ExportImportService(
      repository: newTestRepository(db, nodeId: nodeId),
      files: files,
      keys: keys,
    );
    return (service, files, keys);
  }

  test('a saved backup is sealed — no plaintext leaves the app', () async {
    final (service, files, _) = newService(dbA);
    final repo = newTestRepository(dbA);
    await repo.addQuickVariety(label: 'Secret heirloom');

    await service.exportBackup();

    expect(files.savedName, endsWith('.tanemaki'));
    expect(BackupBox.looksSealed(files.saved!), isTrue);
    expect(String.fromCharCodes(files.saved!).contains('Secret'), isFalse);
  });

  test('restore with the local seed needs no code (same identity)', () async {
    final (serviceA, filesA, keysA) = newService(dbA);
    final repoA = newTestRepository(dbA);
    await repoA.addQuickVariety(label: 'Maize');
    await serviceA.exportBackup();

    // A fresh DB but the SAME keystore = your device after a reinstall.
    final filesB = _FakeFiles()..toPick = filesA.saved;
    final serviceB = ExportImportService(
      repository: newTestRepository(dbB),
      files: filesB,
      keys: keysA,
    );
    final summary = await serviceB.restoreBackup(
      (await serviceB.pickBackup())!,
    );
    expect(summary.inserted, 1);
    final restored = await dbB.select(dbB.varieties).get();
    expect(restored.single.label, 'Maize');
  });

  test(
    'restore on a new device needs the recovery code and adopts the seed',
    () async {
      final (serviceA, filesA, keysA) = newService(dbA, nodeId: 'device-a');
      final repoA = newTestRepository(dbA, nodeId: 'device-a');
      await repoA.addQuickVariety(label: 'Grandma tomato');
      await serviceA.exportBackup();
      final code = await serviceA.recoveryCode();

      final (serviceB, filesB, keysB) = newService(dbB, nodeId: 'device-b');
      // Force device B to have its own (different) identity first.
      final seedB = await keysB.rootSeedHex();
      expect(seedB, isNot(await keysA.rootSeedHex()));

      filesB.toPick = filesA.saved;
      final pending = (await serviceB.pickBackup())!;

      // Without the code: authentication fails, nothing imported.
      await expectLater(
        serviceB.restoreBackup(pending),
        throwsA(isA<BackupAuthException>()),
      );
      expect(await dbB.select(dbB.varieties).get(), isEmpty);

      // With the printed code: data restored AND identity recovered.
      final summary = await serviceB.restoreBackup(pending, recoveryCode: code);
      expect(summary.inserted, 1);
      final restored = await dbB.select(dbB.varieties).get();
      expect(restored.single.label, 'Grandma tomato');
      expect(await keysB.rootSeedHex(), await keysA.rootSeedHex());
    },
  );

  test('a sloppy typed code (lowercase, spaces) still opens', () async {
    final (serviceA, filesA, _) = newService(dbA);
    final repoA = newTestRepository(dbA);
    await repoA.addQuickVariety(label: 'Bean');
    await serviceA.exportBackup();
    final code = await serviceA.recoveryCode();

    final (serviceB, filesB, _) = newService(dbB);
    filesB.toPick = filesA.saved;
    final pending = (await serviceB.pickBackup())!;
    final summary = await serviceB.restoreBackup(
      pending,
      recoveryCode: code.toLowerCase().replaceAll('-', ' '),
    );
    expect(summary.inserted, 1);
  });

  test('sync snapshot round-trips UNSEALED between devices, idempotently',
      () async {
    final (serviceA, _, _) = newService(dbA);
    final repoA = newTestRepository(dbA);
    await repoA.addQuickVariety(label: 'Sync tomato');

    final snapshot = await serviceA.buildSnapshot();
    // Unsealed: plain interchange JSON — the sync transport does the encryption,
    // so this must NOT be double-sealed.
    expect(BackupBox.looksSealed(Uint8List.fromList(snapshot)), isFalse);
    expect(utf8.decode(snapshot).contains('Sync tomato'), isTrue);

    final (serviceB, _, _) = newService(dbB);
    await serviceB.applySnapshot(snapshot);
    expect((await dbB.select(dbB.varieties).get()).single.label, 'Sync tomato');

    // Idempotent: re-applying the same snapshot changes nothing.
    await serviceB.applySnapshot(snapshot);
    expect(await dbB.select(dbB.varieties).get(), hasLength(1));
  });

  test('sync replicates a DELETION (tombstone), not just additions', () async {
    final (serviceA, _, _) = newService(dbA);
    final repoA = newTestRepository(dbA);
    final id = await repoA.addQuickVariety(label: 'To delete');

    // First sync: device B receives the variety, alive.
    final (serviceB, _, _) = newService(dbB);
    await serviceB.applySnapshot(await serviceA.buildSnapshot());
    var onB = (await dbB.select(dbB.varieties).get()).firstWhere((v) => v.id == id);
    expect(onB.isDeleted, isFalse);

    // A deletes it; the next sync carries the tombstone.
    await repoA.softDeleteVariety(id);
    await serviceB.applySnapshot(await serviceA.buildSnapshot());
    onB = (await dbB.select(dbB.varieties).get()).firstWhere((v) => v.id == id);
    expect(onB.isDeleted, isTrue); // deletion replicated, not resurrected
  });

  test('the sync snapshot carries NO photo bytes (photos stay device-local)',
      () async {
    final (serviceA, _, _) = newService(dbA);
    final repoA = newTestRepository(dbA);
    final id = await repoA.addQuickVariety(label: 'With photo');
    await repoA.addPhoto(id, Uint8List.fromList(List.filled(5000, 42)));

    final syncBytes = await serviceA.buildSnapshot();
    expect(utf8.decode(syncBytes).contains('bytesBase64'), isFalse);
    expect(syncBytes.length, lessThan(5000)); // the 5 KB photo isn't in it

    // A sealed BACKUP, by contrast, still embeds the photo.
    final backup = await serviceA.buildSealedBackup();
    expect(backup.length, greaterThan(5000));
  });

  test('legacy plain exports still restore (no seal, direct parse)', () async {
    final repoA = newTestRepository(dbA);
    await repoA.addQuickVariety(label: 'Old export');
    final legacy = utf8.encode(
      const InventoryJsonCodec().encode(await repoA.exportInventory()),
    );

    final (serviceB, filesB, _) = newService(dbB);
    filesB.toPick = Uint8List.fromList(legacy);
    final summary = await serviceB.restoreBackup(
      (await serviceB.pickBackup())!,
    );
    expect(summary.inserted, 1);
  });
}
