using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

namespace GerbilManager.Tests
{
    /// <summary>
    /// FEAT-8c loader tests against a small JSON fixture + an in-memory DB (never Julian's
    /// instance). Covers categorisation, the quarantine policy, genotype composition,
    /// Farbschlag matching, high-confidence linking, and idempotency.
    /// </summary>
    public class ImportServiceTests : IDisposable
    {
        private readonly string _dir;

        public ImportServiceTests()
        {
            _dir = Path.Combine(Path.GetTempPath(), "feat8c-" + Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(_dir);
            File.WriteAllText(Path.Combine(_dir, "litters.json"), LittersJson);
            File.WriteAllText(Path.Combine(_dir, "animals.json"), AnimalsJson);
        }

        public void Dispose()
        {
            try { Directory.Delete(_dir, recursive: true); } catch { }
        }

        private ApplicationContext NewDb()
        {
            var opts = new DbContextOptionsBuilder<ApplicationContext>()
                .UseInMemoryDatabase("feat8c-" + Guid.NewGuid().ToString("N"))
                .Options;
            var db = new ApplicationContext(opts);
            db.Database.EnsureCreated();   // applies the 73-variety HasData seed
            return db;
        }

        [Fact]
        public async Task DryRun_categorises_without_writing()
        {
            using var db = NewDb();
            var report = await new ImportService(db, _dir, _dir).RunAsync(execute: false);

            Assert.False(report.Executed);
            Assert.Equal(2, report.Litters.InSource);
            Assert.Equal(2, report.Litters.Created);
            // 4 animals: a1 (hoch) + a4 (date-only) loadable; a2 conflict, a3 stub quarantined
            Assert.Equal(4, report.Animals.InSource);
            Assert.Equal(2, report.Animals.Created);
            Assert.Equal(1, report.Animals.LinkedToLitter);          // only a1 (hoch)
            Assert.Equal(1, report.Animals.FarbschlagMatched);       // a1 -> Agouti
            Assert.Equal(1, report.Animals.Quarantined.Conflicts);
            Assert.Equal(1, report.Animals.Quarantined.Stubs);
            Assert.Equal(1, report.Animals.Quarantined.DateOnlyLinks);

            // nothing written in dry-run
            Assert.Equal(0, await db.Gerbils.CountAsync());
            Assert.Equal(0, await db.Litters.CountAsync());
        }

        [Fact]
        public async Task Execute_loads_conflict_free_and_links_high_confidence()
        {
            using var db = NewDb();
            var report = await new ImportService(db, _dir, _dir).RunAsync(execute: true);

            Assert.True(report.Executed);
            Assert.Equal(2, await db.Litters.CountAsync());
            Assert.Equal(2, await db.Gerbils.CountAsync());          // a1, a4 only

            // conflict + stub never loaded
            Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "a2"));
            Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "a3"));

            var a1 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a1");
            Assert.NotNull(a1.LitterId);                              // high-confidence link
            Assert.Equal("FEAT-8 Stammbaum/Wurfchronik", a1.ImportSource);
            Assert.NotNull(a1.ColorVarietyId);                       // Agouti matched
            Assert.Contains("aa CC", a1.Genotype);                   // composed from mapped8locus
            Assert.Contains("??", a1.Genotype!);                     // missing loci -> ??
            Assert.Contains("eef", a1.Genotype!);                    // e^f caret stripped
            Assert.Contains("RawGenotype", a1.RawImportData!);       // raw verbatim preserved

            var a4 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a4");
            Assert.Null(a4.LitterId);                                // date-only link quarantined

            // PairingCode carried through
            Assert.True(await db.Litters.AnyAsync(l => l.PairingCode == "G01/ZdkC"));
        }

        [Fact]
        public async Task Execute_is_idempotent()
        {
            using var db = NewDb();
            await new ImportService(db, _dir, _dir).RunAsync(execute: true);
            var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);

            Assert.Equal(0, second.Animals.Created);
            Assert.Equal(0, second.Litters.Created);
            Assert.Equal(2, await db.Gerbils.CountAsync());
            Assert.Equal(2, await db.Litters.CountAsync());
        }

        [Fact]
        public async Task DB5_Litter_ExternalRef_set_and_used_for_idempotency()
        {
            // DB-5: litters must have ExternalRef set to the source litter id on first import,
            // and subsequent runs must detect them via ExternalRef (not just Name+Date).
            using var db = NewDb();
            await new ImportService(db, _dir, _dir).RunAsync(execute: true);

            // ExternalRef is set on created litters
            var litters = await db.Litters.ToListAsync();
            Assert.All(litters, l => Assert.NotNull(l.ExternalRef));
            Assert.Contains(litters, l => l.ExternalRef == "L1");
            Assert.Contains(litters, l => l.ExternalRef == "L2");

            // Simulate the "Name+Date lookup would still work, but ExternalRef is now primary":
            // mutate Name to something different — Name+Date fallback would fail, ExternalRef must catch it.
            foreach (var l in litters) l.Name = "Geänderter Name";
            await db.SaveChangesAsync();

            // Re-import: litters detected as existing via ExternalRef even though Name changed
            var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
            Assert.Equal(0, second.Litters.Created);
            Assert.Equal(2, await db.Litters.CountAsync());
        }

        [Fact]
        public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
        {
            using var db = NewDb();
            await new ImportService(db, _dir, _dir).RunAsync(execute: true);

            var a1 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a1");
            // GEN-3b: deafness is a persisted phenotype flag (NOT a genotype locus).
            Assert.True(a1.IsDeaf);
            // Sls (2nd spotting locus) + provenance tags are preserved in RawImportData
            // (kept out of the 8-locus compact Genotype contract until GEN-3a adopts them).
            Assert.Contains("Sls", a1.RawImportData!);
            Assert.Contains("WFNZ", a1.RawImportData!);
            // GEN-3a contract: a WP/Sls carrier appends the trailing "Slsl" token (Kevin).
            Assert.EndsWith("Slsl", a1.Genotype!);
        }

        [Fact]
        public void ComposeGenotype_appends_Slsl_only_for_carriers()
        {
            // wild-type sl/sl is omitted -> plain 8-locus string
            var wild = new SourceGenotype { Mapped8locus = new() { ["A"] = new() { "a", "a" }, ["Sls"] = new() { "sl", "sl" } } };
            Assert.DoesNotContain("Sl", ImportService.ComposeGenotype(wild));
            // WP heterozygote -> trailing Slsl
            var wp = new SourceGenotype { Mapped8locus = new() { ["A"] = new() { "a", "a" }, ["Sls"] = new() { "Sl", "sl" } } };
            Assert.EndsWith("Slsl", ImportService.ComposeGenotype(wp));
        }

        [Fact]
        public async Task Synthesizes_litter_from_chart_parentRefs_links_offspring_and_parents()
        {
            // Offspring 'C' has chart-position parentRefs to father 'Papa' (loaded) and mother
            // 'Mama' (loaded), but NO Wurfchronik litterRef -> the loader must synthesize a derived
            // litter, link C to it, and set the litter's Father/Mother (PEDIGREE-LINK structural fix).
            var dir = Path.Combine(Path.GetTempPath(), "pedlink-" + Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(dir);
            try
            {
                File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
                File.WriteAllText(Path.Combine(dir, "animals.json"), """
                [
                  {"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"","gender":"male","zuchtCanon":"test",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
                  {"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"","gender":"female","zuchtCanon":"test",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
                  {"id":"ext","name":"Fremd of Foreign","dob":"03.03.2022","death":"","farbschlag":"","zuchtCanon":"foreign",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
                  {"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"","zuchtCanon":"kleinechaote",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
                   "parentRefs":[
                     {"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
                     {"name":"Mama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
                   ]}
                ]
                """);
                using var db = NewDb();
                var report = await new ImportService(db, dir, dir).RunAsync(execute: true);

                Assert.Equal(1, report.Animals.ParentLinksFromChart);
                Assert.Equal(1, report.Litters.DerivedFromChart);

                var papa = await db.Gerbils.SingleAsync(g => g.ExternalRef == "papa");
                var mama = await db.Gerbils.SingleAsync(g => g.ExternalRef == "mama");
                var c = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c");
                Assert.NotNull(c.LitterId);                              // C no longer "unbekannt"

                // box-colour sex flows through (blue=male, white=female)
                Assert.Equal(Gender.male, papa.Gender);
                Assert.Equal(Gender.female, mama.Gender);

                var litter = await db.Litters.SingleAsync(l => l.Id == c.LitterId);
                Assert.Equal(papa.Id, litter.FatherId);
                Assert.Equal(mama.Id, litter.MotherId);
                Assert.Contains("Diagramm", litter.Notes!);             // transparent + reversible

                // OWNERSHIP/RESIDENCY: C is Clan (rule a); its foreign-Zucht parents flip to
                // resident (rule b); the unrelated foreign animal stays external.
                Assert.True(c.IsResident);                               // rule (a)
                Assert.True(papa.IsResident);                            // rule (b) parent exception
                Assert.True(mama.IsResident);                            // rule (b)
                Assert.False((await db.Gerbils.SingleAsync(g => g.ExternalRef == "ext")).IsResident);
                Assert.NotNull(report.Residency);
                Assert.Equal(3, report.Residency!.Resident);
                Assert.Equal(1, report.Residency.External);
                Assert.Equal(2, report.Residency.FlippedByParentRule);
            }
            finally { try { Directory.Delete(dir, recursive: true); } catch { } }
        }

        [Fact]
        public async Task Execute_on_relational_db_with_new_chart_parents_does_not_FK_throw()
        {
            // Regression for FK_Litters_Gerbils_FatherId: a derived litter references parent gerbils
            // created in the SAME run, so they must be inserted before the litter. The EF in-memory
            // provider does NOT enforce FKs (which masked the bug), so this uses SQLite — which does.
            var dir = Path.Combine(Path.GetTempPath(), "fkfix-" + Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(dir);
            using var conn = new SqliteConnection("DataSource=:memory:");
            conn.Open();
            try
            {
                File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
                File.WriteAllText(Path.Combine(dir, "animals.json"), """
                [
                  {"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
                  {"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
                  {"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
                   "parentRefs":[
                     {"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
                     {"name":"Mama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
                   ]}
                ]
                """);
                var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
                using var db = new ApplicationContext(opts);
                await db.Database.EnsureCreatedAsync();   // schema WITH enforced FK constraints

                // must not throw FK_Litters_Gerbils_FatherId (parents inserted before the litter)
                var report = await new ImportService(db, dir, dir).RunAsync(execute: true);

                var c = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c");
                Assert.NotNull(c.LitterId);
                var litter = await db.Litters.SingleAsync(l => l.Id == c.LitterId);
                Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "papa")).Id, litter.FatherId);
                Assert.Equal(0, report.Litters.ParentFksDropped);
                Assert.Equal(0, report.Litters.DerivedSkipped);
            }
            finally { try { Directory.Delete(dir, recursive: true); } catch { } }
        }

        [Fact]
        public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
        {
            // A chart parentRef pointing to a QUARANTINED (conflict) animal must not become an FK —
            // the derived litter keeps that side null; if both sides are unresolvable, no litter.
            var dir = Path.Combine(Path.GetTempPath(), "fkq-" + Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(dir);
            using var conn = new SqliteConnection("DataSource=:memory:");
            conn.Open();
            try
            {
                File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
                File.WriteAllText(Path.Combine(dir, "animals.json"), """
                [
                  {"id":"badpa","name":"BadPapa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
                  {"id":"goodma","name":"GoodMama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
                  {"id":"c2","name":"C2","dob":"29.04.2024","death":"","farbschlag":"",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
                   "parentRefs":[
                     {"name":"BadPapa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
                     {"name":"GoodMama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
                   ]}
                ]
                """);
                var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
                using var db = new ApplicationContext(opts);
                await db.Database.EnsureCreatedAsync();

                await new ImportService(db, dir, dir).RunAsync(execute: true);   // must not throw

                Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "badpa"));  // quarantined
                var c2 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c2");
                Assert.NotNull(c2.LitterId);                                    // still linked (via mother)
                var litter = await db.Litters.SingleAsync(l => l.Id == c2.LitterId);
                Assert.Null(litter.FatherId);                                   // quarantined father -> null FK
                Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "goodma")).Id, litter.MotherId);
            }
            finally { try { Directory.Delete(dir, recursive: true); } catch { } }
        }

        [Fact]
        public async Task Decision_resolved_animal_loads_and_is_counted()
        {
            // extract.py clears the conflict + sets resolvedByDecision when a human conflict-decision
            // un-quarantines an animal; the loader must then LOAD it and surface the count.
            var dir = Path.Combine(Path.GetTempPath(), "dec-" + Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(dir);
            try
            {
                File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
                File.WriteAllText(Path.Combine(dir, "animals.json"), """
                [
                  {"id":"firefly","name":"Firefly","dob":"18.12.2019","death":"","farbschlag":"Agouti",
                   "genotype":{"mapped8locus":{"A":["A","a"],"D":["D","?"]},"rawGenotype":"Aa D-","unmappedTokens":[]},
                   "conflict":false,"resolvedByDecision":true}
                ]
                """);
                using var db = NewDb();
                var report = await new ImportService(db, dir, dir).RunAsync(execute: true);

                Assert.Equal(1, report.Animals.ConflictsResolvedByDecision);
                Assert.True(await db.Gerbils.AnyAsync(g => g.ExternalRef == "firefly"));  // loaded, not quarantined
                Assert.Equal(0, report.Animals.Quarantined.Conflicts);
            }
            finally { try { Directory.Delete(dir, recursive: true); } catch { } }
        }

        [Fact]
        public void ComposeGenotype_strips_carets_and_fills_missing_loci()
        {
            var g = new SourceGenotype
            {
                Mapped8locus = new()
                {
                    ["A"] = new() { "a", "a" },
                    ["C"] = new() { "C", "c^chm" },
                    ["E"] = new() { "e", "e^f" },
                },
            };
            // order A C D E G P Sp Re ; missing -> ??
            Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g));
        }

        [Fact]
        public async Task ParentFkBackfill_fills_null_litter_parent_on_reimport()
        {
            // Run 1: litter "Wurf A" has sire "Vater" (conflict=true — not loaded) and dam "Mutter"
            //        (conflict=false — loaded). After run 1: litter.FatherId = null.
            // Run 2: sire "Vater" now conflict=false → loaded as NEW in run 2. Backfill via
            //        createdAnimalByName sets FatherId. (god steering point 3: run-2 path.)
            var dir = Path.Combine(Path.GetTempPath(), "backfill-" + Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(dir);
            using var conn = new SqliteConnection("DataSource=:memory:");
            conn.Open();
            try
            {
                var littersJson = """
                [{"id":"L-A","litterId":"A","date":"01.05.2023","damName":"Mutter [ZdkC]","sireName":"Vater [ZdkC]","totalBorn":3,"zuchtnummer":"","note":""}]
                """;
                // Run 1: Vater is in conflict -> not loaded
                var animals1 = """
                [
                  {"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
                  {"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
                  {"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
                   "litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
                ]
                """;
                File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
                File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);

                var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
                using var db = new ApplicationContext(opts);
                await db.Database.EnsureCreatedAsync();

                var report1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
                Assert.Equal(0, report1.Litters.ParentFksBackfilled);
                var litter1 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
                Assert.Null(litter1.FatherId);              // Vater was quarantined -> null FK
                Assert.NotNull(litter1.MotherId);           // Mutter was loaded -> set

                // Run 2: Vater now conflict=false -> loaded as NEW animal in this run
                var animals2 = """
                [
                  {"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
                  {"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
                  {"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
                   "litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
                ]
                """;
                File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);

                var report2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
                Assert.Equal(1, report2.Litters.ParentFksBackfilled);    // backfill happened
                var vater = await db.Gerbils.SingleAsync(g => g.ExternalRef == "vater");
                var litter2 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
                Assert.Equal(vater.Id, litter2.FatherId);                // FK now set
            }
            finally
            {
                try { Directory.Delete(dir, recursive: true); } catch { }
            }
        }

        [Fact]
        public async Task ParentFkBackfill_uses_allDb_lookup_when_parent_not_in_current_loadable()
        {
            // god steering point 3: the main case — parent was loaded in a PREVIOUS run (not in
            // the current run's animals.json at all). Backfill must find them via allDbNormToGid.
            //
            // Run 1: litter "Wurf C" + dam loaded, sire quarantined -> FatherId null.
            // Run 2: sire loaded (new animal).
            // Run 3: animals.json has ONLY the kind (sire absent from extract). Sire is in DB
            //        from run 2 but NOT in the current run's loadable/createdAnimalByName.
            //        Backfill must use allDbNormToGid to find him.
            var dir = Path.Combine(Path.GetTempPath(), "backfill-db-" + Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(dir);
            using var conn = new SqliteConnection("DataSource=:memory:");
            conn.Open();
            try
            {
                var littersJson = """
                [{"id":"L-C","litterId":"C","date":"10.06.2023","damName":"Dame [ZdkC]","sireName":"Herr [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
                """;
                // Run 1: sire quarantined
                File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
                File.WriteAllText(Path.Combine(dir, "animals.json"), """
                [
                  {"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
                  {"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
                ]
                """);
                var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
                using var db = new ApplicationContext(opts);
                await db.Database.EnsureCreatedAsync();
                await new ImportService(db, dir, dir).RunAsync(execute: true);
                Assert.Null((await db.Litters.SingleAsync(l => l.Name == "Wurf C")).FatherId);

                // Run 2: sire now loaded
                File.WriteAllText(Path.Combine(dir, "animals.json"), """
                [
                  {"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
                  {"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
                ]
                """);
                await new ImportService(db, dir, dir).RunAsync(execute: true);
                var herrId = (await db.Gerbils.SingleAsync(g => g.ExternalRef == "herr")).Id;
                // Run 2 itself may or may not backfill (depends on name normalization alignment).
                // For the test we care about run 3.

                // Run 3: sire NOT in animals.json at all (absent from new extract).
                // litter still has FatherId=null if run 2 didn't backfill; if it did, we simulate
                // by manually resetting FatherId to null so run 3 must fix it.
                var litter3 = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
                litter3.FatherId = null;
                await db.SaveChangesAsync();

                File.WriteAllText(Path.Combine(dir, "animals.json"), """
                [
                  {"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
                ]
                """);
                // Run 3: sire absent from loadable (NOT in createdAnimalByName), but IS in DB.
                var report3 = await new ImportService(db, dir, dir).RunAsync(execute: true);
                Assert.Equal(1, report3.Litters.ParentFksBackfilled);   // allDbNormToGid path
                var litter3After = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
                Assert.Equal(herrId, litter3After.FatherId);             // FK set from DB lookup
            }
            finally
            {
                try { Directory.Delete(dir, recursive: true); } catch { }
            }
        }

        [Fact]
        public async Task ParentFkBackfill_dry_run_counts_without_writing()
        {
            // Dry-run on a DB with an existing null-parent litter should predict the backfill count.
            var dir = Path.Combine(Path.GetTempPath(), "backfill-dr-" + Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(dir);
            using var conn = new SqliteConnection("DataSource=:memory:");
            conn.Open();
            try
            {
                var littersJson = """
                [{"id":"L-B","litterId":"B","date":"15.06.2023","damName":"Mami [ZdkC]","sireName":"Papi [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
                """;
                var animals1 = """
                [
                  {"id":"mami","name":"Mami [ZdkC]","dob":"03.03.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
                  {"id":"papi","name":"Papi [ZdkC]","dob":"04.04.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
                   "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
                ]
                """;
                File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
                File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);

                var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
                using var db = new ApplicationContext(opts);
                await db.Database.EnsureCreatedAsync();
                await new ImportService(db, dir, dir).RunAsync(execute: true);   // run 1

                // Run 2 dry-run with papi un-quarantined
                var animals2 = animals1.Replace("\"conflict\":true", "\"conflict\":false");
                File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
                var dry = await new ImportService(db, dir, dir).RunAsync(execute: false);

                Assert.Equal(1, dry.Litters.ParentFksBackfilled);     // predicted but not written
                var litter = await db.Litters.SingleAsync(l => l.Name == "Wurf B");
                Assert.Null(litter.FatherId);                          // not written in dry-run
            }
            finally
            {
                try { Directory.Delete(dir, recursive: true); } catch { }
            }
        }

        [Fact]
        public async Task UndatedLitters_counted_as_WithoutDate_not_Created()
        {
            // COUNTER-BUG regression: litters with no parseable date must go into WithoutDate,
            // NOT Created. On re-import, Created must be 0 (not 31-phantom-phantom-phantom...).
            var dir = Path.Combine(Path.GetTempPath(), "undated-" + Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(dir);
            try
            {
                // One dated litter, one undated litter (blank date field)
                File.WriteAllText(Path.Combine(dir, "litters.json"), """
                [
                  {"id":"L-dated","litterId":"A","date":"01.02.2020","damName":"Mutter","sireName":"Vater","totalBorn":3,"zuchtnummer":"","note":""},
                  {"id":"L-undated","litterId":"B","date":"","damName":"Mutter","sireName":"Vater","totalBorn":0,"zuchtnummer":"","note":""}
                ]
                """);
                File.WriteAllText(Path.Combine(dir, "animals.json"), "[]");

                using var db = NewDb();

                // First run
                var r1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
                Assert.Equal(2, r1.Litters.InSource);
                Assert.Equal(1, r1.Litters.Created);        // only the dated one
                Assert.Equal(0, r1.Litters.AlreadyImported);
                Assert.Equal(1, r1.Litters.WithoutDate);    // the undated one
                Assert.Equal(1, await db.Litters.CountAsync());  // only 1 persisted

                // Second run (re-import): dated litter is now existing, undated still WithoutDate
                var r2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
                Assert.Equal(0, r2.Litters.Created);        // no phantom "created"
                Assert.Equal(1, r2.Litters.AlreadyImported);
                Assert.Equal(1, r2.Litters.WithoutDate);
                Assert.Equal(1, await db.Litters.CountAsync());  // still only 1 row
            }
            finally { try { Directory.Delete(dir, recursive: true); } catch { } }
        }

        [Fact]
        public void SeedGen3g_existing_varieties_preserve_id_name_binding()
        {
            // SEED-HELL bounce regression: the 61 existing entries must NOT change their
            // Id->Name binding after GEN-3g. The 5 new entries (IDs 62-66) are appended.
            // A hand-assigned Gerbil.ColorVarietyId pointing to "CP-Fuchs" (ID 58) must
            // still map to CP-Fuchs after the migration runs (append-only, no rename-shift).
            using var db = NewDb();   // EnsureCreated applies HasData including new 66-entry seed

            // ID 58 (index 57 in old catalog, 0-based) = CP-Fuchs  — must still be CP-Fuchs
            var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058");
            var cpFuchs = db.ColorVarieties.Find(cpFuchsId);
            Assert.NotNull(cpFuchs);
            Assert.Equal("CP-Fuchs", cpFuchs!.Name);

            // New entries at IDs 62-66 exist with correct names
            Assert.Equal("CP-Agouti-Hell",        db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000062"))!.Name);
            Assert.Equal("CP-Silberagouti-Hell",   db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000063"))!.Name);
            Assert.Equal("CP-Algierfuchs-Hell",    db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000064"))!.Name);
            Assert.Equal("CP-Polarfuchs-Hell",     db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000065"))!.Name);
            Assert.Equal("CP-Orangeschimmel-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000066"))!.Name);

            // Total count is exactly 66
            Assert.Equal(66, db.ColorVarieties.Count());
        }

        [Theory]
        [InlineData("01.02.2020", 2020, 2, 1)]
        [InlineData("5.3.21", 2021, 3, 5)]
        public void ParseDate_handles_german_dates(string s, int y, int m, int d)
        {
            var date = ImportService.ParseDate(s);
            Assert.Equal(new DateOnly(y, m, d), date);
        }

        [Fact]
        public async Task CR9_NameDOB_drift_falls_back_to_ExternalRef_no_throw()
        {
            // CR-9: if an already-imported animal's Name or DOB in animals.json no longer matches
            // what's stored in the DB (e.g. after a correctDob remap or manual UI rename), the
            // gidByNameDob lookup used to throw KeyNotFoundException. Now it falls back to the
            // stable ExternalRef without throwing.
            var dir = Path.Combine(Path.GetTempPath(), "cr9-" + Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(dir);
            try
            {
                File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
                File.WriteAllText(Path.Combine(dir, "animals.json"), """
                [{"id":"drift","name":"Drift Tier","dob":"01.01.2021","death":"","farbschlag":"",
                  "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},
                  "conflict":false}]
                """);
                using var db = NewDb();
                // Run 1: load the animal normally
                await new ImportService(db, dir, dir).RunAsync(execute: true);
                Assert.Equal(1, await db.Gerbils.CountAsync());

                // Simulate drift: manually rename the animal in the DB (UI rename scenario)
                var g = await db.Gerbils.SingleAsync(x => x.ExternalRef == "drift");
                g.Name = "Umbenannt Tier";
                await db.SaveChangesAsync();

                // Run 2: animals.json still has old name "Drift Tier" — must NOT throw
                var report2 = await new ImportService(db, dir, dir).RunAsync(execute: false);
                // Dry-run should complete without throwing; animal is found by ExternalRef fallback
                Assert.Equal(1, await db.Gerbils.CountAsync());   // no duplicate created
            }
            finally { try { Directory.Delete(dir, recursive: true); } catch { } }
        }

        [Fact]
        public async Task CR11_ColorVariety_derived_from_genotype_when_no_explicit_farbschlag()
        {
            // CR-11: deep-band animals have empty Farbschlag but a full genotype. The loader
            // must derive ColorVarietyId from the catalog when the name-match yields nothing.
            // "Agouti" = aa CC DD EE GG PP spsp rere (first seed entry, ID 00000001).
            var dir = Path.Combine(Path.GetTempPath(), "cr11-" + Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(dir);
            try
            {
                File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
                // Exact Agouti genotype, no explicit Farbschlag name
                File.WriteAllText(Path.Combine(dir, "animals.json"), """
                [{"id":"agouti-deep","name":"Opa Waldmann","dob":"01.01.2018","death":"","farbschlag":"",
                  "genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","D"],"E":["E","E"],"G":["G","G"],"P":["P","P"],"Sp":["sp","sp"],"Re":["re","re"]},
                              "rawGenotype":"aa CC DD EE GG PP spsp rere","unmappedTokens":[]},
                  "conflict":false}]
                """);
                using var db = NewDb();
                var report = await new ImportService(db, dir, dir).RunAsync(execute: true);

                var tier = await db.Gerbils.SingleAsync(g => g.ExternalRef == "agouti-deep");
                // ColorVarietyId must be set even though no explicit Farbschlag name was given
                Assert.NotNull(tier.ColorVarietyId);
                // Should be the "Agouti" variety (id = 00000000-0000-0000-0000-000000000001)
                var variety = await db.ColorVarieties.FindAsync(tier.ColorVarietyId);
                Assert.Equal("Agouti", variety!.Name);
                // Report counter should reflect the genotype derivation
                Assert.True(report.Animals.FarbschlagDerivedFromGenotype > 0);
            }
            finally { try { Directory.Delete(dir, recursive: true); } catch { } }
        }

        // ---- fixtures ----
        private const string LittersJson = """
        [
          {"id":"L1","litterId":"A","date":"01.02.2020","damName":"Mama [X]","sireName":"Papa of Y","totalBorn":4,"zuchtnummer":"G01/ZdkC","note":"erster Wurf"},
          {"id":"L2","litterId":"B","date":"05.03.2021","damName":"Oma [Z]","sireName":"Opa of W","totalBorn":2,"zuchtnummer":"G02/ZdkC","note":""}
        ]
        """;

        private const string AnimalsJson = """
        [
          {"id":"a1","name":"Kind Eins","dob":"01.02.2020","death":"","gender":null,
           "farbschlag":"Agouti","farbschlagVariants":["Agouti"],
           "genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","?"],"E":["e","e^f"],"Sls":["Sl","sl"]},"rawGenotype":"aa CC D- ee[f] WP dea WFNZ","unmappedTokens":[]},
           "deaf":true,"tags":["WFNZ"],
           "zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false,
           "litterRef":{"litterId":"L1","method":"geburtsdatum+eltern","confidence":"hoch"}},
          {"id":"a2","name":"Streit","dob":"01.01.2019","death":"","gender":null,
           "farbschlag":"Schwarz","farbschlagVariants":["Schwarz"],
           "genotype":{"mapped8locus":{"A":["a","a"]},"rawGenotype":"aa","unmappedTokens":[]},
           "zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1","f2"],"conflict":true},
          {"id":"a3","name":"Namenlos","dob":"","death":"","gender":null,
           "farbschlag":"","farbschlagVariants":[],
           "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},
           "zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false},
          {"id":"a4","name":"Unsicher","dob":"05.03.2021","death":"","gender":null,
           "farbschlag":"","farbschlagVariants":[],
           "genotype":{"mapped8locus":{"A":["A","a"]},"rawGenotype":"Aa","unmappedTokens":[]},
           "zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false,
           "litterRef":{"litterId":"L2","method":"geburtsdatum","confidence":"niedrig"}}
        ]
        """;
    }
}
