import 'package:async/async.dart';
import 'package:commons_core/commons_core.dart';
import 'package:drift/drift.dart';
import 'package:equatable/equatable.dart';

import '../db/database.dart';
import '../db/enums.dart';
import '../domain/seed_viability.dart';
import 'export_import/import_reconciler.dart';
import 'export_import/inventory_csv_codec.dart';
import 'export_import/inventory_snapshot.dart';

/// A lightweight row for the inventory list (only what the list renders).
class VarietyListItem extends Equatable {
  const VarietyListItem({
    required this.id,
    required this.label,
    this.category,
    this.scientificName,
    this.photo,
    this.lotTypes = const {},
    this.isDraft = false,
    this.isOrganic = false,
    this.needsReproduction = false,
    this.isShared = false,
    this.viability = SeedViability.unknown,
  });

  final String id;
  final String label;
  final String? category;

  /// Scientific name of the linked species, shown as the tile subtitle.
  final String? scientificName;

  /// First photo (encrypted BLOB) for the avatar, or null → show an initial.
  final Uint8List? photo;

  /// Distinct lot forms this variety currently holds (seed, plant, tree…),
  /// used to filter the list by form. Empty when it has no lots yet.
  final Set<LotType> lotTypes;

  /// True for a photo-first capture still awaiting a name (in the "to
  /// catalogue" tray, hidden from the main inventory list).
  final bool isDraft;

  /// Grower-declared organic ("eco") provenance — badged and filterable.
  final bool isOrganic;

  /// Grower-flagged "regrow this season" stewardship intent — badged and
  /// filterable, alongside the automatic viability warning.
  final bool needsReproduction;

  /// True when at least one lot is marked to give away, swap or sell — the
  /// "what I share" view filters on this.
  final bool isShared;

  /// The most-urgent viability status across this variety's seed lots
  /// (expired ranks above expiringSoon), so the list can flag what to sow or
  /// reproduce before it lapses. [SeedViability.unknown] when nothing is aging
  /// or there is no reference figure.
  final SeedViability viability;

  @override
  List<Object?> get props => [
    id,
    label,
    category,
    scientificName,
    photo,
    lotTypes,
    isDraft,
    isOrganic,
    needsReproduction,
    isShared,
    viability,
  ];
}

/// One shared lot for the printable catalog: the variety it belongs to plus
/// the batch facts a fair-goer needs (what, from when, how much, on what
/// terms). Built by [VarietyRepository.sharedCatalog].
class SharedCatalogEntry extends Equatable {
  const SharedCatalogEntry({
    required this.varietyLabel,
    required this.offerStatus,
    this.scientificName,
    this.category,
    this.type = LotType.seed,
    this.harvestYear,
    this.quantity,
    this.abundance,
  });

  final String varietyLabel;
  final String? scientificName;
  final String? category;
  final LotType type;
  final int? harvestYear;
  final Quantity? quantity;
  final Abundance? abundance;

  /// Never [OfferStatus.private] — private lots don't enter the catalog.
  final OfferStatus offerStatus;

  @override
  List<Object?> get props => [
    varietyLabel,
    scientificName,
    category,
    type,
    harvestYear,
    quantity,
    abundance,
    offerStatus,
  ];
}

/// One germination test on a lot; [rate] is derived (0..1), null when it can't
/// be computed.
class GerminationEntry extends Equatable {
  const GerminationEntry({
    required this.id,
    this.testedOn,
    this.sampleSize,
    this.germinatedCount,
    this.notes,
  });

  final String id;
  final int? testedOn; // ms since epoch
  final int? sampleSize;
  final int? germinatedCount;
  final String? notes;

  double? get rate {
    final sample = sampleSize;
    final germinated = germinatedCount;
    if (sample == null || sample <= 0 || germinated == null) return null;
    return germinated / sample;
  }

  @override
  List<Object?> get props => [id, testedOn, sampleSize, germinatedCount, notes];
}

/// One storage-condition check on a lot: how many containers held it and the
/// state of the drying agent at a point in time.
class ConditionEntry extends Equatable {
  const ConditionEntry({
    required this.id,
    this.checkedOn,
    this.containerCount,
    this.desiccantState,
    this.notes,
  });

  final String id;
  final int? checkedOn; // ms since epoch
  final int? containerCount;
  final DesiccantState? desiccantState;
  final String? notes;

  @override
  List<Object?> get props => [
    id,
    checkedOn,
    containerCount,
    desiccantState,
    notes,
  ];
}

/// One held batch of a variety, for the detail view. [germinationTests] and
/// [conditionChecks] are ordered most-recent first, so `.first` is the latest.
class VarietyLot extends Equatable {
  const VarietyLot({
    required this.id,
    this.type = LotType.seed,
    this.harvestYear,
    this.harvestMonth,
    this.quantity,
    this.presentation,
    this.storageLocation,
    this.originName,
    this.originPlace,
    this.abundance,
    this.preservationFormat,
    this.offerStatus = OfferStatus.private,
    this.germinationTests = const [],
    this.conditionChecks = const [],
  });

  final String id;
  final LotType type;
  final int? harvestYear;

  /// Optional harvest month (1..12). Only meaningful when [harvestYear] is set.
  final int? harvestMonth;
  final Quantity? quantity;

  /// How living material is packaged (pot, tray, bare-root…); null for seeds.
  final Presentation? presentation;
  final String? storageLocation;

  /// Provenance of this batch: who grew/gave it ([originName]) and where it
  /// comes from ([originPlace], with region/province). Optional free text.
  final String? originName;
  final String? originPlace;

  /// Optional coarse "how much I have" fusing amount with shareability.
  final Abundance? abundance;

  /// How the (seed) lot is physically conserved; distinct from storage location.
  final PreservationFormat? preservationFormat;

  /// Whether (and how) this batch is offered to others: kept private, to give
  /// away, to swap, or for sale. Local-only for now — publishing an offer to
  /// the network is Block 2.
  final OfferStatus offerStatus;

  final List<GerminationEntry> germinationTests;

  /// Storage-condition checks, most-recent first (`conditionChecks.first` = last).
  final List<ConditionEntry> conditionChecks;

  /// The most recent germination rate (0..1), or null if there are no tests.
  double? get latestGerminationRate =>
      germinationTests.isEmpty ? null : germinationTests.first.rate;

  @override
  List<Object?> get props => [
    id,
    type,
    harvestYear,
    harvestMonth,
    quantity,
    presentation,
    storageLocation,
    originName,
    originPlace,
    abundance,
    preservationFormat,
    offerStatus,
    germinationTests,
    conditionChecks,
  ];
}

/// One vernacular (common) name of a variety.
class VernacularName extends Equatable {
  const VernacularName({
    required this.id,
    required this.name,
    this.language,
    this.region,
  });

  final String id;
  final String name;
  final String? language;
  final String? region;

  @override
  List<Object?> get props => [id, name, language, region];
}

/// One stored photo of a variety (encrypted BLOB in the DB).
class VarietyPhoto extends Equatable {
  const VarietyPhoto({required this.id, required this.bytes});

  final String id;
  final Uint8List bytes;

  @override
  List<Object?> get props => [id, bytes];
}

/// An external URL attached to a variety (Wikipedia, a forum thread…).
class ExternalLinkItem extends Equatable {
  const ExternalLinkItem({required this.id, required this.url, this.title});

  final String id;
  final String url;
  final String? title;

