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

import '../db/database.dart';
import '../domain/species_autoclassify.dart';

/// One entry from the bundled catalog, before it is stored.
class SpeciesSeed {
  const SpeciesSeed({
    required this.scientificName,
    this.family,
    this.wikidataQid,
    this.gbifKey,
    this.commonNames = const {},
    this.viabilityYears,
  });

  final String scientificName;
  final String? family;

  /// Wikidata entity id (e.g. `Q13223`), the CC0 source of this row; null when
  /// the catalog was authored by hand. Kept for de-duplication and to feed the
  /// derived Wikipedia (locale-aware) reference link.
  final String? wikidataQid;

  /// GBIF backbone taxon key; null if unknown. Feeds the derived reference link.
  final int? gbifKey;

  /// language code → list of common names.
  final Map<String, List<String>> commonNames;

  /// Typical seed longevity in years (bundled reference data); null if unknown.
  final int? viabilityYears;
}

/// A catalog match surfaced to the UI (with a best common name for the locale).
class SpeciesMatch {
  const SpeciesMatch({
    required this.id,
    required this.scientificName,
    this.family,
    this.commonName,
  });

  final String id;
  final String scientificName;
  final String? family;
  final String? commonName;

  /// Label shown in the autocomplete: common name (scientific) when both exist.
  String get displayLabel =>
      commonName == null ? scientificName : '$commonName ($scientificName)';
}

/// Reads and seeds the bundled species catalog. Bundled rows are marked
/// `is_bundled = true` and are not synced (data-model §2.2).
class SpeciesRepository {
  SpeciesRepository(this._db, {required this.idGen, this.nodeId = 'bundle'});

  final AppDatabase _db;
  final IdGen idGen;
  final String nodeId;

  /// Idempotently inserts bundled species (keyed by scientific name). Safe to
  /// call on every startup. Existing rows keep their identity, but bundled
  /// reference fields still missing on a row ([SpeciesSeed.viabilityYears],
  /// [SpeciesSeed.wikidataQid], [SpeciesSeed.gbifKey]) are backfilled — so data
  /// added to the catalog reaches installs seeded before the field was bundled.
  ///
  /// Reads all existing species once and writes in a single batch: with a
  /// catalog of ~1000 species a per-row SELECT on every startup would be a real
  /// cost on the encrypted database.
  /// Seeds the bundled catalog only when [readVersion] doesn't already report
  /// [version]. [loadSeeds] is awaited lazily and skipped entirely when this
  /// install already holds [version] — so the ~1.5 MB catalog parse and the
  /// table scan in [seedBundled] are paid once per catalog version, not on
  /// every launch (the main startup cost). On a (re)seed, [writeVersion] records
  /// [version] only after [seedBundled] commits, so an interrupted seed is
  /// retried on the next launch.
  Future<void> seedBundledIfNeeded({
    required int version,
    required Future<String?> Function() readVersion,
    required Future<void> Function(String version) writeVersion,
    required Future<List<SpeciesSeed>> Function() loadSeeds,
  }) async {
    if (await readVersion() == version.toString()) return;
    await seedBundled(await loadSeeds());
    await writeVersion(version.toString());
  }

  Future<void> seedBundled(List<SpeciesSeed> seeds) async {
    final stamp = Hlc.zero(nodeId).pack();
    await _db.transaction(() async {
      final existing = {
        for (final s in await _db.select(_db.species).get())
          s.scientificName: s,
      };

      final newSpecies = <SpeciesCompanion>[];
      final newNames = <SpeciesCommonNamesCompanion>[];
      final backfills = <({String id, SpeciesCompanion patch})>[];

      for (final seed in seeds) {
        final row = existing[seed.scientificName];
        if (row != null) {
          final patch = _backfillPatch(row, seed);
          if (patch != null) backfills.add((id: row.id, patch: patch));
          continue;
        }

        final speciesId = idGen.newId();
        newSpecies.add(
          SpeciesCompanion.insert(
            id: speciesId,
            createdAt: 0,
            updatedAt: stamp,
            lastAuthor: nodeId,
            scientificName: seed.scientificName,
            family: Value(seed.family),
            wikidataQid: Value(seed.wikidataQid),
            gbifKey: Value(seed.gbifKey),
            isBundled: const Value(true),
            viabilityYears: Value(seed.viabilityYears),
          ),
        );

        for (final entry in seed.commonNames.entries) {
          for (final name in entry.value) {
            newNames.add(
              SpeciesCommonNamesCompanion.insert(
                id: idGen.newId(),
                createdAt: 0,
                updatedAt: stamp,
                lastAuthor: nodeId,
                speciesId: speciesId,
                name: name,
                language: Value(entry.key),
              ),
            );
          }
        }
      }

      if (newSpecies.isEmpty && newNames.isEmpty && backfills.isEmpty) return;

      await _db.batch((b) {
        b.insertAll(_db.species, newSpecies);
        b.insertAll(_db.speciesCommonNames, newNames);
        for (final entry in backfills) {
          b.update(
            _db.species,
            entry.patch,
            where: (s) => s.id.equals(entry.id),
          );
        }
      });
    });
  }

