using System.Globalization;
using System.IO.Compression;
using System.Text;
using GerbilManagerWebAPI.Models;
using Microsoft.Data.Sqlite;

namespace GerbilManagerWebAPI.Import.Rpro3
{
    /// <summary>
    /// Liest eine RennmausPro-III-`.backup` (ZIP mit `_rpro3.db` + `.mxp`) bzw. eine entpackte
    /// `_rpro3.db` direkt und überführt sie in <see cref="Rpro3Data"/>.
    ///
    /// KRITISCHE PARSING-DETAILS (Port von tools/import/compare_rpro3.py):
    ///  1. DATUM: _BIRTH/_AM/_DATE/_TODAM/_ABAM sind astronomische Julianische Tageszahlen (REAL).
    ///     0/None = unbekannt. date = fromordinal(round(jdn) - 1721425).
    ///  2. ENCODING (gemischt!): TEXT teils UTF-8 (neuer), teils CP1252 (älter). Wir lesen jede
    ///     Spalte als BLOB (Bytes) und dekodieren erst strikt UTF-8, bei Fehler CP1252
    ///     (siehe smart_decode). Microsoft.Data.Sqlite würde TEXT sonst als UTF-8 erzwingen → Mojibake.
    ///  3. ID-NAMENSRÄUME: "N"=stamm_tb, "uN"=fremd_tb, "XjY"=wurftier_tb.
    /// </summary>
    public sealed class Rpro3Reader
    {
        private static bool _cp1252Registered;

        public Rpro3Reader()
        {
            // CP1252 ist auf .NET Core nicht ohne CodePagesEncodingProvider verfügbar.
            if (!_cp1252Registered)
            {
                Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
                _cp1252Registered = true;
            }
        }

        /// <summary>Findet/extrahiert die `_rpro3.db` aus einem Upload-Stream (ZIP/.backup) und
        /// parst sie. Wirft <see cref="Rpro3FormatException"/>, wenn keine DB gefunden wurde.
        /// Gibt den temporären Arbeitsordner zurück (Caller löscht ihn).</summary>
        public Rpro3Data ReadFromBackup(Stream backupStream, out string workDir)
        {
            workDir = Path.Combine(Path.GetTempPath(), "rpro3-import-" + Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(workDir);
            try
            {
                using var archive = new ZipArchive(backupStream, ZipArchiveMode.Read, leaveOpen: true);
                var dbEntry = archive.Entries.FirstOrDefault(e =>
                    e.Name.Equals("_rpro3.db", StringComparison.OrdinalIgnoreCase))
                    ?? archive.Entries.FirstOrDefault(e =>
                        e.Name.EndsWith(".db", StringComparison.OrdinalIgnoreCase));
                if (dbEntry is null)
                    throw new Rpro3FormatException(
                        "In der hochgeladenen Datei wurde keine RennmausPro-Datenbank (_rpro3.db) gefunden.");

                var dbPath = Path.Combine(workDir, "_rpro3.db");
                dbEntry.ExtractToFile(dbPath, overwrite: true);
                return ReadFromDbFile(dbPath);
            }
            catch (InvalidDataException ex)
            {
                throw new Rpro3FormatException(
                    "Die Datei ist kein gültiges RennmausPro-Backup (kein lesbares ZIP-Archiv).", ex);
            }
        }

        /// <summary>Parst eine bereits entpackte `_rpro3.db` direkt (für Tests/Schnellpfad).</summary>
        public Rpro3Data ReadFromDbFile(string dbPath)
        {
            var connectionString = new SqliteConnectionStringBuilder
            {
                DataSource = dbPath,
                Mode = SqliteOpenMode.ReadOnly,
            }.ToString();

            using var conn = new SqliteConnection(connectionString);
            conn.Open();
            return Parse(conn);
        }

        // ---------- Decoding helpers (Port von smart_decode / jdn_to_date) ----------

        /// <summary>RPRO3-DB ist gemischt kodiert. Erst UTF-8 strikt versuchen, sonst CP1252.</summary>
        internal static string? SmartDecode(byte[]? bytes)
        {
            if (bytes is null || bytes.Length == 0) return bytes is null ? null : "";
            try
            {
                return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true)
                    .GetString(bytes);
            }
            catch (DecoderFallbackException)
            {
                return Encoding.GetEncoding(1252).GetString(bytes);
            }
        }

