using System.Text.Json;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.EntityFrameworkCore;

namespace GerbilManagerWebAPI.Import.Rpro3
{
    /// <summary>
    /// „First class" RennmausPro-III-Backup-Importer. Parst die `.backup` (SQLite), dedupliziert
    /// RPRO3-interne Dubletten (Port von compare_rpro3.py) und überführt die Daten in unser Modell.
    ///
    /// Zwei Operationen:
    ///   AnalyzeAsync  — schreibt NICHTS; liefert Zählungen, Top-Merges, mehrdeutige Namen, neu/vorhanden.
    ///   ExecuteAsync  — idempotenter Import (deterministische GUIDs + ExternalRef). Re-Run aktualisiert
    ///                   dieselben Zeilen; vorherige RPRO3-Importe (ImportSource="RennmausPro III")
    ///                   werden vor dem Neuladen entfernt. Spreadsheet-Importe bleiben unangetastet.
    /// </summary>
    public sealed class Rpro3ImportService
    {
        private readonly ApplicationContext _db;
        private readonly string _photoRoot;
        private readonly Rpro3Decisions _decisions;
        private Dictionary<string, Rpro3FieldOverride>? _fieldIndex;

        public Rpro3ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
        {
            _db = db;
            var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
            _photoRoot = config["Photos:RootPath"] ?? Path.Combine(contentRoot, "photo-storage");
            // Manuelle Dubletten-Entscheidungen der Züchterin (siehe Rpro3Decisions). Pfad
            // überschreibbar per Config; Default: neben dem Importer-Code (wird mit ins Output kopiert).
            var decPath = config["Rpro3:DecisionsPath"]
                          ?? Path.Combine(contentRoot, "Import", "Rpro3", "rpro3-decisions.json");
            _decisions = Rpro3Decisions.Load(decPath);
        }

        private Dictionary<string, Rpro3FieldOverride> FieldIndex =>
            _fieldIndex ??= _decisions.BuildFieldIndex();

        // ───────────────────────── ANALYZE ─────────────────────────

        public async Task<Rpro3AnalyzeResult> AnalyzeAsync(
            Rpro3Data data, bool photosProvided, IReadOnlySet<string>? availablePhotoFiles)
        {
            var dedup = Rpro3Dedup.Run(data.Animals, _decisions);
            var plan = BuildPlan(data, dedup);

            // Abgleich gegen Bestand: Match über separator-insensitiven NameSearch + DOB-Toleranz.
            var existing = await _db.Gerbils.AsNoTracking()
                .Select(g => new { g.NameSearch, g.DateOfBirth, g.ExternalRef })
                .ToListAsync();
            var existingRefs = existing.Where(e => e.ExternalRef is not null)
                .Select(e => e.ExternalRef!).ToHashSet(StringComparer.Ordinal);
            var existingByName = existing
                .GroupBy(e => e.NameSearch ?? "")
                .ToDictionary(g => g.Key, g => g.Select(e => e.DateOfBirth).ToList());

            int newCount = 0, existingCount = 0;
            foreach (var gp in plan.Gerbils.Values)
            {
                if (existingRefs.Contains(gp.ExternalRef)) { existingCount++; continue; }
                var key = GerbilSearch.Normalize(gp.Name);
                if (existingByName.TryGetValue(key, out var dobs) && DobMatch(gp.Dob, dobs)) existingCount++;
                else newCount++;
            }

            var counts = new Rpro3Counts(
                OwnAnimals: data.Animals.Count(a => a.Src == Rpro3Src.Stamm),
                ExternalRaw: data.Animals.Count(a => a.Src == Rpro3Src.Fremd),
                ExternalAfterDedup: plan.Gerbils.Values.Count(g => !g.IsResident),
                Litters: data.Litters.Count,
                Contacts: data.HerkContacts.Count + data.AbnContacts.Count,
                Genotypes: data.ColorStammCount + data.ColorExtCount,
                DuplicatesMerged: dedup.DuplicatesRemoved,
                MergeClusters: dedup.MergeClusters.Count,
                Enclosures: plan.Enclosures.Count,
                Acquisitions: plan.Acquisitions.Count,
                Reservations: plan.Reservations.Count,
                Returns: plan.Returns.Count,
                WaitingList: plan.WaitingList.Count,
                Exhibitions: plan.Exhibitions.Count);

            var topMerges = dedup.MergeClusters.Values
                .OrderByDescending(v => v.Count)
                .Take(25)
                .Select(v =>
                {
                    var rep = Rpro3Dedup.Representative(v);
                    return new Rpro3MergeSample(
                        rep.Name ?? "—",
                        v.Count,
                        string.Join(", ", v.Where(x => x.Dob is not null).Select(x => x.Dob!.Value.ToString("yyyy-MM-dd")).Distinct()),
                        Truncate(string.Join(", ", v.Where(x => !string.IsNullOrEmpty(x.Farbe)).Select(x => x.Farbe!).Distinct()), 60),
                        Truncate(string.Join(", ", v.Where(x => Rpro3Dedup.NormValue(x.Origin).Length > 0).Select(x => x.Origin!).Distinct()), 60));
                })
                .ToList();

            var ambiguous = dedup.Ambiguous
                .Take(80)
                .Select(a => new Rpro3AmbiguousName(
                    a.Name,
                    a.Variants.Select(v => new Rpro3AmbiguousVariant(
                        v.Count,
                        v.Dob.Count > 0 ? string.Join(", ", v.Dob) : "—",
                        v.Farbe.Count > 0 ? Truncate(string.Join(", ", v.Farbe), 40) : "—",
                        v.Origin.Count > 0 ? Truncate(string.Join(", ", v.Origin), 40) : "—",
                        v.IsOwn)).ToList(),
                    a.BareCount))
                .ToList();

            return new Rpro3AnalyzeResult(
                counts, newCount, existingCount, topMerges, ambiguous,
                photosProvided, availablePhotoFiles?.Count ?? 0);
        }

