using System.Globalization;
using System.IO.Compression;
using System.Text;
using GerbilManagerWebAPI.Models;
using Microsoft.Data.Sqlite;
namespace GerbilManagerWebAPI.Import.Rpro3
{
///
/// Liest eine RennmausPro-III-`.backup` (ZIP mit `_rpro3.db` + `.mxp`) bzw. eine entpackte
/// `_rpro3.db` direkt und überführt sie in .
///
/// 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.
///
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;
}
}
/// Findet/extrahiert die `_rpro3.db` aus einem Upload-Stream (ZIP/.backup) und
/// parst sie. Wirft , wenn keine DB gefunden wurde.
/// Gibt den temporären Arbeitsordner zurück (Caller löscht ihn).
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);
}
}
/// Parst eine bereits entpackte `_rpro3.db` direkt (für Tests/Schnellpfad).
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) ----------
/// RPRO3-DB ist gemischt kodiert. Erst UTF-8 strikt versuchen, sonst CP1252.
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);
}
}
/// JDN (REAL) → DateOnly. 0/None/ungültig = null.
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 OrdinalLookup(SqliteDataReader r)
{
var map = new Dictionary(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();
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();
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 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();
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();
var fremdName = new Dictionary();
var pupName = new Dictionary();
// stamm_tb (eigene Tiere)
Query(conn,
"SELECT id,_NAME,_SEX,_BIRTH,_HERKUNFT,_ZB,_STATUS,_FEHLER,_KASTRAT_DATE 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 (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;
});
return data;
}
private static Dictionary ReadColors(SqliteConnection conn, string table)
{
var dict = new Dictionary();
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> onRow)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
using var r = cmd.ExecuteReader();
Func? ord = null;
while (r.Read())
{
ord ??= OrdinalLookup(r);
onRow(r, ord);
}
}
}
public sealed class Rpro3FormatException : Exception
{
public Rpro3FormatException(string message, Exception? inner = null) : base(message, inner) { }
}
}