  /// What to show: the title if present, otherwise the URL.
  String get display =>
      (title != null && title!.trim().isNotEmpty) ? title! : url;

  @override
  List<Object?> get props => [id, url, title];
}

/// The full detail of one variety (identity + its lots, names and photos).
class VarietyDetail extends Equatable {
  const VarietyDetail({
    required this.id,
    required this.label,
    this.category,
    this.notes,
    this.speciesId,
    this.scientificName,
    this.gbifKey,
    this.wikidataQid,
    this.viabilityYears,
    this.isOrganic = false,
    this.needsReproduction = false,
    this.sowMonths,
    this.transplantMonths,
    this.floweringMonths,
    this.fruitingMonths,
    this.seedHarvestMonths,
    this.lots = const [],
    this.vernacularNames = const [],
    this.photos = const [],
    this.links = const [],
  });

  final String id;
  final String label;
  final String? category;
  final String? notes;
  final String? speciesId;
  final String? scientificName;

  /// Bundled identifiers of the linked species, used to derive verified
  /// "learn more" reference links (GBIF/Wikipedia/Wikispecies); null when no
  /// species is linked or the catalog entry lacks the id.
  final int? gbifKey;
  final String? wikidataQid;

  /// Grower-declared organic ("eco") provenance.
  final bool isOrganic;

  /// Grower-flagged "regrow this season" stewardship intent.
  final bool needsReproduction;

  /// Advisory crop-calendar months, packed as 12-bit masks (a phase can span
  /// several months); null where unrecorded. See [Varieties.sowMonths] et al.
  /// and `domain/crop_calendar.dart` for packing.
  final int? sowMonths;
  final int? transplantMonths;
  final int? floweringMonths;
  final int? fruitingMonths;
  final int? seedHarvestMonths;

  /// True when any crop-calendar phase is recorded — so the UI shows the
  /// calendar section only when there is something in it.
  bool get hasCropCalendar =>
      sowMonths != null ||
      transplantMonths != null ||
      floweringMonths != null ||
      fruitingMonths != null ||
      seedHarvestMonths != null;

  /// Typical seed longevity (years) of the linked species, from the bundled
  /// catalog; null when no species is linked or the figure is unknown. Drives
  /// the per-lot viability warning together with each lot's harvest year.
  final int? viabilityYears;
  final List<VarietyLot> lots;
  final List<VernacularName> vernacularNames;
  final List<VarietyPhoto> photos;
  final List<ExternalLinkItem> links;

  @override
  List<Object?> get props => [
    id,
    label,
    category,
    notes,
    speciesId,
    scientificName,
    gbifKey,
    wikidataQid,
    viabilityYears,
    isOrganic,
    needsReproduction,
    sowMonths,
    transplantMonths,
    floweringMonths,
    fruitingMonths,
    seedHarvestMonths,
    lots,
    vernacularNames,
    photos,
    links,
  ];
}

/// Reads and writes the inventory. The encrypted Drift DB is the single source
/// of truth; [watchInventory] exposes a reactive stream the UI subscribes to.
///
/// Every write stamps the row with the local [Hlc] and [nodeId] so the CRDT
/// metadata is correct from day one, even though sync does not exist yet.
class VarietyRepository {
  VarietyRepository(
    this._db, {
    required this.idGen,
    required this.nodeId,
    int Function()? nowMillis,
  }) : _now = nowMillis ?? (() => DateTime.now().millisecondsSinceEpoch),
       _clock = Hlc.zero(nodeId);

  final AppDatabase _db;
  final IdGen idGen;
  final String nodeId;
  final int Function() _now;
  Hlc _clock;

  /// Emits the non-deleted inventory, ordered by category then label, each with
  /// its first photo for the avatar.
  ///
  /// Re-emits on any change to varieties, their photos (attachments) or the
  /// species catalog, so avatars and scientific names refresh reactively — a
  /// photo added on the detail screen shows up in the list right away.
  Stream<List<VarietyListItem>> watchInventory() {
    final triggers = StreamGroup.merge<void>([
      (_db.select(
        _db.varieties,
      )..where((v) => v.isDeleted.equals(false))).watch().map((_) {}),
      _db.select(_db.attachments).watch().map((_) {}),
      _db.select(_db.species).watch().map((_) {}),
      // Lots drive the form filter, so re-emit when they change too.
      _db.select(_db.lots).watch().map((_) {}),
    ]);
    return triggers.asyncMap((_) => _loadInventory());
  }

  /// The whole inventory screen in one reactive subscription: the catalogued
  /// list and the draft tray, from a single [StreamGroup]. The cubit MUST use
  /// this rather than subscribing to [watchInventory] and [watchDrafts]
  /// separately — two overlapping StreamGroups (both watching `attachments`)
  /// re-emit in a tight loop that starves the event loop and hangs widget
  /// tests' `pumpAndSettle`.
  Stream<({List<VarietyListItem> items, List<VarietyListItem> drafts})>
  watchInventoryView() {
    final triggers = StreamGroup.merge<void>([
      (_db.select(
        _db.varieties,
      )..where((v) => v.isDeleted.equals(false))).watch().map((_) {}),
      _db.select(_db.attachments).watch().map((_) {}),
      _db.select(_db.species).watch().map((_) {}),
      _db.select(_db.lots).watch().map((_) {}),
    ]);
    return triggers.asyncMap(
      (_) async => (items: await _loadInventory(), drafts: await _loadDrafts()),
    );
  }

  Future<List<VarietyListItem>> _loadInventory() async {
    final rows =
        await (_db.select(_db.varieties)
              // Drafts live in the "to catalogue" tray, not the main list.
              ..where(
                (v) => v.isDeleted.equals(false) & v.isDraft.equals(false),
              )
              ..orderBy([
                (v) => OrderingTerm(expression: v.category),
                (v) => OrderingTerm(expression: v.label),
              ]))
            .get();
    return _toListItems(rows);
  }

  /// Reactively watches the photo-first drafts awaiting a name, newest first —
  /// the "to catalogue" tray. Re-emits when a draft is added, named or its
  /// photo changes.
  Stream<List<VarietyListItem>> watchDrafts() {
    final triggers = StreamGroup.merge<void>([
      (_db.select(
        _db.varieties,
      )..where((v) => v.isDraft.equals(true))).watch().map((_) {}),
      _db.select(_db.attachments).watch().map((_) {}),
    ]);
    return triggers.asyncMap((_) => _loadDrafts());
  }

  Future<List<VarietyListItem>> _loadDrafts() async {
    final rows =
        await (_db.select(_db.varieties)
              ..where((v) => v.isDeleted.equals(false) & v.isDraft.equals(true))
              // Packed HLC sorts monotonically, so newest capture is first even
              // when two are stamped within the same wall-clock millisecond.
              ..orderBy([(v) => OrderingTerm.desc(v.updatedAt)]))
            .get();
    return _toListItems(rows);
  }

