import 'dart:typed_data';

import 'package:commons_core/commons_core.dart';
import 'package:drift/drift.dart' show Value;
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/export_import/inventory_json_codec.dart';
import 'package:tane/data/species_repository.dart';
import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
import 'package:tane/domain/crop_calendar.dart';

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

void main() {
  const codec = InventoryJsonCodec();
  const tomato = SpeciesSeed(
    scientificName: 'Solanum lycopersicum',
    family: 'Solanaceae',
  );

  late AppDatabase sourceDb;
  late VarietyRepository source;

  setUp(() {
    sourceDb = newTestDatabase();
    source = newTestRepository(sourceDb, nodeId: 'node-source');
  });
  tearDown(() => sourceDb.close());

  /// Populates the source DB with one of everything and returns the ids.
  Future<({String varietyId, String lotId})> populate() async {
    await newTestSpeciesRepository(sourceDb).seedBundled(const [tomato]);
    final varietyId = await source.addQuickVariety(
      label: 'Grandma tomato',
      category: 'Solanaceae',
      photoBytes: Uint8List.fromList([10, 20, 30]),
    );
    final species = await sourceDb.select(sourceDb.species).getSingle();
    await source.linkSpecies(varietyId, species.id);
    await source.updateVariety(
      id: varietyId,
      label: 'Grandma tomato',
      category: 'Solanaceae',
      notes: 'Sow in March',
      needsReproduction: true,
      // Sow in spring and again in autumn (a phase can span several months).
      sowMonths: Value(monthsToMask(const [3, 4, 9])),
      transplantMonths: Value(monthsToMask(const [5])),
      floweringMonths: Value(monthsToMask(const [7])),
      fruitingMonths: Value(monthsToMask(const [8])),
      seedHarvestMonths: Value(monthsToMask(const [9, 10])),
    );
    final lotId = await source.addLot(
      varietyId: varietyId,
      harvestYear: 2024,
      harvestMonth: 6,
      quantity: const Quantity(kind: QuantityKind.cob, count: 2),
      storageLocation: 'fridge',
      originName: 'La Verde Coop',
      originPlace: 'Cádiz',
      abundance: Abundance.plentyToShare,
      preservationFormat: PreservationFormat.jarWithDesiccant,
    );
    await source.addConditionCheck(
      lotId: lotId,
      checkedOn: 1700000001000,
      containerCount: 2,
      desiccantState: DesiccantState.dry,
    );
    await source.addVernacularName(
      varietyId,
      'tomate de la abuela',
      language: 'es',
    );
    await source.addExternalLink(
      varietyId,
      'https://example.org/tomato',
      title: 'Wiki',
    );
    await source.addGerminationTest(
      lotId: lotId,
      testedOn: 1700000000000,
      sampleSize: 10,
      germinatedCount: 8,
    );
    // A second photo, then promote it to cover so sortOrder must survive.
    final cover = await source.addPhoto(
      varietyId,
      Uint8List.fromList([40, 50, 60]),
    );
    await source.setPreferredPhoto(varietyId, cover);
    return (varietyId: varietyId, lotId: lotId);
  }

  test(
    'export → import into a fresh database restores the identical state',
    () async {
      await populate();
      final json = codec.encode(await source.exportInventory());

      final targetDb = newTestDatabase();
      addTearDown(targetDb.close);
      // The target install has its own bundled catalog (different species id).
      await newTestSpeciesRepository(targetDb).seedBundled(const [tomato]);
      final target = newTestRepository(targetDb, nodeId: 'node-target');

      final summary = await target.importInventory(codec.decode(json));
      expect(summary.inserted, greaterThan(0));
      expect(summary.updated, 0);

      // Everything round-trips identically, except speciesId which is
      // re-resolved against the target's own catalog by scientific name.
      final sourceRows = await source.exportInventory();
      final targetRows = await target.exportInventory();
      expect(
        targetRows.varieties.map(
          (v) => v.copyWith(speciesId: const Value(null)),
        ),
        sourceRows.varieties.map(
          (v) => v.copyWith(speciesId: const Value(null)),
        ),
      );
      expect(targetRows.lots, sourceRows.lots);
      expect(targetRows.vernacularNames, sourceRows.vernacularNames);
      expect(targetRows.externalLinks, sourceRows.externalLinks);
      expect(targetRows.germinationTests, sourceRows.germinationTests);
      expect(targetRows.conditionChecks, sourceRows.conditionChecks);
      expect(targetRows.movements, sourceRows.movements);
      expect(targetRows.attachments, sourceRows.attachments);

      // The species link points at the target's own catalog row.
      final targetSpecies = await targetDb.select(targetDb.species).getSingle();
      expect(targetRows.varieties.single.speciesId, targetSpecies.id);

      // Photo bytes and cover order survived.
      final detail = await target
          .watchVariety(targetRows.varieties.single.id)
          .first;
      expect(detail!.photos.first.bytes, [40, 50, 60]);
      expect(detail.photos.last.bytes, [10, 20, 30]);
    },
  );

  test('re-importing the same file is a no-op (idempotent)', () async {
    await populate();
    final json = codec.encode(await source.exportInventory());
    final snapshot = codec.decode(json);

    final targetDb = newTestDatabase();
    addTearDown(targetDb.close);
    final target = newTestRepository(targetDb, nodeId: 'node-target');

    await target.importInventory(snapshot);
    final again = await target.importInventory(codec.decode(json));
    expect(again.inserted, 0);
    expect(again.updated, 0);
    expect(again.skipped, greaterThan(0));

    final varieties = await targetDb.select(targetDb.varieties).get();
    expect(varieties, hasLength(1)); // no duplicates
  });

  test('import does not overwrite local edits that are newer', () async {
    final ids = await populate();
    final json = codec.encode(await source.exportInventory());

    // The local edit happens after the export was taken.
    await source.updateVariety(
      id: ids.varietyId,
      label: 'Renamed after export',
      category: 'Solanaceae',
      notes: 'Sow in March',
    );

    final summary = await source.importInventory(codec.decode(json));
    expect(summary.updated, 0);

    final variety = await (sourceDb.select(
      sourceDb.varieties,
    )..where((v) => v.id.equals(ids.varietyId))).getSingle();
    expect(variety.label, 'Renamed after export');
  });

  test('import overwrites when the incoming row is newer (LWW)', () async {
    final ids = await populate();

    // Fork: a second device imports this inventory, edits the variety…
    final json = codec.encode(await source.exportInventory());
    final otherDb = newTestDatabase();
    addTearDown(otherDb.close);
    final other = newTestRepository(otherDb, nodeId: 'node-other');
    await other.importInventory(codec.decode(json));
    await other.updateVariety(
      id: ids.varietyId,
      label: 'Edited elsewhere',
      category: 'Solanaceae',
      notes: 'Sow in March',
    );

    // …and its export comes back: the newer edit wins here.
    final back = codec.encode(await other.exportInventory());
    final summary = await source.importInventory(codec.decode(back));
    expect(summary.updated, greaterThan(0));

    final variety = await (sourceDb.select(
      sourceDb.varieties,
    )..where((v) => v.id.equals(ids.varietyId))).getSingle();
    expect(variety.label, 'Edited elsewhere');
    expect(variety.lastAuthor, 'node-other');
  });

  test('import overwrites a lot edited elsewhere (LWW)', () async {
    final ids = await populate();

    // Fork: a second device imports this inventory, edits the lot…
    final json = codec.encode(await source.exportInventory());
    final otherDb = newTestDatabase();
    addTearDown(otherDb.close);
    final other = newTestRepository(otherDb, nodeId: 'node-other');
    await other.importInventory(codec.decode(json));
    await other.updateLot(
      lotId: ids.lotId,
      type: LotType.seed,
      harvestYear: 2025,
      quantity: const Quantity(kind: QuantityKind.cob, count: 9),
      storageLocation: 'cellar',
    );

    // …and its export comes back: the newer edit wins here.
    final back = codec.encode(await other.exportInventory());
    final summary = await source.importInventory(codec.decode(back));
    expect(summary.updated, greaterThan(0));

    final lot = await (sourceDb.select(
      sourceDb.lots,
    )..where((l) => l.id.equals(ids.lotId))).getSingle();
    expect(lot.harvestYear, 2025);
    expect(lot.quantityPrecise, 9);
    expect(lot.storageLocation, 'cellar');
    expect(lot.lastAuthor, 'node-other');
  });

  test('after an import the local clock never stamps behind it', () async {
    await populate();
    final json = codec.encode(await source.exportInventory());
    final snapshot = codec.decode(json);

    // A target whose wall clock is frozen in the past.
    final targetDb = newTestDatabase();
    addTearDown(targetDb.close);
    final target = VarietyRepository(
      targetDb,
      idGen: IdGen(),
      nodeId: 'node-past',
      nowMillis: () => 1000,
    );
    await target.importInventory(snapshot);
    await target.addQuickVariety(label: 'Written after import');

    final maxImported = snapshot.varieties
        .map((v) => Hlc.parse(v.updatedAt))
        .reduce((a, b) => a.compareTo(b) > 0 ? a : b);
    final written = await (targetDb.select(
      targetDb.varieties,
    )..where((v) => v.label.equals('Written after import'))).getSingle();
    expect(Hlc.parse(written.updatedAt).compareTo(maxImported), greaterThan(0));
  });

  test('a restarted repository re-seeds its clock from stored rows so local '
      'edits still win LWW (survives a lagging wall clock)', () async {
    // A peer device whose wall clock runs far ahead writes the inventory.
    final peerDb = newTestDatabase();
    addTearDown(peerDb.close);
    final peer = VarietyRepository(
      peerDb,
      idGen: IdGen(),
      nodeId: 'peer',
      nowMillis: () => 9000000000000, // ~year 2255
    );
    final varietyId = await peer.addQuickVariety(label: 'Maize');
    final lotId = await peer.addLot(varietyId: varietyId, harvestYear: 2024);
    final json = codec.encode(await peer.exportInventory());

    // Our device (wall clock in the real, much earlier present) imports it.
    final myDb = newTestDatabase();
    addTearDown(myDb.close);
    const myWall = 1000; // far behind the imported stamps
    final importing = VarietyRepository(
      myDb,
      idGen: IdGen(),
      nodeId: 'mine',
      nowMillis: () => myWall,
    );
    await importing.importInventory(codec.decode(json));

    // App restart: a brand-new repository instance over the same DB, whose
    // in-memory clock starts fresh at Hlc.zero and whose wall clock lags.
    final restarted = VarietyRepository(
      myDb,
      idGen: IdGen(),
      nodeId: 'mine',
      nowMillis: () => myWall,
    );
    final before = await _lotStamp(myDb, lotId);
    await restarted.updateLot(
      lotId: lotId,
      type: LotType.seed,
      harvestYear: 2030,
    );
    final after = await _lotStamp(myDb, lotId);

    // The edit must out-stamp the imported row, or it would silently lose
    // last-writer-wins when this device shares its backup back.
    expect(Hlc.parse(after).compareTo(Hlc.parse(before)), greaterThan(0));
  });

  test('unmatched species leaves speciesId null instead of dangling', () async {
    await populate();
    final json = codec.encode(await source.exportInventory());

    final targetDb = newTestDatabase();
    addTearDown(targetDb.close);
    // No bundled catalog in the target: the name cannot be resolved.
    final target = newTestRepository(targetDb, nodeId: 'node-target');
    await target.importInventory(codec.decode(json));

    final variety = await targetDb.select(targetDb.varieties).getSingle();
    expect(variety.speciesId, isNull);
  });
}

/// The packed HLC `updatedAt` currently stored for [lotId].
Future<String> _lotStamp(AppDatabase db, String lotId) async {
  final lot = await (db.select(
    db.lots,
  )..where((l) => l.id.equals(lotId))).getSingle();
  return lot.updatedAt;
}