        private static bool DobMatch(DateOnly? rpDob, List<DateOnly?> existing)
        {
            // Match, wenn ein Bestandstier denselben (oder unbekannten) Geburtstag hat (±14 Tage).
            if (rpDob is null) return true;
            foreach (var d in existing)
            {
                if (d is null) return true;
                if (Math.Abs((rpDob.Value.ToDateTime(TimeOnly.MinValue) - d.Value.ToDateTime(TimeOnly.MinValue)).TotalDays) <= 14)
                    return true;
            }
            return false;
        }

        // ───────────────────────── EXECUTE ─────────────────────────

        public async Task<Rpro3ExecuteResult> ExecuteAsync(
            Rpro3Data data, string workDir, bool photosProvided, IReadOnlyDictionary<string, string>? photoSourcePaths)
        {
            var dedup = Rpro3Dedup.Run(data.Animals, _decisions);
            var plan = BuildPlan(data, dedup);

            // Change-Tracker leeren: ExecuteDelete/Update umgehen den Tracker; bei wiederholtem
            // ExecuteAsync auf derselben DbContext-Instanz würden sonst alte Tracking-Einträge
            // mit den neu eingefügten (gleiche deterministische IDs) kollidieren.
            _db.ChangeTracker.Clear();

            // 1. Vorherigen RPRO3-Import idempotent entfernen (deterministische IDs → Re-Run ok).
            //    Health/Weight zuerst (FK auf Gerbil), dann FK-Links lösen, dann Gerbils/Litters.
            var priorGerbilIds = await _db.Gerbils
                .Where(g => g.ImportSource == Rpro3Guid.ImportSource)
                .Select(g => g.Id).ToListAsync();
            var priorLitterRefs = plan.Litters.Values.Select(l => l.ExternalRef).ToList();

            if (priorGerbilIds.Count > 0)
            {
                await _db.HealthRecords.Where(h => priorGerbilIds.Contains(h.GerbilId)).ExecuteDeleteAsync();
                await _db.WeightRecords.Where(w => priorGerbilIds.Contains(w.GerbilId)).ExecuteDeleteAsync();
                await _db.GerbilPhotos.Where(p => priorGerbilIds.Contains(p.GerbilId)).ExecuteDeleteAsync();
            }
            // FK-Schleifen lösen: Litter.Father/Mother + Gerbil.Litter, die auf RPRO3-Tiere zeigen.
            await _db.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef))
                .ExecuteUpdateAsync(s => s.SetProperty(l => l.FatherId, (Guid?)null).SetProperty(l => l.MotherId, (Guid?)null));
            await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource)
                .ExecuteUpdateAsync(s => s.SetProperty(g => g.LitterId, (Guid?)null));
            await _db.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef)).ExecuteDeleteAsync();
            await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource).ExecuteDeleteAsync();

            // 1b. Feature-Tabellen idempotent leeren. Diese Entities haben KEINE ImportSource-Spalte,
            //     daher löschen wir gezielt die deterministischen RPRO3-IDs, die dieser Import erzeugt
            //     (Re-Import desselben Backups → keine Dubletten). Manuell angelegte Einträge mit
            //     anderen IDs bleiben unangetastet. Enclosures werden NICHT aus Gerbils gelöscht,
            //     nur die EnclosureId-Verknüpfung gelöschter Tiere fällt mit den Tieren weg.
            var acqIds = plan.Acquisitions.Select(a => a.Id).ToList();
            var resIds = plan.Reservations.Select(r => r.Id).ToList();
            var retIds = plan.Returns.Select(r => r.Id).ToList();
            var wlIds = plan.WaitingList.Select(w => w.Id).ToList();
            var exhIds = plan.Exhibitions.Select(e => e.Id).ToList();
            var encIds = plan.Enclosures.Keys.ToList();
            if (acqIds.Count > 0) await _db.AcquisitionRecords.Where(x => acqIds.Contains(x.Id)).ExecuteDeleteAsync();
            if (resIds.Count > 0) await _db.SaleReservations.Where(x => resIds.Contains(x.Id)).ExecuteDeleteAsync();
            if (retIds.Count > 0) await _db.ReturnRecords.Where(x => retIds.Contains(x.Id)).ExecuteDeleteAsync();
            if (wlIds.Count > 0) await _db.WaitingListEntries.Where(x => wlIds.Contains(x.Id)).ExecuteDeleteAsync();
            if (exhIds.Count > 0) await _db.ExhibitionResults.Where(x => exhIds.Contains(x.Id)).ExecuteDeleteAsync();

            // 2. Kontakte upserten (deterministische IDs; überleben als eigenständige Bestände).
            int contactsImported = 0;
            var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
            foreach (var c in plan.Contacts.Values)
            {
                if (existingContacts.TryGetValue(c.Id, out var ex))
                {
                    ex.Name = c.Name; ex.Email = c.Email; ex.Phone = c.Phone; ex.Address = c.Address;
                    ex.Notes = c.Notes; ex.IsBreeder = c.IsBreeder; ex.IsReceiver = c.IsReceiver;
                    ex.NameSuffix = c.NameSuffix; ex.Provenance = c.Provenance;
                }
                else { _db.Contacts.Add(c); }
                contactsImported++;
            }
            await _db.SaveChangesAsync();

            // 2b. Enclosures upserten (deterministische IDs). MUSS vor den Gerbils laufen, da
            //     Gerbil.EnclosureId ein echter FK ist. Re-Import aktualisiert dieselben Becken.
            var existingEnclosures = await _db.Enclosures
                .Where(e => plan.Enclosures.Keys.Contains(e.Id)).ToDictionaryAsync(e => e.Id);
            foreach (var en in plan.Enclosures.Values)
            {
                if (existingEnclosures.TryGetValue(en.Id, out var ex))
                {
                    ex.Name = en.Name; ex.Size = en.Size; ex.Capacity = en.Capacity;
                    ex.LastCleanedDate = en.LastCleanedDate; ex.CleaningCycleDays = en.CleaningCycleDays;
                }
                else { _db.Enclosures.Add(en); }
            }
            await _db.SaveChangesAsync();

            // 3. Litters (Pass 1: ohne Eltern-FKs).
            foreach (var l in plan.Litters.Values)
                _db.Litters.Add(new Litter
                {
                    Id = l.Id, Name = l.Name, Date = l.Date, ExternalRef = l.ExternalRef,
                    Notes = l.Notes, Provenance = l.Provenance, FatherId = null, MotherId = null,
                });
            await _db.SaveChangesAsync();

            // 4. Gerbils (Pass 1: ohne LitterId).
            var today = DateOnly.FromDateTime(DateTime.UtcNow);
            foreach (var g in plan.Gerbils.Values)
            {
                var gerbil = new Gerbil
                {
                    Id = g.Id, Name = g.Name, Gender = g.Gender,
                    DateOfBirth = g.Dob, DateOfDeath = g.DateOfDeath, CauseOfDeath = g.CauseOfDeath,
                    Genotype = g.Genotype, ColorVarietyId = g.ColorVarietyId, OriginBreeder = g.OriginBreeder,
                    OriginContactId = g.OriginContactId, ReceiverContactId = g.ReceiverContactId,
                    GoHomeDate = g.GoHomeDate, IsResident = g.IsResident, IsCastrated = g.IsCastrated,
                    Notes = g.Notes, ImportSource = Rpro3Guid.ImportSource, ExternalRef = g.ExternalRef,
                    Provenance = g.Provenance, LitterId = null,
                    EnclosureId = plan.GerbilEnclosure.TryGetValue(g.Id, out var encId) ? encId : null,
                };
                GerbilStatusService.Apply(gerbil, today);
                _db.Gerbils.Add(gerbil);
            }
            await _db.SaveChangesAsync();

            // 5. Pass 2: FK-Beziehungen setzen (Litter-Eltern + Gerbil-Geburtswurf).
            var littersInDb = await _db.Litters.Where(l => l.ExternalRef != null && priorLitterRefs.Contains(l.ExternalRef)).ToDictionaryAsync(l => l.Id);
            foreach (var l in plan.Litters.Values)
                if (littersInDb.TryGetValue(l.Id, out var dbl))
                {
                    dbl.MotherId = l.MotherId; dbl.FatherId = l.FatherId;
                }
            var gerbilsInDb = await _db.Gerbils.Where(g => g.ImportSource == Rpro3Guid.ImportSource).ToDictionaryAsync(g => g.Id);
            foreach (var g in plan.Gerbils.Values)
                if (g.LitterId is not null && gerbilsInDb.TryGetValue(g.Id, out var dbg))
                    dbg.LitterId = g.LitterId;
            await _db.SaveChangesAsync();

            // 6. Gesundheits- und Gewichtseinträge.
            foreach (var h in plan.Health) _db.HealthRecords.Add(h);
            foreach (var w in plan.Weights) _db.WeightRecords.Add(w);
            await _db.SaveChangesAsync();

            // 7. Fotos (optional, aus bilder.zip).
            int photosImported = 0;
            if (photosProvided && photoSourcePaths is not null)
            {
                Directory.CreateDirectory(_photoRoot);
                foreach (var (gerbilId, fileNames) in plan.PhotoFiles)
                {
                    int sort = 0;
                    foreach (var fn in fileNames)
                    {
                        if (!photoSourcePaths.TryGetValue(fn.ToLowerInvariant(), out var src) || !File.Exists(src)) continue;
                        var ext = Path.GetExtension(fn);
                        var storedName = $"{Guid.NewGuid():N}{ext}";
                        try { File.Copy(src, Path.Combine(_photoRoot, storedName), overwrite: true); }
                        catch { continue; }
                        _db.GerbilPhotos.Add(new GerbilPhoto
                        {
                            Id = Guid.NewGuid(), GerbilId = gerbilId, FileName = storedName,
                            SortOrder = sort++, CreatedAt = DateTimeOffset.UtcNow,
                        });
                        photosImported++;
                    }
                }
                await _db.SaveChangesAsync();
            }

            // 8. Feature-Tabellen einfügen (Enclosures schon in 2b erledigt).
            foreach (var a in plan.Acquisitions) _db.AcquisitionRecords.Add(a);
            foreach (var r in plan.Reservations) _db.SaleReservations.Add(r);
            foreach (var r in plan.Returns) _db.ReturnRecords.Add(r);
            foreach (var w in plan.WaitingList) _db.WaitingListEntries.Add(w);
            foreach (var e in plan.Exhibitions) _db.ExhibitionResults.Add(e);
            await _db.SaveChangesAsync();

            return new Rpro3ExecuteResult(
                plan.Gerbils.Count, plan.Litters.Count, contactsImported,
                plan.Health.Count, plan.Weights.Count, photosImported,
                plan.Enclosures.Count, plan.Acquisitions.Count, plan.Reservations.Count,
                plan.Returns.Count, plan.WaitingList.Count, plan.Exhibitions.Count,
                $"Import erfolgreich: {plan.Gerbils.Count} Tiere, {plan.Litters.Count} Würfe, " +
                $"{contactsImported} Kontakte, {plan.Health.Count} Gesundheits- und {plan.Weights.Count} Gewichtseinträge, " +
                $"{photosImported} Fotos, {plan.Enclosures.Count} Gehege, {plan.Acquisitions.Count} Erwerbe, " +
                $"{plan.Reservations.Count} Reservierungen, {plan.Returns.Count} Rücknahmen, " +
                $"{plan.WaitingList.Count} Wartelisten-Einträge, {plan.Exhibitions.Count} Ausstellungen.");
        }

        // ───────────────────────── PLAN-AUFBAU ─────────────────────────

        private sealed class GerbilPlan
        {
            public required Guid Id { get; init; }
            public required string ExternalRef { get; init; }
            public required string Name { get; set; }
            public Gender Gender { get; set; }
            public DateOnly? Dob { get; set; }
            public DateOnly? DateOfDeath { get; set; }
            public string? CauseOfDeath { get; set; }
            public string? Genotype { get; set; }
            public Guid? ColorVarietyId { get; set; }
            public string? OriginBreeder { get; set; }
            public Guid? OriginContactId { get; set; }
            public Guid? ReceiverContactId { get; set; }
            public DateOnly? GoHomeDate { get; set; }
            public bool IsResident { get; set; }
            public bool IsCastrated { get; set; }
            public string? Notes { get; set; }
            public string? Provenance { get; set; }
            public Guid? LitterId { get; set; }
            public string? MotherRid { get; set; }
            public string? FatherRid { get; set; }
        }

        private sealed class LitterPlan
        {
            public required Guid Id { get; init; }
            public required string ExternalRef { get; init; }
            public string Name { get; set; } = "Wurf";
            public DateOnly? Date { get; set; }
            public Guid? MotherId { get; set; }
            public Guid? FatherId { get; set; }
            public string? Notes { get; set; }
            public string? Provenance { get; set; }
        }

        private sealed class ImportPlan
        {
            public Dictionary<Guid, GerbilPlan> Gerbils { get; } = new();
            public Dictionary<Guid, LitterPlan> Litters { get; } = new();
            public Dictionary<Guid, Contact> Contacts { get; } = new();
            public List<HealthRecord> Health { get; } = new();
            public List<WeightRecord> Weights { get; } = new();
            public Dictionary<Guid, List<string>> PhotoFiles { get; } = new();

            // Feature-Tabellen.
            public Dictionary<Guid, Enclosure> Enclosures { get; } = new();
            public Dictionary<Guid, Guid> GerbilEnclosure { get; } = new(); // GerbilId → EnclosureId
            public List<AcquisitionRecord> Acquisitions { get; } = new();
            public List<SaleReservation> Reservations { get; } = new();
            public List<ReturnRecord> Returns { get; } = new();
            public List<WaitingListEntry> WaitingList { get; } = new();
            public List<ExhibitionResult> Exhibitions { get; } = new();
        }

        private ImportPlan BuildPlan(Rpro3Data data, Rpro3Dedup.DedupResult dedup)
        {
            _lastDedup = dedup;
            var plan = new ImportPlan();

            // 0) Farbschlag-Katalog (Name → Id) für ColorVariety-Verknüpfung.
            var colorByName = _db.ColorVarieties.AsNoTracking()
                .ToDictionary(c => Rpro3Dedup.NormName(c.Name), c => c.Id);

            // 1) Kontakte (herk = Züchter/Herkunft, abn = Abnehmer).
            Guid? HerkContactId(int? herkId)
            {
                if (herkId is null || herkId == 1) return null; // 1 = eigene Zucht → kein Kontakt
                if (!data.HerkContacts.TryGetValue(herkId.Value.ToString(), out var c)) return null;
                return EnsureContact(plan, c, isBreeder: true, isReceiver: false);
            }
            Guid? AbnContactId(int? abnId)
            {
                if (abnId is null) return null;
                if (!data.AbnContacts.TryGetValue(abnId.Value.ToString(), out var c)) return null;
                return EnsureContact(plan, c, isBreeder: false, isReceiver: true);
            }

            // 2) Rid → Cluster-Root → Gerbil-GUID. Platzhalter-Tiere bekommen ihren eigenen Root.
            string RootOf(string rid) => dedup.RidToRoot.TryGetValue(rid, out var r) ? r : rid;
            Guid GerbilIdOfRid(string rid) => Rpro3Guid.Gerbil(RootOf(rid));

            // 3) Pup-Index: SID(stamm-id) → pup, und stamm-id → Welpen-Daten (Abgabe/Preis/Tod).
            var pupBySid = new Dictionary<string, Rpro3Pup>(StringComparer.Ordinal);
            foreach (var p in data.Pups.Values)
                if (!string.IsNullOrWhiteSpace(p.Sid) && p.Sid != "0")
                    pupBySid[p.Sid] = p;

            // 4) Ein Gerbil je Cluster (Repräsentant führt; stamm-Tiere machen den Cluster resident).
            //    Singletons (RidToRoot == self) ebenfalls einbeziehen.
            var clusterMembers = new Dictionary<string, List<Rpro3Animal>>(StringComparer.Ordinal);
            foreach (var a in data.Animals)
            {
                var root = RootOf(a.Rid);
                if (!clusterMembers.TryGetValue(root, out var list)) clusterMembers[root] = list = new();
                list.Add(a);
            }

            foreach (var (root, members) in clusterMembers)
            {
                var rep = Rpro3Dedup.Representative(members);
                bool resident = members.Any(m => m.Src == Rpro3Src.Stamm);
                var id = Rpro3Guid.Gerbil(root);
                if (plan.Gerbils.ContainsKey(id)) continue;

                // Bestes bekanntes Feld aus allen Cluster-Mitgliedern wählen.
                var dob = members.Select(m => m.Dob).FirstOrDefault(d => d is not null) ?? rep.Dob;
                var farbe = members.Select(m => m.Farbe).FirstOrDefault(f => !string.IsNullOrWhiteSpace(f)) ?? rep.Farbe;
                var fcode = members.Select(m => m.Fcode).FirstOrDefault(f => !string.IsNullOrWhiteSpace(f)) ?? rep.Fcode;
                var death = members.FirstOrDefault(m => m.DateOfDeath is not null);
                var origin = members.Select(m => m.Origin).FirstOrDefault(o => Rpro3Dedup.NormValue(o).Length > 0) ?? rep.Origin;
                var herkId = members.Select(m => m.OriginHerkId).FirstOrDefault(h => h is not null and not 1);

                // Manuelle Feld-Korrektur der Züchterin (Rpro3Decisions): adressiert über IRGENDEINE
                // rid des Clusters. Überschreibt das automatisch gewählte Feld; "Note" wird angehängt.
                Rpro3FieldOverride? ov = null;
                foreach (var m in members)
                    if (FieldIndex.TryGetValue(m.Rid, out ov)) break;
                bool? residentOverride = ov?.Resident;
                if (ov is not null)
                {
                    if (!string.IsNullOrWhiteSpace(ov.Color)) farbe = ov.Color;
                    if (!string.IsNullOrWhiteSpace(ov.Origin)) origin = ov.Origin;
                    if (!string.IsNullOrWhiteSpace(ov.Genotype)) fcode = ov.Genotype;
                    if (!string.IsNullOrWhiteSpace(ov.Dob) && DateOnly.TryParse(ov.Dob, out var od)) dob = od;
                }

                var gp = new GerbilPlan
                {
                    Id = id,
                    ExternalRef = Rpro3Guid.GerbilRef(root),
                    Name = string.IsNullOrWhiteSpace(rep.Name) ? "Unbenannt" : rep.Name!,
                    Gender = members.Select(m => m.Gender).FirstOrDefault(g => g != Gender.unknown),
                    Dob = dob,
                    DateOfDeath = death?.DateOfDeath,
                    CauseOfDeath = death?.CauseOfDeath,
                    Genotype = CleanGenotype(fcode),
                    OriginBreeder = herkId == 1 ? "eigene Zucht" : (Rpro3Dedup.NormValue(origin).Length > 0 ? origin : null),
                    OriginContactId = HerkContactId(herkId),
                    IsResident = residentOverride ?? resident,
                    IsCastrated = members.Any(m => m.IsCastrated),
                    MotherRid = rep.MidRaw,
                    FatherRid = rep.PidRaw,
                };
                if (!string.IsNullOrWhiteSpace(farbe) && colorByName.TryGetValue(Rpro3Dedup.NormName(farbe), out var cvId))
                    gp.ColorVarietyId = cvId;

                // Welpen-Zusatzdaten (Abgabe/Preis) für residente Tiere, die ein wurftier sind.
                foreach (var m in members.Where(m => m.Src == Rpro3Src.Stamm))
                    if (pupBySid.TryGetValue(m.Rid, out var pup))
                    {
                        gp.GoHomeDate = pup.AbgabeDate;
                        gp.ReceiverContactId = AbnContactId(pup.AbnId);
                        if (pup.DateOfDeath is not null && gp.DateOfDeath is null)
                        {
                            gp.DateOfDeath = pup.DateOfDeath; gp.CauseOfDeath = pup.CauseOfDeath;
                        }
                    }

                gp.Notes = BuildNotes(members);
                if (!string.IsNullOrWhiteSpace(ov?.Note))
                    gp.Notes = string.IsNullOrWhiteSpace(gp.Notes) ? ov!.Note : $"{gp.Notes}\n{ov!.Note}";
                gp.Provenance = BuildProvenance(data, members, dedup);
                plan.Gerbils[id] = gp;
            }

            // 5) Würfe aus wurf_tb (autoritativ). Eltern → Cluster-GUID.
            foreach (var (wid, w) in data.Litters)
            {
                var id = Rpro3Guid.Litter(wid);
                var lp = new LitterPlan
                {
                    Id = id,
                    ExternalRef = Rpro3Guid.LitterRef(wid),
                    Name = string.IsNullOrWhiteSpace(w.Name) ? $"Wurf {wid}" : w.Name!,
                    Date = w.Date,
                    Notes = w.Notes,
                    MotherId = ResolveParentGuid(w.MotherRid, plan),
                    FatherId = ResolveParentGuid(w.FatherRid, plan),
                    Provenance = JsonProvenance("RennmausPro III", new() { ["wurfId"] = wid, ["quelle"] = "wurf_tb" }),
                };
                plan.Litters[id] = lp;
            }

            // 6) Welpen → Geburtswurf verknüpfen (pup._SID → resultierendes Gerbil).
            foreach (var p in data.Pups.Values)
            {
                if (string.IsNullOrWhiteSpace(p.Sid) || p.Sid == "0") continue;
                var gid = GerbilIdOfRid(p.Sid);
                if (!plan.Gerbils.TryGetValue(gid, out var gp)) continue;
                var litterId = Rpro3Guid.Litter(p.WurfId);
                if (plan.Litters.ContainsKey(litterId)) gp.LitterId = litterId;
            }

            // 7) Synthetische „Pedigree-Würfe" für Tiere mit baum_tb-Eltern, aber ohne wurf_tb-Geburtswurf.
            //    So traversiert der Stammbaum (Litter → Father/Mother) auch externe Ahnen-Linien.
            foreach (var gp in plan.Gerbils.Values)
            {
                if (gp.LitterId is not null) continue;
                var motherId = ResolveParentGuid(gp.MotherRid, plan);
                var fatherId = ResolveParentGuid(gp.FatherRid, plan);
                if (motherId is null && fatherId is null) continue;
                var key = $"ped:{motherId}:{fatherId}";
                var litterId = Rpro3Guid.For("litter", key);
                if (!plan.Litters.TryGetValue(litterId, out var lp))
                {
                    lp = new LitterPlan
                    {
                        Id = litterId,
                        ExternalRef = $"rpro3-ped:{motherId}:{fatherId}",
                        Name = "Abstammung (RennmausPro)",
                        MotherId = motherId,
                        FatherId = fatherId,
                        Provenance = JsonProvenance("RennmausPro III", new() { ["quelle"] = "baum_tb", ["synthetisch"] = true }),
                    };
                    plan.Litters[litterId] = lp;
                }
                gp.LitterId = litterId;
            }

            // 8) Gesundheit/Gewicht/Tagebuch → stamm-Tiere (tid = stamm-id).
            BuildHealthAndWeights(data, plan, GerbilIdOfRid);

            // 9) Fotos (photo_tb id = stamm-id).
            foreach (var (tid, set) in data.Photos)
            {
                var gid = GerbilIdOfRid(tid);
                if (plan.Gerbils.ContainsKey(gid)) plan.PhotoFiles[gid] = set.FileNames;
            }

            // 10) Feature-Tabellen (Becken/Erwerb/Reservierung/Rücknahme/Warteliste/Ausstellung).
            BuildFeatureTables(data, plan, dedup, pupBySid);

            return plan;
        }

        /// <summary>
        /// Baut die 6 Feature-Entities aus den RPRO3-Feature-Tabellen. Alle Referenzen werden über
        /// die bereits aufgebauten Gerbil-/Contact-GUIDs aufgelöst; tids können stamm ("N") oder
        /// wurftier ("XjY") sein — Welpen-tids werden über _SID auf das resultierende Tier abgebildet.
        /// </summary>
        private void BuildFeatureTables(
            Rpro3Data data, ImportPlan plan, Rpro3Dedup.DedupResult dedup, Dictionary<string, Rpro3Pup> pupBySid)
        {
            string RootOf(string rid) => dedup.RidToRoot.TryGetValue(rid, out var r) ? r : rid;

            // tid ("N" oder "XjY") → (GerbilId?, erfasster Name?). Welpen ohne kept-_SID liefern null-Id.
            (Guid? id, string? name) ResolveGerbil(string? tid)
            {
                if (string.IsNullOrWhiteSpace(tid)) return (null, null);
                string rid = tid;
                if (tid.Contains('j'))
                {
                    // Welpe: über _SID auf das behaltene stamm-Tier abbilden, falls vorhanden.
                    if (data.Pups.TryGetValue(tid, out var pup) && !string.IsNullOrWhiteSpace(pup.Sid) && pup.Sid != "0")
                        rid = pup.Sid!;
                    else
                    {
                        var pupName = data.Pups.TryGetValue(tid, out var p) ? p.Name : null;
                        return (null, pupName);
                    }
                }
                var gid = Rpro3Guid.Gerbil(RootOf(rid));
                return plan.Gerbils.TryGetValue(gid, out var gp) ? (gid, gp.Name) : (null, data.ResolveName(rid));
            }

            // abn-id → (ContactId?, erfasster Name?). Legt den Kontakt bei Bedarf an (als Abnehmer).
            (Guid? id, string? name) ResolveAbn(int? abnId)
            {
                if (abnId is null) return (null, null);
                if (!data.AbnContacts.TryGetValue(abnId.Value.ToString(), out var c)) return (null, null);
                var id = EnsureContact(plan, c, isBreeder: false, isReceiver: true);
                return (id, c.DisplayName);
            }

            var now = DateTimeOffset.UtcNow;
            static DateTime? AsDt(DateOnly? d) => d?.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);

            // ── Becken → Enclosure (+ stamm._BECKEN → Gerbil-Zuordnung) ──
            var beckenById = new Dictionary<int, Guid>();
            foreach (var b in data.Becken)
            {
                var id = Rpro3Guid.Enclosure(b.Id);
                beckenById[b.Id] = id;
                plan.Enclosures[id] = new Enclosure
                {
                    Id = id,
                    Name = string.IsNullOrWhiteSpace(b.Name) ? $"Becken {b.Id}" : b.Name!,
                    Size = string.IsNullOrWhiteSpace(b.Size) ? null : b.Size,
                    Capacity = b.Capacity is > 0 ? b.Capacity : null,
                    LastCleanedDate = b.LastCleaned,
                    CleaningCycleDays = b.CycleDays is > 0 ? b.CycleDays : null,
                };
            }
            foreach (var (rid, beckenId) in data.StammBecken)
            {
                if (!beckenById.TryGetValue(beckenId, out var encId)) continue;
                var gid = Rpro3Guid.Gerbil(RootOf(rid));
                if (plan.Gerbils.ContainsKey(gid)) plan.GerbilEnclosure[gid] = encId;
            }

            // ── herktier_tb → AcquisitionRecord ──
            foreach (var a in data.Acquisitions)
            {
                var (gid, _) = ResolveGerbil(a.Tid);
                if (gid is null) continue; // kein Tier → kein Erwerb
                // Herkunfts-Kontakt steht bereits auf Gerbil.OriginContactId; hier Datum/Preis/Notiz.
                Guid? sourceContactId = plan.Gerbils.TryGetValue(gid.Value, out var gp) ? gp.OriginContactId : null;
                plan.Acquisitions.Add(new AcquisitionRecord
                {
                    Id = Rpro3Guid.Acquisition(a.Id),
                    GerbilId = gid,
                    SourceContactId = sourceContactId,
                    Date = a.Date,
                    Price = a.Price is > 0 ? a.Price : null,
                    Note = NullIfEmpty(a.Note),
                    CreatedAt = now,
                });
            }

            // ── abgeben_tb + abstat_tb → SaleReservation (eine Zeile je Tier) ──
            // abgeben = Vormerkung/Reservierung (oft Welpen-tid), abstat = Abgabe-Abschluss (stamm-tid).
            // Beide werden über das Tier zusammengeführt; ist beides vorhanden, gewinnt „abgegeben".
            var reservationByGerbil = new Dictionary<Guid, SaleReservation>();
            SaleReservation Res(Guid gid, string? name, string tidKey)
            {
                if (reservationByGerbil.TryGetValue(gid, out var ex)) return ex;
                var res = new SaleReservation
                {
                    Id = Rpro3Guid.Reservation(tidKey),
                    GerbilId = gid,
                    GerbilName = name,
                    Status = "verfuegbar",
                    CreatedAt = now,
                    UpdatedAt = now,
                };
                reservationByGerbil[gid] = res;
                plan.Reservations.Add(res);
                return res;
            }
            foreach (var ab in data.Abgeben)
            {
                var (gid, name) = ResolveGerbil(ab.Tid);
                if (gid is null) continue;
                var res = Res(gid.Value, name, ab.Tid);
                var (cid, cname) = ResolveAbn(ab.AbnId);
                if (res.Status != "abgegeben") res.Status = ab.Reserved ? "reserviert" : res.Status;
                res.ReservedForContactId ??= cid;
                res.ContactName ??= cname;
                res.AppointmentDate ??= AsDt(ab.Appointment);
                res.Note ??= NullIfEmpty(ab.Note);
            }
            foreach (var st in data.Abstat)
            {
                var (gid, name) = ResolveGerbil(st.Tid);
                if (gid is null) continue;
                var res = Res(gid.Value, name, st.Tid);
                var (cid, cname) = ResolveAbn(st.AbnId);
                res.Status = "abgegeben";
                res.ReservedForContactId ??= cid;
                res.ContactName ??= cname;
                res.HandedOverDate ??= AsDt(st.HandedOver);
                if (st.Price is > 0) res.Price ??= st.Price;
                res.Note ??= NullIfEmpty(st.Note);
                res.UpdatedAt = now;
            }

            // ── getback_tb → ReturnRecord ──
            foreach (var gb in data.Getback)
            {
                var (gid, name) = ResolveGerbil(gb.Tid);
                var (cid, cname) = ResolveAbn(gb.AbnId);
                plan.Returns.Add(new ReturnRecord
                {
                    Id = Rpro3Guid.Return(gb.Id),
                    GerbilId = gid,
                    GerbilName = name,
                    ReturnDate = AsDt(gb.ReturnDate),
                    ReturnPrice = gb.ReturnPrice is > 0 ? gb.ReturnPrice : null,
                    OriginalPrice = gb.OriginalPrice is > 0 ? gb.OriginalPrice : null,
                    OriginalSaleDate = AsDt(gb.OriginalSaleDate),
                    FromContactId = cid,
                    FromContactName = cname,
                    Note = NullIfEmpty(gb.Note),
                    CreatedAt = now,
                });
            }

            // ── nachfrage_tb → WaitingListEntry ──
            foreach (var nf in data.Nachfrage)
            {
                var (cid, cname) = ResolveAbn(nf.AbnId);
                plan.WaitingList.Add(new WaitingListEntry
                {
                    Id = Rpro3Guid.WaitingList(nf.Id),
                    ContactId = cid,
                    ContactName = cname,
                    WishColor = NullIfEmpty(nf.WishColor),
                    WishGender = nf.WishGender switch
                    {
                        Gender.male => "male",
                        Gender.female => "female",
                        _ => null,
                    },
                    RequestedAt = AsDt(nf.RequestedAt),
                    Status = MapWaitlistStatus(nf.Status),
                    Note = NullIfEmpty(nf.Note),
                    CreatedAt = now,
                });
            }

            // ── ausz_tb → ExhibitionResult (in vielen Backups leer; trotzdem voll verdrahtet) ──
            foreach (var ex in data.Ausstellungen)
            {
                var (gid, name) = ResolveGerbil(ex.Tid);
                var noteParts = new[] { ex.Place, ex.Juror is { Length: > 0 } ? $"Juror: {ex.Juror}" : null, ex.Note }
                    .Where(s => !string.IsNullOrWhiteSpace(s));
                var note = string.Join(" · ", noteParts);
                plan.Exhibitions.Add(new ExhibitionResult
                {
                    Id = Rpro3Guid.Exhibition(ex.Id),
                    GerbilId = gid,
                    EntityName = name,
                    EventName = string.IsNullOrWhiteSpace(ex.EventName) ? "Ausstellung" : ex.EventName!,
                    Date = AsDt(ex.Date),
                    Placement = NullIfEmpty(ex.Placement),
                    Award = NullIfEmpty(ex.Award),
                    Note = string.IsNullOrWhiteSpace(note) ? null : note,
                    CreatedAt = now,
                });
            }
        }

        private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();

        /// <summary>RPRO3 nachfrage._STATUS → unser Status (offen | erfuellt | storniert).</summary>
        private static string MapWaitlistStatus(string? raw)
        {
            var s = (raw ?? "").Trim().ToLowerInvariant();
            return s switch
            {
                "" or "offen" or "0" => "offen",
                "erfuellt" or "erfüllt" or "1" => "erfuellt",
                "storniert" or "2" => "storniert",
                _ => "offen",
            };
        }

        private Guid? ResolveParentGuid(string? rid, ImportPlan plan)
        {
            if (string.IsNullOrWhiteSpace(rid) || rid is "n" or "NULL" or "0") return null;
            var gid = Rpro3Guid.Gerbil(_lastDedup!.RidToRoot.TryGetValue(rid, out var r) ? r : rid);
            return plan.Gerbils.ContainsKey(gid) ? gid : null;
        }

        // Kontext-Hack: ResolveParentGuid braucht die RidToRoot-Map des aktuellen Plans.
        private Rpro3Dedup.DedupResult? _lastDedup;

        private void BuildHealthAndWeights(Rpro3Data data, ImportPlan plan, Func<string, Guid> gerbilIdOfRid)
        {
            int seq = 0;
            foreach (var h in data.Health)
            {
                var gid = gerbilIdOfRid(h.Tid);
                if (!plan.Gerbils.ContainsKey(gid) || h.Date is null) continue;
                var desc = string.IsNullOrWhiteSpace(h.Description) ? "Krankheitseintrag" : h.Description!;
                if (!string.IsNullOrWhiteSpace(h.Medication)) desc += $" (Medikation: {h.Medication})";
                plan.Health.Add(new HealthRecord
                {
                    Id = Rpro3Guid.Health(h.Tid, desc, seq++),
                    GerbilId = gid, Date = h.Date.Value, Type = HealthRecordType.Treatment,
                    Description = desc, CreatedAt = DateTimeOffset.UtcNow,
                });
            }
            // Tagebuch als "Other"-Gesundheitseinträge (kein eigenes Tagebuch-Modell vorhanden).
            foreach (var d in data.Diary)
            {
                var gid = gerbilIdOfRid(d.Tid);
                if (!plan.Gerbils.ContainsKey(gid) || d.Date is null || string.IsNullOrWhiteSpace(d.Description)) continue;
                var desc = (string.IsNullOrWhiteSpace(d.Title) ? "" : d.Title + ": ") + d.Description;
                plan.Health.Add(new HealthRecord
                {
                    Id = Rpro3Guid.Health(d.Tid, "diary:" + desc, seq++),
                    GerbilId = gid, Date = d.Date.Value, Type = HealthRecordType.Other,
                    Description = "Tagebuch: " + desc, CreatedAt = DateTimeOffset.UtcNow,
                });
            }

            int wseq = 0;
            var seen = new HashSet<Guid>();
            foreach (var w in data.Weights)
            {
                var gid = gerbilIdOfRid(w.Tid);
                if (!plan.Gerbils.ContainsKey(gid) || w.Date is null || w.Grams <= 0) continue;
                var id = Rpro3Guid.Weight(w.Tid + ":" + w.Date.Value.DayNumber, wseq++);
                if (!seen.Add(id)) continue;
                plan.Weights.Add(new WeightRecord
                {
                    Id = id, GerbilId = gid, Date = w.Date.Value, WeightGrams = w.Grams, Notes = w.Notes,
                });
            }
        }

        private Guid EnsureContact(ImportPlan plan, Rpro3Contact c, bool isBreeder, bool isReceiver)
        {
            var id = Rpro3Guid.Contact(c.Namespace, c.Id);
            if (plan.Contacts.TryGetValue(id, out var ex))
            {
                ex.IsBreeder |= isBreeder; ex.IsReceiver |= isReceiver;
                return id;
            }
            var address = string.Join(", ",
                new[] { c.Str, $"{c.Plz} {c.Ort}".Trim() }.Where(s => !string.IsNullOrWhiteSpace(s)));
            var notes = string.Join("\n",
                new[] { c.Memo, c.Bem, c.Http }.Where(s => !string.IsNullOrWhiteSpace(s)));
            plan.Contacts[id] = new Contact
            {
                Id = id,
                Name = c.DisplayName,
                Email = string.IsNullOrWhiteSpace(c.Mail) ? null : c.Mail,
                Phone = string.IsNullOrWhiteSpace(c.Telp) ? c.Teld : c.Telp,
                Address = string.IsNullOrWhiteSpace(address) ? null : address,
                Notes = string.IsNullOrWhiteSpace(notes) ? null : notes,
                IsBreeder = isBreeder,
                IsReceiver = isReceiver,
                NameSuffix = string.IsNullOrWhiteSpace(c.Clan) ? null : c.Clan,
                Provenance = JsonProvenance("RennmausPro III",
                    new() { ["quelle"] = c.Namespace == "herk" ? "herk_tb" : "abn_tb", ["rpro3Id"] = c.Id }),
            };
            return id;
        }

        // ───────────────────────── Helpers ─────────────────────────

        /// <summary>RPRO3 nutzt Bracket-Notation (c[chm], e[f], ee[-]); unser Genotyp-Contract nutzt
        /// kompakte Notation (cchm, ef). Klammern entfernen, "-" (unbekannt) bleibt erhalten.</summary>
        internal static string? CleanGenotype(string? fcode)
        {
            if (string.IsNullOrWhiteSpace(fcode)) return null;
            var sb = new System.Text.StringBuilder(fcode.Length);
            foreach (var ch in fcode)
                if (ch != '[' && ch != ']') sb.Append(ch);
            var cleaned = sb.ToString().Trim();
            return cleaned.Length == 0 ? null : cleaned;
        }

        private static string? BuildNotes(List<Rpro3Animal> members)
        {
            var defects = members.Select(m => m.Defects).Where(d => !string.IsNullOrWhiteSpace(d)).Distinct().ToList();
            var zb = members.Select(m => m.Zb).FirstOrDefault(z => !string.IsNullOrWhiteSpace(z));
            var parts = new List<string>();
            if (!string.IsNullOrWhiteSpace(zb)) parts.Add($"Zuchtbuch-Nr.: {zb}");
            if (defects.Count > 0) parts.Add("Defekte: " + string.Join("; ", defects));
            return parts.Count > 0 ? string.Join("\n", parts) : null;
        }

        private static string BuildProvenance(Rpro3Data data, List<Rpro3Animal> members, Rpro3Dedup.DedupResult dedup)
        {
            var rep = Rpro3Dedup.Representative(members);
            var obj = new Dictionary<string, object?>
            {
                ["importSource"] = "RennmausPro III",
                ["mergedRecordCount"] = members.Count,
                ["rpro3Ids"] = members.Select(m => m.Rid).Take(8).ToList(),
                ["mother"] = data.ResolveName(rep.MidRaw),
                ["father"] = data.ResolveName(rep.PidRaw),
            };
            if (members.Count > 1)
                obj["note"] = $"{members.Count} RennmausPro-Datensätze zusammengelegt (Name + Geburtsdatum + Farbe + Herkunft).";
            return JsonSerializer.Serialize(obj);
        }

        private static string JsonProvenance(string source, Dictionary<string, object?> extra)
        {
            extra["importSource"] = source;
            return JsonSerializer.Serialize(extra);
        }

        private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max];
    }
}