  Future<List<VarietyListItem>> _toListItems(List<Variety> rows) async {
    final varietyIds = rows.map((v) => v.id).toList();
    final speciesIds = rows.map((v) => v.speciesId).whereType<String>().toSet();
    final photos = await _firstPhotosFor(varietyIds);
    final sciNames = await _scientificNamesFor(speciesIds);
    final speciesViability = await _speciesViabilityFor(speciesIds);
    final lotTypes = await _lotTypesFor(varietyIds);
    final shared = await _sharedVarietyIdsFor(varietyIds);
    final seedYears = await _seedHarvestYearsFor(varietyIds);
    final currentYear = DateTime.fromMillisecondsSinceEpoch(_now()).year;
    return rows
        .map(
          (v) => VarietyListItem(
            id: v.id,
            label: v.label,
            category: v.category,
            scientificName: v.speciesId == null ? null : sciNames[v.speciesId],
            photo: photos[v.id],
            lotTypes: lotTypes[v.id] ?? const {},
            isDraft: v.isDraft,
            isOrganic: v.isOrganic,
            needsReproduction: v.needsReproduction,
            isShared: shared.contains(v.id),
            viability: _worstViability(
              harvestYears: seedYears[v.id] ?? const [],
              viabilityYears: v.speciesId == null
                  ? null
                  : speciesViability[v.speciesId],
              currentYear: currentYear,
            ),
          ),
        )
        .toList();
  }

  /// Maps each of [speciesIds] to its bundled viability figure (years); species
  /// without a figure are simply absent from the map.
  Future<Map<String, int>> _speciesViabilityFor(Set<String> speciesIds) async {
    if (speciesIds.isEmpty) return const {};
    final rows = await (_db.select(
      _db.species,
    )..where((s) => s.id.isIn(speciesIds))).get();
    return {
      for (final s in rows)
        if (s.viabilityYears != null) s.id: s.viabilityYears!,
    };
  }

  /// Harvest years of each variety's non-deleted seed lots (living lots don't
  /// age like dry seed). Lots without a year are omitted — they can't be judged.
  Future<Map<String, List<int>>> _seedHarvestYearsFor(
    List<String> varietyIds,
  ) async {
    if (varietyIds.isEmpty) return const {};
    final rows =
        await (_db.select(_db.lots)..where(
              (l) =>
                  l.varietyId.isIn(varietyIds) &
                  l.isDeleted.equals(false) &
                  l.type.equalsValue(LotType.seed),
            ))
            .get();
    final byVariety = <String, List<int>>{};
    for (final row in rows) {
      final year = row.harvestYear;
      if (year != null) (byVariety[row.varietyId] ??= <int>[]).add(year);
    }
    return byVariety;
  }

  /// The most-urgent viability across a variety's seed lots (expired outranks
  /// expiring-soon), so the list surfaces the worst case at a glance.
  SeedViability _worstViability({
    required List<int> harvestYears,
    required int? viabilityYears,
    required int currentYear,
  }) {
    var worst = SeedViability.unknown;
    for (final year in harvestYears) {
      final status = seedViability(
        harvestYear: year,
        viabilityYears: viabilityYears,
        currentYear: currentYear,
      );
      if (_viabilityRank(status) > _viabilityRank(worst)) worst = status;
    }
    return worst;
  }

  int _viabilityRank(SeedViability status) => switch (status) {
    SeedViability.unknown => 0,
    SeedViability.fresh => 1,
    SeedViability.expiringSoon => 2,
    SeedViability.expired => 3,
  };

  /// Maps each variety to the distinct lot forms it currently holds (one
  /// query). Varieties without lots are simply absent from the map.
  Future<Map<String, Set<LotType>>> _lotTypesFor(
    List<String> varietyIds,
  ) async {
    if (varietyIds.isEmpty) return const {};
    final rows =
        await (_db.select(_db.lots)..where(
              (l) => l.varietyId.isIn(varietyIds) & l.isDeleted.equals(false),
            ))
            .get();
    final byVariety = <String, Set<LotType>>{};
    for (final row in rows) {
      (byVariety[row.varietyId] ??= <LotType>{}).add(row.type);
    }
    return byVariety;
  }

  /// The subset of [varietyIds] holding at least one lot offered to others
  /// (to give away, swap or sell) — one query.
  Future<Set<String>> _sharedVarietyIdsFor(List<String> varietyIds) async {
    if (varietyIds.isEmpty) return const {};
    final rows =
        await (_db.select(_db.lots)..where(
              (l) =>
                  l.varietyId.isIn(varietyIds) &
                  l.isDeleted.equals(false) &
                  l.offerStatus.equalsValue(OfferStatus.private).not(),
            ))
            .get();
    return rows.map((l) => l.varietyId).toSet();
  }

  /// Loads the first photo BLOB for each of [varietyIds] (one query).
  Future<Map<String, Uint8List>> _firstPhotosFor(
    List<String> varietyIds,
  ) async {
    if (varietyIds.isEmpty) return const {};
    final rows =
        await (_db.select(_db.attachments)
              ..where(
                (a) =>
                    a.parentId.isIn(varietyIds) &
                    a.parentType.equalsValue(ParentType.variety) &
                    a.kind.equalsValue(AttachmentKind.photo) &
                    a.isDeleted.equals(false),
              )
              ..orderBy([
                (a) => OrderingTerm(expression: a.sortOrder),
                (a) => OrderingTerm(expression: a.createdAt),
              ]))
            .get();
    final byVariety = <String, Uint8List>{};
    for (final row in rows) {
      final bytes = row.bytes;
      if (bytes != null) byVariety.putIfAbsent(row.parentId, () => bytes);
    }
    return byVariety;
  }

  /// Maps each of [speciesIds] to its scientific name (one query).
  Future<Map<String, String>> _scientificNamesFor(
    Set<String> speciesIds,
  ) async {
    if (speciesIds.isEmpty) return const {};
    final rows = await (_db.select(
      _db.species,
    )..where((s) => s.id.isIn(speciesIds))).get();
    return {for (final s in rows) s.id: s.scientificName};
  }

  /// The 20-second quick-add: a [label] (required) plus an optional [category],
  /// an optional [quantity] (creates a Lot) and an optional [photoBytes]
  /// (stored as an encrypted BLOB Attachment). Returns the new Variety id.
  Future<String> addQuickVariety({
    required String label,
    String? category,
    LotType lotType = LotType.seed,
    Quantity? quantity,
    Uint8List? photoBytes,
  }) async {
    final varietyId = idGen.newId();
    await _db.transaction(() async {
      final (created, updated) = await _stamp();
      await _db
          .into(_db.varieties)
          .insert(
            VarietiesCompanion.insert(
              id: varietyId,
              label: label,
              createdAt: created,
              updatedAt: updated,
              lastAuthor: nodeId,
              category: Value(category),
            ),
          );

      if (quantity != null) {
        final (created, updated) = await _stamp();
        await _db
            .into(_db.lots)
            .insert(
              LotsCompanion.insert(
                id: idGen.newId(),
                varietyId: varietyId,
                createdAt: created,
                updatedAt: updated,
                lastAuthor: nodeId,
                type: Value(lotType),
                quantityKind: Value(quantity.kind.name),
                quantityPrecise: Value(quantity.count?.toDouble()),
                quantityLabel: Value(quantity.label),
              ),
            );
      }

      if (photoBytes != null) {
        final (created, updated) = await _stamp();
        await _db
            .into(_db.attachments)
            .insert(
              AttachmentsCompanion.insert(
                id: idGen.newId(),
                createdAt: created,
                updatedAt: updated,
                lastAuthor: nodeId,
                parentType: ParentType.variety,
                parentId: varietyId,
                kind: AttachmentKind.photo,
                bytes: Value(photoBytes),
                mimeType: const Value('image/jpeg'),
              ),
            );
      }
    });
    return varietyId;
  }