        /// <summary>JDN (REAL) → DateOnly. 0/None/ungültig = null.</summary>
        internal static DateOnly? JdnToDate(double? jdn)
        {
            if (jdn is null || jdn.Value <= 0) return null;
            // Python: date.fromordinal(round(jdn) - 1721425). .NET-Ordinaltage zählen ab 0001-01-01
            // mit DateOnly.FromDayNumber(n) wobei DayNumber 0 = 0001-01-01 (= Python-ordinal 1).
            // → dayNumber = round(jdn) - 1721425 - 1.
            long dayNumber = (long)Math.Round(jdn.Value) - 1721425 - 1;
            if (dayNumber < 0 || dayNumber > DateOnly.MaxValue.DayNumber) return null;
            return DateOnly.FromDayNumber((int)dayNumber);
        }

        private static Gender ParseGender(string? sex) => (sex ?? "").Trim().ToLowerInvariant() switch
        {
            "männlich" or "maennlich" or "m" or "male" => Gender.male,
            "weiblich" or "w" or "f" or "female" => Gender.female,
            _ => Gender.unknown,
        };

        // ---------- BLOB-aware accessors ----------

        private static string? Str(SqliteDataReader r, int ord)
        {
            if (r.IsDBNull(ord)) return null;
            // Als Bytes lesen → smart decode (UTF-8/CP1252).
            using var stream = r.GetStream(ord);
            using var ms = new MemoryStream();
            stream.CopyTo(ms);
            return SmartDecode(ms.ToArray());
        }