  /// A partial update carrying only the bundled reference fields a stored row is
  /// still missing, or null when the row is already complete.
  SpeciesCompanion? _backfillPatch(Specy row, SpeciesSeed seed) {
    var patch = const SpeciesCompanion();
    var changed = false;
    if (row.viabilityYears == null && seed.viabilityYears != null) {
      patch = patch.copyWith(viabilityYears: Value(seed.viabilityYears));
      changed = true;
    }
    if (row.wikidataQid == null && seed.wikidataQid != null) {
      patch = patch.copyWith(wikidataQid: Value(seed.wikidataQid));
      changed = true;
    }
    if (row.gbifKey == null && seed.gbifKey != null) {
      patch = patch.copyWith(gbifKey: Value(seed.gbifKey));
      changed = true;
    }
    return changed ? patch : null;
  }

  /// Searches species by scientific name or a common name (case-insensitive
  /// substring). Filtering runs in SQL (`LIKE`) so a large catalog is not pulled
  /// into memory on every keystroke. Returns up to [limit] matches, each with a
  /// best common name for [languageCode].
  Future<List<SpeciesMatch>> search(
    String query, {
    String languageCode = 'en',
    int limit = 8,
  }) async {
    final q = query.trim();
    if (q.isEmpty) return const [];

    // SQLite `LIKE` is case-insensitive for ASCII (matching the previous
    // `toLowerCase().contains` behaviour); `%`/`_`/`\` in the query are escaped
    // so they match literally rather than acting as wildcards.
    final pattern = '%${_escapeLike(q)}%';
    // A few extra candidates so the final scientific-name sort/limit is stable.
    final scan = limit * 4;

    final byScientific =
        await (_db.select(_db.species)
              ..where(
                (s) =>
                    s.isDeleted.equals(false) &
                    s.scientificName.like(pattern, escapeChar: r'\'),
              )
              ..limit(scan))
            .get();

    final byCommon =
        await (_db.select(_db.speciesCommonNames)
              ..where(
                (n) =>
                    n.isDeleted.equals(false) &
                    n.name.like(pattern, escapeChar: r'\'),
              )
              ..limit(scan))
            .get();

    final speciesById = {for (final s in byScientific) s.id: s};
    final missing = byCommon
        .map((n) => n.speciesId)
        .where((id) => !speciesById.containsKey(id))
        .toSet();
    if (missing.isNotEmpty) {
      final more =
          await (_db.select(_db.species)..where(
                (s) => s.isDeleted.equals(false) & s.id.isIn(missing.toList()),
              ))
              .get();
      for (final s in more) {
        speciesById[s.id] = s;
      }
    }
    if (speciesById.isEmpty) return const [];

    // Resolve a localized label only for the matched species.
    final names =
        await (_db.select(_db.speciesCommonNames)..where(
              (n) =>
                  n.isDeleted.equals(false) &
                  n.speciesId.isIn(speciesById.keys.toList()),
            ))
            .get();
    final namesBySpecies = <String, List<({String name, String? language})>>{};
    for (final n in names) {
      namesBySpecies.putIfAbsent(n.speciesId, () => []).add((
        name: n.name,
        language: n.language,
      ));
    }

    final matches = [
      for (final s in speciesById.values)
        SpeciesMatch(
          id: s.id,
          scientificName: s.scientificName,
          family: s.family,
          commonName: _bestName(namesBySpecies[s.id] ?? const [], languageCode),
        ),
    ];
    matches.sort((a, b) => a.scientificName.compareTo(b.scientificName));
    return matches.take(limit).toList();
  }

  /// Escapes SQL `LIKE` metacharacters so a user query matches literally.
  String _escapeLike(String input) => input
      .replaceAll(r'\', r'\\')
      .replaceAll('%', r'\%')
      .replaceAll('_', r'\_');

  /// Auto-classification for the UI: infers the single species a free-text
  /// variety [label] names (see [matchSpeciesInLabel]) and returns it as a
  /// [SpeciesMatch] with a best common name for [languageCode]. Returns null
  /// when the label names no known species or the match is ambiguous — so the
  /// caller can offer it as a one-tap suggestion, never a silent guess.
  Future<SpeciesMatch?> classifyLabel(
    String label, {
    String languageCode = 'en',
  }) async {
    final species = await (_db.select(
      _db.species,
    )..where((s) => s.isDeleted.equals(false))).get();
    final commons = await (_db.select(
      _db.speciesCommonNames,
    )..where((n) => n.isDeleted.equals(false))).get();

    final names = <SpeciesNameEntry>[
      for (final s in species)
        SpeciesNameEntry(speciesId: s.id, name: s.scientificName),
      for (final c in commons)
        SpeciesNameEntry(speciesId: c.speciesId, name: c.name),
    ];
    final id = matchSpeciesInLabel(label, names);
    if (id == null) return null;

    final match = species.firstWhere((s) => s.id == id);
    final matchNames = [
      for (final c in commons)
        if (c.speciesId == id) (name: c.name, language: c.language),
    ];
    return SpeciesMatch(
      id: match.id,
      scientificName: match.scientificName,
      family: match.family,
      commonName: _bestName(matchNames, languageCode),
    );
  }

  String? _bestName(
    List<({String name, String? language})> names,
    String languageCode,
  ) {
    if (names.isEmpty) return null;
    final inLocale = names.where((n) => n.language == languageCode);
    return (inLocale.isNotEmpty ? inLocale.first : names.first).name;
  }
}