  /// Captures a photo-first draft: a Variety with no name yet ([label] empty),
  /// flagged [isDraft], plus its photo. It lands in the "to catalogue" tray
  /// ([watchDrafts]) until the user names it with [nameDraft]. Returns the id.
  Future<String> addDraftVariety(Uint8List photoBytes) async {
    final varietyId = idGen.newId();
    await _db.transaction(() async {
      final (created, updated) = await _stamp();
      await _db
          .into(_db.varieties)
          .insert(
            VarietiesCompanion.insert(
              id: varietyId,
              label: '',
              createdAt: created,
              updatedAt: updated,
              lastAuthor: nodeId,
              isDraft: const Value(true),
            ),
          );
      final (createdA, updatedA) = await _stamp();
      await _db
          .into(_db.attachments)
          .insert(
            AttachmentsCompanion.insert(
              id: idGen.newId(),
              createdAt: createdA,
              updatedAt: updatedA,
              lastAuthor: nodeId,
              parentType: ParentType.variety,
              parentId: varietyId,
              kind: AttachmentKind.photo,
              bytes: Value(photoBytes),
              mimeType: const Value('image/jpeg'),
            ),
          );
    });
    return varietyId;
  }

  /// Names a draft and promotes it out of the "to catalogue" tray: sets its
  /// [label] and clears [isDraft] in one LWW write.
  Future<void> nameDraft(String id, String label) async {
    final (_, updated) = await _stamp();
    await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
      VarietiesCompanion(
        label: Value(label),
        isDraft: const Value(false),
        updatedAt: Value(updated),
        lastAuthor: Value(nodeId),
      ),
    );
  }

  /// Reactively watches one variety with its lots, vernacular names and first
  /// photo. Emits `null` if the variety does not exist or is soft-deleted.
  ///
  /// Re-emits whenever the variety or any related row changes: a merged trigger
  /// stream over the four tables drives a full reload, so adding a lot (a change
  /// to a *different* table) still refreshes the view.
  Stream<VarietyDetail?> watchVariety(String id) {
    final triggers = StreamGroup.merge<void>([
      (_db.select(
        _db.varieties,
      )..where((v) => v.id.equals(id))).watch().map((_) {}),
      (_db.select(
        _db.lots,
      )..where((l) => l.varietyId.equals(id))).watch().map((_) {}),
      (_db.select(
        _db.varietyVernacularNames,
      )..where((n) => n.varietyId.equals(id))).watch().map((_) {}),
      (_db.select(
        _db.attachments,
      )..where((a) => a.parentId.equals(id))).watch().map((_) {}),
      (_db.select(
        _db.externalLinks,
      )..where((e) => e.parentId.equals(id))).watch().map((_) {}),
      // Coarse: any germination-test change re-emits (catalog of tests is tiny).
      _db.select(_db.germinationTests).watch().map((_) {}),
      // Same for condition checks (jars + drying-agent state).
      _db.select(_db.conditionChecks).watch().map((_) {}),
    ]);
    return triggers.asyncMap((_) => _loadVariety(id));
  }

  Future<VarietyDetail?> _loadVariety(String id) async {
    final v =
        await (_db.select(_db.varieties)
              ..where((t) => t.id.equals(id) & t.isDeleted.equals(false)))
            .getSingleOrNull();
    if (v == null) return null;

    String? scientificName;
    int? viabilityYears;
    int? gbifKey;
    String? wikidataQid;
    if (v.speciesId != null) {
      final species = await (_db.select(
        _db.species,
      )..where((s) => s.id.equals(v.speciesId!))).getSingleOrNull();
      scientificName = species?.scientificName;
      viabilityYears = species?.viabilityYears;
      gbifKey = species?.gbifKey;
      wikidataQid = species?.wikidataQid;
    }

    final lots =
        await (_db.select(_db.lots)
              ..where((l) => l.varietyId.equals(id) & l.isDeleted.equals(false))
              ..orderBy([(l) => OrderingTerm.desc(l.harvestYear)]))
            .get();

    final lotIds = lots.map((l) => l.id).toList();
    final testsByLot = <String, List<GerminationEntry>>{};
    final checksByLot = <String, List<ConditionEntry>>{};
    if (lotIds.isNotEmpty) {
      final tests =
          await (_db.select(_db.germinationTests)
                ..where((g) => g.lotId.isIn(lotIds) & g.isDeleted.equals(false))
                ..orderBy([(g) => OrderingTerm.desc(g.testedOn)]))
              .get();
      for (final t in tests) {
        testsByLot
            .putIfAbsent(t.lotId, () => [])
            .add(
              GerminationEntry(
                id: t.id,
                testedOn: t.testedOn,
                sampleSize: t.sampleSize,
                germinatedCount: t.germinatedCount,
                notes: t.notes,
              ),
            );
      }
      final checks =
          await (_db.select(_db.conditionChecks)
                ..where((c) => c.lotId.isIn(lotIds) & c.isDeleted.equals(false))
                ..orderBy([(c) => OrderingTerm.desc(c.checkedOn)]))
              .get();
      for (final c in checks) {
        checksByLot
            .putIfAbsent(c.lotId, () => [])
            .add(
              ConditionEntry(
                id: c.id,
                checkedOn: c.checkedOn,
                containerCount: c.containerCount,
                desiccantState: c.desiccantState,
                notes: c.notes,
              ),
            );
      }
    }

    final names = await (_db.select(
      _db.varietyVernacularNames,
    )..where((n) => n.varietyId.equals(id) & n.isDeleted.equals(false))).get();
    final photos =
        await (_db.select(_db.attachments)
              ..where(
                (a) =>
                    a.parentId.equals(id) &
                    a.parentType.equalsValue(ParentType.variety) &
                    a.kind.equalsValue(AttachmentKind.photo) &
                    a.isDeleted.equals(false),
              )
              ..orderBy([
                (a) => OrderingTerm(expression: a.sortOrder),
                (a) => OrderingTerm(expression: a.createdAt),
              ]))
            .get();

    final links =
        await (_db.select(_db.externalLinks)
              ..where(
                (e) =>
                    e.parentId.equals(id) &
                    e.parentType.equalsValue(ParentType.variety) &
                    e.isDeleted.equals(false),
              )
              ..orderBy([(e) => OrderingTerm(expression: e.createdAt)]))
            .get();

    return VarietyDetail(
      id: v.id,
      label: v.label,
      category: v.category,
      notes: v.notes,
      speciesId: v.speciesId,
      scientificName: scientificName,
      gbifKey: gbifKey,
      wikidataQid: wikidataQid,
      viabilityYears: viabilityYears,
      isOrganic: v.isOrganic,
      needsReproduction: v.needsReproduction,
      sowMonths: v.sowMonths,
      transplantMonths: v.transplantMonths,
      floweringMonths: v.floweringMonths,
      fruitingMonths: v.fruitingMonths,
      seedHarvestMonths: v.seedHarvestMonths,
      lots: lots
          .map(
            (l) => _toLot(
              l,
              testsByLot[l.id] ?? const [],
              checksByLot[l.id] ?? const [],
            ),
          )
          .toList(),
      vernacularNames: names
          .map(
            (n) => VernacularName(
              id: n.id,
              name: n.name,
              language: n.language,
              region: n.region,
            ),
          )
          .toList(),
      photos: [
        for (final p in photos)
          if (p.bytes != null) VarietyPhoto(id: p.id, bytes: p.bytes!),
      ],
      links: links
          .map((e) => ExternalLinkItem(id: e.id, url: e.url, title: e.title))
          .toList(),
    );
  }

  /// Links [varietyId] to a catalog [speciesId]. If the variety has no category
  /// yet, prefill it from the species' botanical family (data-model §6).
  Future<void> linkSpecies(String varietyId, String speciesId) async {
    final (_, updated) = await _stamp();
    final species = await (_db.select(
      _db.species,
    )..where((s) => s.id.equals(speciesId))).getSingleOrNull();
    final variety = await (_db.select(
      _db.varieties,
    )..where((v) => v.id.equals(varietyId))).getSingleOrNull();

    final categoryIsEmpty =
        variety?.category == null || variety!.category!.trim().isEmpty;
    final prefill = categoryIsEmpty ? species?.family : null;

    await (_db.update(
      _db.varieties,
    )..where((v) => v.id.equals(varietyId))).write(
      VarietiesCompanion(
        speciesId: Value(speciesId),
        category: prefill == null ? const Value.absent() : Value(prefill),
        updatedAt: Value(updated),
        lastAuthor: Value(nodeId),
      ),
    );
  }

  /// Updates a variety's scalar fields (LWW). Passing null clears [category]
  /// and [notes]; a null [label] leaves the label unchanged. Boolean flags and
  /// the crop-calendar months are only touched when a wrapping [Value] is
  /// passed, so callers can update one field without disturbing the rest.
  Future<void> updateVariety({
    required String id,
    String? label,
    String? category,
    String? notes,
    bool? isOrganic,
    bool? needsReproduction,
    Value<int?> sowMonths = const Value.absent(),
    Value<int?> transplantMonths = const Value.absent(),
    Value<int?> floweringMonths = const Value.absent(),
    Value<int?> fruitingMonths = const Value.absent(),
    Value<int?> seedHarvestMonths = const Value.absent(),
  }) async {
    final (_, updated) = await _stamp();
    await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
      VarietiesCompanion(
        label: label == null ? const Value.absent() : Value(label),
        category: Value(category),
        notes: Value(notes),
        isOrganic: isOrganic == null ? const Value.absent() : Value(isOrganic),
        needsReproduction: needsReproduction == null
            ? const Value.absent()
            : Value(needsReproduction),
        sowMonths: sowMonths,
        transplantMonths: transplantMonths,
        floweringMonths: floweringMonths,
        fruitingMonths: fruitingMonths,
        seedHarvestMonths: seedHarvestMonths,
        updatedAt: Value(updated),
        lastAuthor: Value(nodeId),
      ),
    );
  }

  /// Adds a lot (a held batch) to a variety. Returns the new lot id.
  Future<String> addLot({
    required String varietyId,
    LotType type = LotType.seed,
    int? harvestYear,
    int? harvestMonth,
    Quantity? quantity,
    Presentation? presentation,
    String? storageLocation,
    String? originName,
    String? originPlace,
    Abundance? abundance,
    PreservationFormat? preservationFormat,
    OfferStatus offerStatus = OfferStatus.private,
  }) async {
    final (created, updated) = await _stamp();
    final id = idGen.newId();
    await _db
        .into(_db.lots)
        .insert(
          LotsCompanion.insert(
            id: id,
            varietyId: varietyId,
            createdAt: created,
            updatedAt: updated,
            lastAuthor: nodeId,
            type: Value(type),
            harvestYear: Value(harvestYear),
            harvestMonth: Value(harvestMonth),
            quantityKind: Value(quantity?.kind.name),
            quantityPrecise: Value(quantity?.count?.toDouble()),
            quantityLabel: Value(quantity?.label),
            presentation: Value(presentation),
            storageLocation: Value(storageLocation),
            originName: Value(originName),
            originPlace: Value(originPlace),
            abundance: Value(abundance),
            preservationFormat: Value(preservationFormat),
            offerStatus: Value(offerStatus),
          ),
        );
    return id;
  }

  /// Updates an existing lot's fields (LWW). Every field is set as given (null
  /// clears it), matching the "save the whole lot form" call from the UI.
  Future<void> updateLot({
    required String lotId,
    required LotType type,
    int? harvestYear,
    int? harvestMonth,
    Quantity? quantity,
    Presentation? presentation,
    String? storageLocation,
    String? originName,
    String? originPlace,
    Abundance? abundance,
    PreservationFormat? preservationFormat,
    OfferStatus offerStatus = OfferStatus.private,
  }) async {
    final (_, updated) = await _stamp();
    await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write(
      LotsCompanion(
        type: Value(type),
        harvestYear: Value(harvestYear),
        harvestMonth: Value(harvestMonth),
        quantityKind: Value(quantity?.kind.name),
        quantityPrecise: Value(quantity?.count?.toDouble()),
        quantityLabel: Value(quantity?.label),
        presentation: Value(presentation),
        storageLocation: Value(storageLocation),
        originName: Value(originName),
        originPlace: Value(originPlace),
        abundance: Value(abundance),
        preservationFormat: Value(preservationFormat),
        offerStatus: Value(offerStatus),
        updatedAt: Value(updated),
        lastAuthor: Value(nodeId),
      ),
    );
  }

  /// Every lot offered to others (to give away, swap or sell), joined with its
  /// variety and ordered by label then harvest year — the content of the
  /// printable "what I share" catalog.
  Future<List<SharedCatalogEntry>> sharedCatalog() async {
    final lots =
        await (_db.select(_db.lots)..where(
              (l) =>
                  l.isDeleted.equals(false) &
                  l.offerStatus.equalsValue(OfferStatus.private).not(),
            ))
            .get();
    if (lots.isEmpty) return const [];

    final varietyIds = lots.map((l) => l.varietyId).toSet();
    final varieties =
        await (_db.select(_db.varieties)..where(
              (v) =>
                  v.id.isIn(varietyIds) &
                  v.isDeleted.equals(false) &
                  v.isDraft.equals(false),
            ))
            .get();
    final byId = {for (final v in varieties) v.id: v};
    final sciNames = await _scientificNamesFor(
      varieties.map((v) => v.speciesId).whereType<String>().toSet(),
    );

    final entries = <SharedCatalogEntry>[
      for (final l in lots)
        if (byId[l.varietyId] case final v?)
          SharedCatalogEntry(
            varietyLabel: v.label,
            scientificName: v.speciesId == null ? null : sciNames[v.speciesId],
            category: v.category,
            type: l.type,
            harvestYear: l.harvestYear,
            quantity:
                l.quantityKind != null ||
                    l.quantityPrecise != null ||
                    l.quantityLabel != null
                ? Quantity(
                    kind: _parseKind(l.quantityKind),
                    count: l.quantityPrecise,
                    label: l.quantityLabel,
                  )
                : null,
            abundance: l.abundance,
            offerStatus: l.offerStatus,
          ),
    ];
    entries.sort(
      (a, b) =>
          a.varietyLabel.toLowerCase().compareTo(b.varietyLabel.toLowerCase()),
    );
    return entries;
  }

  /// Soft-deletes a lot (tombstone); it leaves the variety's lot list.
  Future<void> softDeleteLot(String lotId) async {
    final (_, updated) = await _stamp();
    await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write(
      LotsCompanion(
        isDeleted: const Value(true),
        updatedAt: Value(updated),
        lastAuthor: Value(nodeId),
      ),
    );
  }

  /// Adds a vernacular (common) name to a variety. Returns the new row id.
  Future<String> addVernacularName(
    String varietyId,
    String name, {
    String? language,
    String? region,
  }) async {
    final (created, updated) = await _stamp();
    final id = idGen.newId();
    await _db
        .into(_db.varietyVernacularNames)
        .insert(
          VarietyVernacularNamesCompanion.insert(
            id: id,
            varietyId: varietyId,
            name: name,
            createdAt: created,
            updatedAt: updated,
            lastAuthor: nodeId,
            language: Value(language),
            region: Value(region),
          ),
        );
    return id;
  }

  /// Soft-deletes a vernacular name (tombstone).
  Future<void> removeVernacularName(String nameId) async {
    final (_, updated) = await _stamp();
    await (_db.update(
      _db.varietyVernacularNames,
    )..where((n) => n.id.equals(nameId))).write(
      VarietyVernacularNamesCompanion(
        isDeleted: const Value(true),
        updatedAt: Value(updated),
        lastAuthor: Value(nodeId),
      ),
    );
  }

  /// Adds a photo (encrypted BLOB) to a variety. Returns the new attachment id.
  Future<String> addPhoto(String varietyId, Uint8List bytes) async {
    final (created, updated) = await _stamp();
    final id = idGen.newId();
    await _db
        .into(_db.attachments)
        .insert(
          AttachmentsCompanion.insert(
            id: id,
            createdAt: created,
            updatedAt: updated,
            lastAuthor: nodeId,
            parentType: ParentType.variety,
            parentId: varietyId,
            kind: AttachmentKind.photo,
            bytes: Value(bytes),
            mimeType: const Value('image/jpeg'),
          ),
        );
    return id;
  }

  /// Soft-deletes a photo (tombstone).
  Future<void> removePhoto(String attachmentId) async {
    final (_, updated) = await _stamp();
    await (_db.update(
      _db.attachments,
    )..where((a) => a.id.equals(attachmentId))).write(
      AttachmentsCompanion(
        isDeleted: const Value(true),
        updatedAt: Value(updated),
        lastAuthor: Value(nodeId),
      ),
    );
  }

  /// Makes [attachmentId] the cover (preferred) attachment of [varietyId] by
  /// giving it a [sortOrder] below every current sibling. The cover is what the
  /// inventory list shows as the avatar and the detail gallery shows first.
  Future<void> setPreferredPhoto(String varietyId, String attachmentId) async {
    final siblings =
        await (_db.select(_db.attachments)..where(
              (a) =>
                  a.parentId.equals(varietyId) &
                  a.parentType.equalsValue(ParentType.variety) &
                  a.isDeleted.equals(false),
            ))
            .get();
    var minOrder = 0;
    for (final a in siblings) {
      if (a.sortOrder < minOrder) minOrder = a.sortOrder;
    }
    final (_, updated) = await _stamp();
    await (_db.update(
      _db.attachments,
    )..where((a) => a.id.equals(attachmentId))).write(
      AttachmentsCompanion(
        sortOrder: Value(minOrder - 1),
        updatedAt: Value(updated),
        lastAuthor: Value(nodeId),
      ),
    );
  }

  /// Adds an external link (URL, optional title) to a variety. Returns its id.
  Future<String> addExternalLink(
    String varietyId,
    String url, {
    String? title,
  }) async {
    final (created, updated) = await _stamp();
    final id = idGen.newId();
    await _db
        .into(_db.externalLinks)
        .insert(
          ExternalLinksCompanion.insert(
            id: id,
            createdAt: created,
            updatedAt: updated,
            lastAuthor: nodeId,
            parentType: ParentType.variety,
            parentId: varietyId,
            url: url,
            title: Value(title),
          ),
        );
    return id;
  }

  /// Soft-deletes an external link (tombstone).
  Future<void> removeExternalLink(String linkId) async {
    final (_, updated) = await _stamp();
    await (_db.update(
      _db.externalLinks,
    )..where((e) => e.id.equals(linkId))).write(
      ExternalLinksCompanion(
        isDeleted: const Value(true),
        updatedAt: Value(updated),
        lastAuthor: Value(nodeId),
      ),
    );
  }

  /// Soft-deletes a variety (tombstone); it disappears from the inventory but
  /// the row survives for correct CRDT merges later.
  Future<void> softDeleteVariety(String id) async {
    final (_, updated) = await _stamp();
    await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
      VarietiesCompanion(
        isDeleted: const Value(true),
        updatedAt: Value(updated),
        lastAuthor: Value(nodeId),
      ),
    );
  }

  /// Records a germination test for a lot. Returns the new test id.
  Future<String> addGerminationTest({
    required String lotId,
    int? testedOn,
    int? sampleSize,
    int? germinatedCount,
    String? notes,
  }) async {
    final (created, updated) = await _stamp();
    final id = idGen.newId();
    await _db
        .into(_db.germinationTests)
        .insert(
          GerminationTestsCompanion.insert(
            id: id,
            lotId: lotId,
            createdAt: created,
            updatedAt: updated,
            lastAuthor: nodeId,
            testedOn: Value(testedOn),
            sampleSize: Value(sampleSize),
            germinatedCount: Value(germinatedCount),
            notes: Value(notes),
          ),
        );
    return id;
  }

  /// Records a storage-condition check for a lot (container count + drying-agent
  /// state). Returns the new check id.
  Future<String> addConditionCheck({
    required String lotId,
    int? checkedOn,
    int? containerCount,
    DesiccantState? desiccantState,
    String? notes,
  }) async {
    final (created, updated) = await _stamp();
    final id = idGen.newId();
    await _db
        .into(_db.conditionChecks)
        .insert(
          ConditionChecksCompanion.insert(
            id: id,
            lotId: lotId,
            createdAt: created,
            updatedAt: updated,
            lastAuthor: nodeId,
            checkedOn: Value(checkedOn),
            containerCount: Value(containerCount),
            desiccantState: Value(desiccantState),
            notes: Value(notes),
          ),
        );
    return id;
  }

  /// Snapshots the live inventory (tombstones excluded) for the interchange
  /// export — data-model §7. Includes photo bytes; the JSON codec embeds them
  /// as base64 and the CSV codec ignores them.
  Future<InventorySnapshot> exportInventory() async {
    final varieties = await (_db.select(
      _db.varieties,
    )..where((v) => v.isDeleted.equals(false))).get();
    final speciesNamesById = await _scientificNamesFor(
      varieties.map((v) => v.speciesId).whereType<String>().toSet(),
    );
    return InventorySnapshot(
      varieties: varieties,
      speciesNamesById: speciesNamesById,
      lots: await (_db.select(
        _db.lots,
      )..where((l) => l.isDeleted.equals(false))).get(),
      vernacularNames: await (_db.select(
        _db.varietyVernacularNames,
      )..where((n) => n.isDeleted.equals(false))).get(),
      externalLinks: await (_db.select(
        _db.externalLinks,
      )..where((e) => e.isDeleted.equals(false))).get(),
      germinationTests: await (_db.select(
        _db.germinationTests,
      )..where((g) => g.isDeleted.equals(false))).get(),
      conditionChecks: await (_db.select(
        _db.conditionChecks,
      )..where((c) => c.isDeleted.equals(false))).get(),
      movements: await _db.select(_db.movements).get(),
      parties: await (_db.select(
        _db.parties,
      )..where((p) => p.isDeleted.equals(false))).get(),
      attachments: await (_db.select(
        _db.attachments,
      )..where((a) => a.isDeleted.equals(false))).get(),
    );
  }

  /// Imports a snapshot, reconciling by row id (UUIDv7) so nothing duplicates:
  /// unknown id → insert preserving the original sync metadata; known mutable
  /// id → last-writer-wins on the packed HLC `updatedAt`; movements are
  /// append-only (insert-if-unknown). Runs in one transaction; afterwards the
  /// local clock absorbs the newest imported stamp so it never runs behind.
  ///
  /// Species ids are per-install, so incoming `speciesId`s are re-resolved
  /// against the local catalog by scientific name (unmatched → null).
  Future<ImportSummary> importInventory(InventorySnapshot snapshot) async {
    const reconciler = ImportReconciler();
    final localSpeciesIdByIncoming = await _resolveSpecies(
      snapshot.speciesNamesById,
    );
    final varieties = [
      for (final v in snapshot.varieties)
        v.speciesId == null
            ? v
            : v.copyWith(
                speciesId: Value(localSpeciesIdByIncoming[v.speciesId]),
              ),
    ];

    var summary = const ImportSummary();
    await _db.transaction(() async {
      summary += await _importMutableRows(
        table: _db.varieties,
        idColumn: _db.varieties.id,
        rows: varieties,
        idOf: (r) => r.id,
        updatedAtOf: (r) => r.updatedAt,
        reconciler: reconciler,
      );
      summary += await _importMutableRows(
        table: _db.lots,
        idColumn: _db.lots.id,
        rows: snapshot.lots,
        idOf: (r) => r.id,
        updatedAtOf: (r) => r.updatedAt,
        reconciler: reconciler,
      );
      summary += await _importMutableRows(
        table: _db.varietyVernacularNames,
        idColumn: _db.varietyVernacularNames.id,
        rows: snapshot.vernacularNames,
        idOf: (r) => r.id,
        updatedAtOf: (r) => r.updatedAt,
        reconciler: reconciler,
      );
      summary += await _importMutableRows(
        table: _db.externalLinks,
        idColumn: _db.externalLinks.id,
        rows: snapshot.externalLinks,
        idOf: (r) => r.id,
        updatedAtOf: (r) => r.updatedAt,
        reconciler: reconciler,
      );
      summary += await _importMutableRows(
        table: _db.germinationTests,
        idColumn: _db.germinationTests.id,
        rows: snapshot.germinationTests,
        idOf: (r) => r.id,
        updatedAtOf: (r) => r.updatedAt,
        reconciler: reconciler,
      );
      summary += await _importMutableRows(
        table: _db.conditionChecks,
        idColumn: _db.conditionChecks.id,
        rows: snapshot.conditionChecks,
        idOf: (r) => r.id,
        updatedAtOf: (r) => r.updatedAt,
        reconciler: reconciler,
      );
      summary += await _importMutableRows(
        table: _db.parties,
        idColumn: _db.parties.id,
        rows: snapshot.parties,
        idOf: (r) => r.id,
        updatedAtOf: (r) => r.updatedAt,
        reconciler: reconciler,
      );
      summary += await _importMutableRows(
        table: _db.attachments,
        idColumn: _db.attachments.id,
        rows: snapshot.attachments,
        idOf: (r) => r.id,
        updatedAtOf: (r) => r.updatedAt,
        reconciler: reconciler,
      );
      summary += await _importMovements(snapshot.movements, reconciler);
    });

    final maxIncoming = reconciler.maxHlc([
      for (final v in snapshot.varieties) v.updatedAt,
      for (final l in snapshot.lots) l.updatedAt,
      for (final n in snapshot.vernacularNames) n.updatedAt,
      for (final e in snapshot.externalLinks) e.updatedAt,
      for (final g in snapshot.germinationTests) g.updatedAt,
      for (final c in snapshot.conditionChecks) c.updatedAt,
      for (final p in snapshot.parties) p.updatedAt,
      for (final a in snapshot.attachments) a.updatedAt,
    ]);
    if (maxIncoming != null) {
      _clock = _clock.receiveEvent(maxIncoming, _now());
    }
    return summary;
  }

  /// Additive import of a spreadsheet [csv]: every parsed variety is inserted
  /// fresh (new UUIDv7 + local HLC), so it never overwrites existing rows and
  /// re-importing the same file adds it again. The canonical, id-reconciling
  /// import is JSON ([importInventory]); this is the low-friction path for a
  /// list a collective already keeps in a spreadsheet.
  ///
  /// Species are re-linked by scientific name against the local catalog
  /// (unmatched → null). Returns the number of varieties added.
  Future<ImportSummary> importCsv(CsvImport csv) async {
    var inserted = 0;
    await _db.transaction(() async {
      for (final v in csv.varieties) {
        final speciesId = await _resolveSpeciesByName(v.scientificName);
        final varietyId = idGen.newId();
        final (created, updated) = await _stamp();
        await _db
            .into(_db.varieties)
            .insert(
              VarietiesCompanion.insert(
                id: varietyId,
                label: v.label,
                createdAt: created,
                updatedAt: updated,
                lastAuthor: nodeId,
                category: Value(v.category),
                cultivarName: Value(v.cultivarName),
                notes: Value(v.notes),
                speciesId: Value(speciesId),
                needsReproduction: Value(v.needsReproduction),
                sowMonths: Value(v.sowMonths),
                transplantMonths: Value(v.transplantMonths),
                floweringMonths: Value(v.floweringMonths),
                fruitingMonths: Value(v.fruitingMonths),
                seedHarvestMonths: Value(v.seedHarvestMonths),
              ),
            );
        inserted++;

        for (final lot in v.lots) {
          final (created, updated) = await _stamp();
          await _db
              .into(_db.lots)
              .insert(
                LotsCompanion.insert(
                  id: idGen.newId(),
                  varietyId: varietyId,
                  createdAt: created,
                  updatedAt: updated,
                  lastAuthor: nodeId,
                  type: Value(lot.type),
                  harvestYear: Value(lot.harvestYear),
                  harvestMonth: Value(lot.harvestMonth),
                  quantityKind: Value(lot.quantityKind),
                  quantityPrecise: Value(lot.quantityPrecise),
                  quantityLabel: Value(lot.quantityLabel),
                  presentation: Value(lot.presentation),
                  storageLocation: Value(lot.storageLocation),
                  offerStatus: Value(lot.offerStatus),
                  originName: Value(lot.originName),
                  originPlace: Value(lot.originPlace),
                  abundance: Value(lot.abundance),
                  preservationFormat: Value(lot.preservationFormat),
                ),
              );
        }

        for (final name in v.vernacularNames) {
          final (created, updated) = await _stamp();
          await _db
              .into(_db.varietyVernacularNames)
              .insert(
                VarietyVernacularNamesCompanion.insert(
                  id: idGen.newId(),
                  varietyId: varietyId,
                  name: name.name,
                  createdAt: created,
                  updatedAt: updated,
                  lastAuthor: nodeId,
                  language: Value(name.language),
                ),
              );
        }

        for (final link in v.links) {
          final (created, updated) = await _stamp();
          await _db
              .into(_db.externalLinks)
              .insert(
                ExternalLinksCompanion.insert(
                  id: idGen.newId(),
                  createdAt: created,
                  updatedAt: updated,
                  lastAuthor: nodeId,
                  parentType: ParentType.variety,
                  parentId: varietyId,
                  url: link.url,
                  title: Value(link.title),
                ),
              );
        }
      }
    });
    return ImportSummary(inserted: inserted);
  }

  /// Resolves a single scientific name to a local catalog species id, or null
  /// when blank or unmatched.
  Future<String?> _resolveSpeciesByName(String? scientificName) async {
    final name = scientificName?.trim();
    if (name == null || name.isEmpty) return null;
    final local =
        await (_db.select(_db.species)..where(
              (s) => s.scientificName.equals(name) & s.isDeleted.equals(false),
            ))
            .getSingleOrNull();
    return local?.id;
  }

  /// Maps incoming species ids to local catalog ids by scientific name.
  Future<Map<String, String?>> _resolveSpecies(
    Map<String, String> speciesNamesById,
  ) async {
    final resolved = <String, String?>{};
    for (final entry in speciesNamesById.entries) {
      final local =
          await (_db.select(_db.species)..where(
                (s) =>
                    s.scientificName.equals(entry.value) &
                    s.isDeleted.equals(false),
              ))
              .getSingleOrNull();
      resolved[entry.key] = local?.id;
    }
    return resolved;
  }

  /// LWW import of one mutable table. Existing rows are looked up including
  /// tombstones, so a locally-deleted row only resurrects when the incoming
  /// version is strictly newer — plain last-writer-wins.
  Future<ImportSummary> _importMutableRows<T extends Table, R>({
    required TableInfo<T, R> table,
    required GeneratedColumn<String> idColumn,
    required List<R> rows,
    required String Function(R) idOf,
    required String Function(R) updatedAtOf,
    required ImportReconciler reconciler,
  }) async {
    if (rows.isEmpty) return const ImportSummary();
    final byId = {for (final r in rows) idOf(r): r};
    final existing = await (_db.select(
      table,
    )..where((_) => idColumn.isIn(byId.keys))).get();
    final existingUpdatedAt = {
      for (final r in existing) idOf(r): updatedAtOf(r),
    };

    var inserted = 0, updated = 0, skipped = 0;
    for (final row in byId.values) {
      final action = reconciler.reconcileMutable(
        existingUpdatedAt: existingUpdatedAt[idOf(row)],
        incomingUpdatedAt: updatedAtOf(row),
      );
      switch (action) {
        case ReconcileAction.insert:
          await _db.into(table).insert(row as Insertable<R>);
          inserted++;
        case ReconcileAction.update:
          await _db.update(table).replace(row as Insertable<R>);
          updated++;
        case ReconcileAction.skip:
          skipped++;
      }
    }
    return ImportSummary(
      inserted: inserted,
      updated: updated,
      skipped: skipped,
    );
  }

  /// Movements are append-only: insert unknown ids, never touch known ones.
  Future<ImportSummary> _importMovements(
    List<Movement> movements,
    ImportReconciler reconciler,
  ) async {
    if (movements.isEmpty) return const ImportSummary();
    final ids = movements.map((m) => m.id).toList();
    final existing = await (_db.select(
      _db.movements,
    )..where((m) => m.id.isIn(ids))).get();
    final existingIds = existing.map((m) => m.id).toSet();

    var inserted = 0, skipped = 0;
    for (final movement in movements) {
      final action = reconciler.reconcileAppendOnly(
        exists: existingIds.contains(movement.id),
      );
      if (action == ReconcileAction.insert) {
        await _db.into(_db.movements).insert(movement);
        inserted++;
      } else {
        skipped++;
      }
    }
    return ImportSummary(inserted: inserted, skipped: skipped);
  }

  VarietyLot _toLot(
    Lot l,
    List<GerminationEntry> germinationTests,
    List<ConditionEntry> conditionChecks,
  ) {
    final hasQuantity =
        l.quantityKind != null ||
        l.quantityPrecise != null ||
        l.quantityLabel != null;
    return VarietyLot(
      id: l.id,
      type: l.type,
      harvestYear: l.harvestYear,
      harvestMonth: l.harvestMonth,
      presentation: l.presentation,
      storageLocation: l.storageLocation,
      originName: l.originName,
      originPlace: l.originPlace,
      abundance: l.abundance,
      preservationFormat: l.preservationFormat,
      offerStatus: l.offerStatus,
      germinationTests: germinationTests,
      conditionChecks: conditionChecks,
      quantity: hasQuantity
          ? Quantity(
              kind: _parseKind(l.quantityKind),
              count: l.quantityPrecise,
              label: l.quantityLabel,
            )
          : null,
    );
  }

  QuantityKind _parseKind(String? name) =>
      QuantityKind.values.asNameMap()[name] ?? QuantityKind.aFew;

  /// Advances the local clock and returns `(createdAtMillis, packedHlc)`.
  ///
  /// Before the first stamp of the session the clock is re-seeded from the
  /// newest stamp already stored in the DB (see [_ensureClockSeeded]). The
  /// in-memory clock starts fresh at [Hlc.zero] every app launch, so without
  /// this a local edit could be stamped *older* than a row written in an
  /// earlier session or imported from a peer whose wall clock ran ahead — and
  /// silently lose last-writer-wins when the backup is shared back.
  Future<(int, String)> _stamp() async {
    await _ensureClockSeeded();
    final now = _now();
    _clock = _clock.localEvent(now);
    return (now, _clock.pack());
  }

  Future<void>? _clockSeeded;

  /// Absorbs the newest stored stamp into the local clock exactly once per
  /// repository instance, so it never runs behind data the DB already holds.
  Future<void> _ensureClockSeeded() =>
      _clockSeeded ??= _seedClockFromStoredRows();

  Future<void> _seedClockFromStoredRows() async {
    final maxStored = await _maxStoredHlc();
    if (maxStored != null) {
      _clock = _clock.receiveEvent(maxStored, _now());
    }
  }

  /// The newest packed HLC `updatedAt` across every mutable table, or null when
  /// the inventory is empty. Packed stamps are fixed-width and
  /// lexicographically sortable, so SQL `MAX` orders them exactly like
  /// [Hlc.compareTo].
  Future<Hlc?> _maxStoredHlc() async {
    final tables =
        <
          (
            ResultSetImplementation<HasResultSet, dynamic>,
            GeneratedColumn<String>,
          )
        >[
          (_db.varieties, _db.varieties.updatedAt),
          (_db.lots, _db.lots.updatedAt),
          (_db.varietyVernacularNames, _db.varietyVernacularNames.updatedAt),
          (_db.externalLinks, _db.externalLinks.updatedAt),
          (_db.germinationTests, _db.germinationTests.updatedAt),
          (_db.conditionChecks, _db.conditionChecks.updatedAt),
          (_db.parties, _db.parties.updatedAt),
          (_db.attachments, _db.attachments.updatedAt),
        ];
    String? maxPacked;
    for (final (table, column) in tables) {
      final max = column.max();
      final query = _db.selectOnly(table)..addColumns([max]);
      final packed = (await query.getSingleOrNull())?.read(max);
      if (packed != null &&
          (maxPacked == null || packed.compareTo(maxPacked) > 0)) {
        maxPacked = packed;
      }
    }
    return maxPacked == null ? null : Hlc.parse(maxPacked);
  }
}