        private static double? Real(SqliteDataReader r, int ord)
        {
            if (r.IsDBNull(ord)) return null;
            try { return r.GetDouble(ord); }
            catch { return double.TryParse(Str(r, ord), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null; }
        }

        private static int? IntOrNull(SqliteDataReader r, int ord)
        {
            if (r.IsDBNull(ord)) return null;
            try { return (int)r.GetInt64(ord); }
            catch
            {
                var s = Str(r, ord);
                return int.TryParse(s, out var v) ? v : null;
            }
        }

        private static decimal? DecimalOrNull(SqliteDataReader r, int ord)
        {
            if (r.IsDBNull(ord)) return null;
            try { return (decimal)r.GetDouble(ord); }
            catch
            {
                var s = Str(r, ord);
                return decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : null;
            }
        }

        // Map column name → ordinal once per query (RPRO3 column order is stable but we stay safe).
        private static Func<SqliteDataReader, string, int> OrdinalLookup(SqliteDataReader r)
        {
            var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
            for (int i = 0; i < r.FieldCount; i++) map[r.GetName(i)] = i;
            return (rr, name) => map.TryGetValue(name, out var o) ? o : -1;
        }

        // ---------- Parse ----------

        private Rpro3Data Parse(SqliteConnection conn)
        {
            var data = new Rpro3Data();

            // herk_tb → Anzeigename + voller Kontakt
            var herkName = new Dictionary<int, string>();
            foreach (var (id, c) in ReadContacts(conn, "herk_tb", "herk", "_HERK_IGNORED"))
            {
                data.HerkContacts[c.Id.ToString()] = c;
                herkName[id] = c.DisplayName;
            }
            foreach (var (_, c) in ReadContacts(conn, "abn_tb", "abn", "_ABN_IGNORED"))
                data.AbnContacts[c.Id.ToString()] = c;

            // baum_tb: master parent table (id → Mutter/Vater im Namensraum)
            var baum = new Dictionary<string, (string? mid, string? pid)>();
            Query(conn, "SELECT id, _MID, _PID FROM baum_tb", (r, ord) =>
            {
                var id = Str(r, ord(r, "id"));
                if (id is not null) baum[id] = (Str(r, ord(r, "_MID")), Str(r, ord(r, "_PID")));
            });

            // Farben: color_tb (stamm), wurfcolor_tb (pup), fremdcolor_tb (extern)
            var colorStamm = ReadColors(conn, "color_tb");
            var colorPup = ReadColors(conn, "wurfcolor_tb");
            var colorExt = ReadColors(conn, "fremdcolor_tb");
            data.ColorStammCount = colorStamm.Count;
            data.ColorExtCount = colorExt.Count;

            (string farbe, string fcode) ColorFor(string rid)
            {
                Dictionary<string, (string, string)> src =
                    rid.StartsWith('u') ? colorExt : rid.Contains('j') ? colorPup : colorStamm;
                return src.TryGetValue(rid, out var v) ? v : ("", "");
            }

            // Tod (stamm/fremd nutzen tod_tb via tid = stamm-id; fremd hat eigenes _TODAM)
            var tod = new Dictionary<string, (DateOnly? am, string? warum)>();
            Query(conn, "SELECT tid, _AM, _WARUM FROM tod_tb", (r, ord) =>
            {
                var tid = Str(r, ord(r, "tid"));
                if (tid is not null) tod[tid] = (JdnToDate(Real(r, ord(r, "_AM"))), Str(r, ord(r, "_WARUM")));
            });

            // stamm_tb: Namen für Eltern-Auflösung
            var stammName = new Dictionary<int, string?>();
            var fremdName = new Dictionary<int, string?>();
            var pupName = new Dictionary<string, string?>();

            // stamm_tb (eigene Tiere)
            Query(conn,
                "SELECT id,_NAME,_SEX,_BIRTH,_HERKUNFT,_ZB,_STATUS,_FEHLER,_KASTRAT_DATE,_BECKEN FROM stamm_tb",
                (r, ord) =>
                {
                    var sid = IntOrNull(r, ord(r, "id"));
                    if (sid is null) return;
                    var rid = sid.Value.ToString();
                    var name = Str(r, ord(r, "_NAME"));
                    stammName[sid.Value] = name;
                    var beckenId = IntOrNull(r, ord(r, "_BECKEN"));
                    if (beckenId is > 0) data.StammBecken[rid] = beckenId.Value;
                    var (farbe, fcode) = ColorFor(rid);
                    baum.TryGetValue(rid, out var par);
                    var herkId = IntOrNull(r, ord(r, "_HERKUNFT"));
                    tod.TryGetValue(rid, out var death);
                    data.Animals.Add(new Rpro3Animal
                    {
                        Rid = rid,
                        Src = Rpro3Src.Stamm,
                        Name = name,
                        Gender = ParseGender(Str(r, ord(r, "_SEX"))),
                        Dob = JdnToDate(Real(r, ord(r, "_BIRTH"))),
                        Farbe = farbe,
                        Fcode = fcode,
                        Origin = herkId is not null && herkName.TryGetValue(herkId.Value, out var hn) ? hn : "",
                        OriginHerkId = herkId,
                        Zb = Str(r, ord(r, "_ZB")),
                        MidRaw = par.mid,
                        PidRaw = par.pid,
                        DateOfDeath = death.am,
                        CauseOfDeath = death.warum,
                        Defects = Str(r, ord(r, "_FEHLER")),
                        IsCastrated = JdnToDate(Real(r, ord(r, "_KASTRAT_DATE"))) is not null,
                        StatusCode = IntOrNull(r, ord(r, "_STATUS")),
                    });
                });

            // fremd_tb (externe Ahnen)
            Query(conn,
                "SELECT id,_NAME,_SEX,_BIRTH,_ZB,_HERK,_MID,_PID,_TODAM,_KASTRAT_DATE FROM fremd_tb",
                (r, ord) =>
                {
                    var fid = IntOrNull(r, ord(r, "id"));
                    if (fid is null) return;
                    var rid = "u" + fid.Value;
                    var name = Str(r, ord(r, "_NAME"));
                    fremdName[fid.Value] = name;
                    var (farbe, fcode) = ColorFor(rid);
                    var herkId = IntOrNull(r, ord(r, "_HERK"));
                    data.Animals.Add(new Rpro3Animal
                    {
                        Rid = rid,
                        Src = Rpro3Src.Fremd,
                        Name = name,
                        Gender = ParseGender(Str(r, ord(r, "_SEX"))),
                        Dob = JdnToDate(Real(r, ord(r, "_BIRTH"))),
                        Farbe = farbe,
                        Fcode = fcode,
                        Origin = herkId is not null && herkName.TryGetValue(herkId.Value, out var hn) ? hn : "",
                        OriginHerkId = herkId,
                        Zb = Str(r, ord(r, "_ZB")),
                        MidRaw = Str(r, ord(r, "_MID")),
                        PidRaw = Str(r, ord(r, "_PID")),
                        DateOfDeath = JdnToDate(Real(r, ord(r, "_TODAM"))),
                        IsCastrated = JdnToDate(Real(r, ord(r, "_KASTRAT_DATE"))) is not null,
                    });
                });

            // wurf_tb (Würfe)
            Query(conn, "SELECT id,_MID,_PID,_AM,_BEZ,_BEM FROM wurf_tb", (r, ord) =>
            {
                var wid = IntOrNull(r, ord(r, "id"));
                if (wid is null) return;
                data.Litters[wid.Value] = new Rpro3Litter
                {
                    Id = wid.Value,
                    Name = Str(r, ord(r, "_BEZ")),
                    Date = JdnToDate(Real(r, ord(r, "_AM"))),
                    MotherRid = Str(r, ord(r, "_MID")),
                    FatherRid = Str(r, ord(r, "_PID")),
                    Notes = Str(r, ord(r, "_BEM")),
                };
            });

            // wurftier_tb (Welpen)
            Query(conn,
                "SELECT id,_WID,_NAME,_SEX,_BIRTH,_SID,_TODAM,_WARUM,_ABAM,_ABN,_PRICE,_ZB,_FEHLER FROM wurftier_tb",
                (r, ord) =>
                {
                    var id = Str(r, ord(r, "id"));
                    if (id is null) return;
                    var name = Str(r, ord(r, "_NAME"));
                    pupName[id] = name;
                    data.Pups[id] = new Rpro3Pup
                    {
                        Id = id,
                        WurfId = IntOrNull(r, ord(r, "_WID")) ?? 0,
                        Name = name,
                        Gender = ParseGender(Str(r, ord(r, "_SEX"))),
                        Dob = JdnToDate(Real(r, ord(r, "_BIRTH"))),
                        Sid = Str(r, ord(r, "_SID")),
                        DateOfDeath = JdnToDate(Real(r, ord(r, "_TODAM"))),
                        CauseOfDeath = Str(r, ord(r, "_WARUM")),
                        AbgabeDate = JdnToDate(Real(r, ord(r, "_ABAM"))),
                        AbnId = IntOrNull(r, ord(r, "_ABN")),
                        Price = DecimalOrNull(r, ord(r, "_PRICE")),
                        Zb = Str(r, ord(r, "_ZB")),
                        Defects = Str(r, ord(r, "_FEHLER")),
                    };
                });

            // Eltern-Namen auflösen (für Report / Provenance)
            string? NameFor(string? rid)
            {
                if (string.IsNullOrWhiteSpace(rid) || rid is "n" or "NULL") return null;
                if (rid.StartsWith('u'))
                    return int.TryParse(rid[1..], out var fid) && fremdName.TryGetValue(fid, out var n) ? n : null;
                if (rid.Contains('j'))
                    return pupName.TryGetValue(rid, out var n) ? n : null;
                return int.TryParse(rid, out var sid) && stammName.TryGetValue(sid, out var sn) ? sn : null;
            }
            data.ResolveName = NameFor;

            // Gewichte: waage_tb (_TID = stamm-id) + jungwaage_tb (_TID = wurftier-id "XjY" oder via _WID)
            Query(conn, "SELECT _TID,_GRAMM,_DATE,_BEM FROM waage_tb", (r, ord) =>
            {
                var tid = Str(r, ord(r, "_TID"));
                if (tid is null) return;
                data.Weights.Add(new Rpro3Weight
                {
                    Tid = tid,
                    Grams = (int)Math.Round(Real(r, ord(r, "_GRAMM")) ?? 0),
                    Date = JdnToDate(Real(r, ord(r, "_DATE"))),
                    Notes = Str(r, ord(r, "_BEM")),
                });
            });
            Query(conn, "SELECT _TID,_WID,_GRAMM,_DATE,_BEM FROM jungwaage_tb", (r, ord) =>
            {
                // _TID ist hier i. d. R. die laufende Pup-Nr im Wurf; _WID der Wurf → "WIDjTID".
                var tidRaw = Str(r, ord(r, "_TID"));
                var widRaw = IntOrNull(r, ord(r, "_WID"));
                string? tid = tidRaw is not null && tidRaw.Contains('j') ? tidRaw
                    : widRaw is not null && tidRaw is not null ? $"{widRaw}j{tidRaw}" : null;
                if (tid is null) return;
                data.Weights.Add(new Rpro3Weight
                {
                    Tid = tid,
                    Grams = (int)Math.Round(Real(r, ord(r, "_GRAMM")) ?? 0),
                    Date = JdnToDate(Real(r, ord(r, "_DATE"))),
                    Notes = Str(r, ord(r, "_BEM")),
                });
            });

            // Krankheiten: krank_tb (_TID = stamm-id)
            Query(conn, "SELECT _TID,_DATE,_BEM,_MED FROM krank_tb", (r, ord) =>
            {
                var tid = Str(r, ord(r, "_TID"));
                if (tid is null) return;
                data.Health.Add(new Rpro3Health
                {
                    Tid = tid,
                    Date = JdnToDate(Real(r, ord(r, "_DATE"))),
                    Description = Str(r, ord(r, "_BEM")),
                    Medication = Str(r, ord(r, "_MED")),
                });
            });

            // Tagebuch: diary_tb (_TID = stamm-id)
            Query(conn, "SELECT _TID,_BEZ,_DATE,_DESC FROM diary_tb", (r, ord) =>
            {
                var tid = Str(r, ord(r, "_TID"));
                if (tid is null) return;
                data.Diary.Add(new Rpro3Diary
                {
                    Tid = tid,
                    Title = Str(r, ord(r, "_BEZ")),
                    Date = JdnToDate(Real(r, ord(r, "_DATE"))),
                    Description = Str(r, ord(r, "_DESC")),
                });
            });

            // Fotos: photo_tb (id = stamm-id, _P1/_P2/_P3 = Bilddateinamen)
            Query(conn, "SELECT id,_P1,_P2,_P3 FROM photo_tb", (r, ord) =>
            {
                var pid = IntOrNull(r, ord(r, "id"));
                if (pid is null) return;
                var set = new Rpro3PhotoSet { Tid = pid.Value.ToString() };
                foreach (var col in new[] { "_P1", "_P2", "_P3" })
                {
                    var fn = Str(r, ord(r, col));
                    if (!string.IsNullOrWhiteSpace(fn) && fn != "---")
                        set.FileNames.Add(Path.GetFileName(fn.Replace('\\', '/')));
                }
                if (set.FileNames.Count > 0) data.Photos[set.Tid] = set;
            });

            ParseFeatureTables(conn, data);

            return data;
        }

        /// <summary>Parst die Feature-Tabellen (becken/herktier/abgeben/abstat/getback/nachfrage/ausz).
        /// Jede Tabelle ist optional — fehlt sie in einem älteren Backup, wird sie still übersprungen.</summary>
        private void ParseFeatureTables(SqliteConnection conn, Rpro3Data data)
        {
            // becken_tb: Gehege.
            QueryOptional(conn, "SELECT id,_BEZ,_SIZE,_MENGE,_CLEANED,_CYCLUS FROM becken_tb", (r, ord) =>
            {
                var id = IntOrNull(r, ord(r, "id"));
                if (id is null) return;
                data.Becken.Add(new Rpro3Becken
                {
                    Id = id.Value,
                    Name = Str(r, ord(r, "_BEZ")),
                    Size = Str(r, ord(r, "_SIZE")),
                    Capacity = IntOrNull(r, ord(r, "_MENGE")),
                    LastCleaned = JdnToDate(Real(r, ord(r, "_CLEANED"))),
                    CycleDays = IntOrNull(r, ord(r, "_CYCLUS")),
                });
            });

            // herktier_tb: Erwerb.
            QueryOptional(conn, "SELECT id,_TID,_DATE,_PRICE,_BEM FROM herktier_tb", (r, ord) =>
            {
                var id = IntOrNull(r, ord(r, "id"));
                if (id is null) return;
                data.Acquisitions.Add(new Rpro3Acquisition
                {
                    Id = id.Value,
                    Tid = Str(r, ord(r, "_TID")),
                    Date = JdnToDate(Real(r, ord(r, "_DATE"))),
                    Price = DecimalOrNull(r, ord(r, "_PRICE")),
                    Note = Str(r, ord(r, "_BEM")),
                });
            });

            // abgeben_tb: Reservierung/Vormerkung.
            QueryOptional(conn, "SELECT tid,_RES,_TERMIN,_ABN,_BEM FROM abgeben_tb", (r, ord) =>
            {
                var tid = Str(r, ord(r, "tid"));
                if (string.IsNullOrWhiteSpace(tid)) return;
                data.Abgeben.Add(new Rpro3Abgeben
                {
                    Tid = tid,
                    Reserved = (IntOrNull(r, ord(r, "_RES")) ?? 0) != 0,
                    Appointment = JdnToDate(Real(r, ord(r, "_TERMIN"))),
                    AbnId = IntOrNull(r, ord(r, "_ABN")),
                    Note = Str(r, ord(r, "_BEM")),
                });
            });

            // abstat_tb: Abgabe-Abschluss.
            QueryOptional(conn, "SELECT tid,_IDABN,_AM,_PRICE,_BEM FROM abstat_tb", (r, ord) =>
            {
                var tid = Str(r, ord(r, "tid"));
                if (string.IsNullOrWhiteSpace(tid)) return;
                data.Abstat.Add(new Rpro3Abstat
                {
                    Tid = tid,
                    AbnId = IntOrNull(r, ord(r, "_IDABN")),
                    HandedOver = JdnToDate(Real(r, ord(r, "_AM"))),
                    Price = DecimalOrNull(r, ord(r, "_PRICE")),
                    Note = Str(r, ord(r, "_BEM")),
                });
            });

            // getback_tb: Rücknahme.
            QueryOptional(conn, "SELECT id,_TID,_ZAM,_ZPREIS,_AM,_PREIS,_ABN,_BEM FROM getback_tb", (r, ord) =>
            {
                var id = IntOrNull(r, ord(r, "id"));
                if (id is null) return;
                data.Getback.Add(new Rpro3Getback
                {
                    Id = id.Value,
                    Tid = Str(r, ord(r, "_TID")),
                    ReturnDate = JdnToDate(Real(r, ord(r, "_ZAM"))),
                    ReturnPrice = DecimalOrNull(r, ord(r, "_ZPREIS")),
                    OriginalSaleDate = JdnToDate(Real(r, ord(r, "_AM"))),
                    OriginalPrice = DecimalOrNull(r, ord(r, "_PREIS")),
                    AbnId = IntOrNull(r, ord(r, "_ABN")),
                    Note = Str(r, ord(r, "_BEM")),
                });
            });

            // nachfrage_tb: Warteliste. _VOM ist das Anfragedatum (_DATE ist hier i. d. R. 0).
            // Hat keine eigene _BEM-Spalte; _CODE (z. B. "egal") dient als Zusatznotiz.
            QueryOptional(conn, "SELECT id,_COLOR,_SEX,_ABN,_VOM,_DATE,_STATUS,_CODE FROM nachfrage_tb", (r, ord) =>
            {
                var id = IntOrNull(r, ord(r, "id"));
                if (id is null) return;
                var code = Str(r, ord(r, "_CODE"));
                data.Nachfrage.Add(new Rpro3Nachfrage
                {
                    Id = id.Value,
                    WishColor = Str(r, ord(r, "_COLOR")),
                    WishGender = ParseGender(Str(r, ord(r, "_SEX"))),
                    AbnId = IntOrNull(r, ord(r, "_ABN")),
                    RequestedAt = JdnToDate(Real(r, ord(r, "_VOM"))) ?? JdnToDate(Real(r, ord(r, "_DATE"))),
                    Status = Str(r, ord(r, "_STATUS")),
                    Note = string.IsNullOrWhiteSpace(code) || code == "egal" ? null : code,
                });
            });

            // ausz_tb: Ausstellungsergebnisse (in vielen Backups leer — robust gegen 0 Zeilen).
            QueryOptional(conn, "SELECT id,_TID,_VERANSTALTUNG,_DATE,_ORT,_PLATZ,_AUSZ,_JUROR,_BEM FROM ausz_tb", (r, ord) =>
            {
                var id = IntOrNull(r, ord(r, "id"));
                if (id is null) return;
                data.Ausstellungen.Add(new Rpro3Ausstellung
                {
                    Id = id.Value,
                    Tid = Str(r, ord(r, "_TID")),
                    EventName = Str(r, ord(r, "_VERANSTALTUNG")),
                    Date = JdnToDate(Real(r, ord(r, "_DATE"))),
                    Place = Str(r, ord(r, "_ORT")),
                    Placement = Str(r, ord(r, "_PLATZ")),
                    Award = Str(r, ord(r, "_AUSZ")),
                    Juror = Str(r, ord(r, "_JUROR")),
                    Note = Str(r, ord(r, "_BEM")),
                });
            });
        }

        private static Dictionary<string, (string farbe, string fcode)> ReadColors(SqliteConnection conn, string table)
        {
            var dict = new Dictionary<string, (string, string)>();
            Query(conn, $"SELECT id,_FARBE,_FCODE FROM {table}", (r, ord) =>
            {
                var id = Str(r, ord(r, "id"));
                if (id is not null)
                    dict[id] = (Str(r, ord(r, "_FARBE")) ?? "", Str(r, ord(r, "_FCODE")) ?? "");
            });
            return dict;
        }

        private static IEnumerable<(int id, Rpro3Contact c)> ReadContacts(
            SqliteConnection conn, string table, string ns, string _)
        {
            var list = new List<(int, Rpro3Contact)>();
            Query(conn,
                $"SELECT id,_BEZ,_ANREDE,_VNAME,_NNAME,_CLAN,_STR,_ORT,_PLZ,_TELP,_TELD,_MAIL,_HTTP,_MEMO,_BEM FROM {table}",
                (r, ord) =>
                {
                    var id = IntOrNull(r, ord(r, "id"));
                    if (id is null) return;
                    list.Add((id.Value, new Rpro3Contact
                    {
                        Id = id.Value,
                        Namespace = ns,
                        Bez = Str(r, ord(r, "_BEZ")),
                        Anrede = Str(r, ord(r, "_ANREDE")),
                        Vname = Str(r, ord(r, "_VNAME")),
                        Nname = Str(r, ord(r, "_NNAME")),
                        Clan = Str(r, ord(r, "_CLAN")),
                        Str = Str(r, ord(r, "_STR")),
                        Ort = Str(r, ord(r, "_ORT")),
                        Plz = Str(r, ord(r, "_PLZ")),
                        Telp = Str(r, ord(r, "_TELP")),
                        Teld = Str(r, ord(r, "_TELD")),
                        Mail = Str(r, ord(r, "_MAIL")),
                        Http = Str(r, ord(r, "_HTTP")),
                        Memo = Str(r, ord(r, "_MEMO")),
                        Bem = Str(r, ord(r, "_BEM")),
                    }));
                });
            return list;
        }

        private static void Query(
            SqliteConnection conn, string sql,
            Action<SqliteDataReader, Func<SqliteDataReader, string, int>> onRow)
        {
            using var cmd = conn.CreateCommand();
            cmd.CommandText = sql;
            using var r = cmd.ExecuteReader();
            Func<SqliteDataReader, string, int>? ord = null;
            while (r.Read())
            {
                ord ??= OrdinalLookup(r);
                onRow(r, ord);
            }
        }

        /// <summary>Wie <see cref="Query"/>, ignoriert aber fehlende Tabellen/Spalten
        /// (ältere Backups ohne die Feature-Tabellen). SQLite-Fehler werden geschluckt.</summary>
        private static void QueryOptional(
            SqliteConnection conn, string sql,
            Action<SqliteDataReader, Func<SqliteDataReader, string, int>> onRow)
        {
            try { Query(conn, sql, onRow); }
            catch (SqliteException) { /* Tabelle/Spalte fehlt in diesem Backup → überspringen */ }
        }
    }

    public sealed class Rpro3FormatException : Exception
    {
        public Rpro3FormatException(string message, Exception? inner = null) : base(message, inner) { }
    }
}
