#!/usr/bin/env python3
"""FEAT-8b stages 1-2 — extract + dedup the GerbilManager source spreadsheets.

Stage 1: parse the 10 Stammbaum pedigree charts and the Wurfchronik litter
         chronicle into normalised animals.json + litters.json, and extract
         embedded photos (anchor-mapped to animals).
Stage 2: dedup animals (key = normalise(name)+DOB, corroborated by DOB+genotype)
         and emit a German-friendly review-report.md for Julian's wife. No DB load.

Re-runnable per file (later Wurfchronik "Teil2+" / more charts just re-run).
Migration tooling — Python, not product code. Zero third-party deps.

See FEAT-8a-format-spec.md (in Pam's hive workspace) for the format analysis.
"""
import os
import re
import sys
import json
import glob
import shutil
import argparse
import unicodedata
from collections import Counter

import xlsx_util as xu
import genotype as gt

HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_STAMMBAEUME = r"C:\Users\gulum\dev\Sttammbäume"
DEFAULT_WURFCHRONIK = r"C:\Users\gulum\dev\Wurfchronik der Kleine Chaoten Teil1.xlsx"
OUT = os.path.join(HERE, "output")

DOB = re.compile(r"\*\s?(\d{1,2}\.\d{1,2}\.(?:\d{4}|\d{2}))")
DEATH = re.compile(r"\+\s?(\d{1,2}\.\d{1,2}\.(?:\d{4}|\d{2})|\d{4})")


# ---------------------------------------------------------------- helpers ----
def gen_of(colnum):
    """Map a column number to a generation band (0=proband ... 5=deepest)."""
    if colnum <= 6:
        return 0          # E band (proband / "Kids")
    if colnum <= 9:
        return 1          # H band (parents)
    if colnum <= 12:
        return 2          # K band (grandparents)
    if colnum <= 15:
        return 3          # N band (great-grandparents)
    if colnum <= 17:
        return 4          # Q band (gg-grandparents)
    return 5              # R/S band (name-pairs)


def norm_name(name):
    if not name:
        return ""
    n = name.lower()
    n = re.sub(r"\[.*?\]", " ", n)               # drop [line] tags (Wurfchronik)
    n = re.sub(r"\bgen\.\b", " ", n)             # "gen." nickname marker
    n = re.sub(r"\bv\.\s?d\.\b", " von den ", n)
    n = re.sub(r"\b(von der|von den|von|of)\b", " ", n)  # cattery/line connectors
    n = unicodedata.normalize("NFKD", n)
    n = re.sub(r"[^a-z0-9äöüß]", "", n)
    return n


def norm_dob(d):
    if not d:
        return ""
    p = d.split(".")
    if len(p) == 3 and len(p[2]) == 2:
        p[2] = "20" + p[2]
    return ".".join(x.zfill(2) if i < 2 else x for i, x in enumerate(p))


def slug(name, dob):
    base = norm_name(name) or "unbekannt"
    d = norm_dob(dob).replace(".", "")
    return (base[:40] + ("-" + d if d else "")) or "unbekannt"


def clean_name(raw):
    """Strip detail/markers from a name cell, keep the human name + [line]."""
    n = raw.strip().strip(",").strip()
    return n


# --- (name, Zucht) canonicalisation -------------------------------------------
# Julian (FEAT-8 ruling): the [brackets] in Wurfchronik names ARE the Zucht
# (breeding line) and are equivalent to the Stammbaum "of/von <line>" suffix.
# Both fold into one canonical (call-name, zucht) pair; the Zucht acts as a
# dedup DISCRIMINATOR (same name+DOB but different Zucht = different animal).
_BRACKET_ZUCHT = re.compile(r"^(.*?)\s*\[([^\]]+)\]\s*$")
_SUFFIX_ZUCHT = re.compile(
    r"^(.+?)\s+(?:of|von\s+den|von\s+der|v\.\s?d\.|von)\s+(.+)$", re.IGNORECASE)
# canonical values are post-norm_zucht (word-final 'n' folded: "kleinechaote")
ZUCHT_ALIASES = {
    "zdkc": "kleinechaote",   # "Zucht der kleinen Chaoten" (home cattery shorthand)
}

# A Farbschlag value must NOT contain cattery/line connectors (v.d./von/of/gen.) — when it
# does, a parent's NAME has bled into the Farbschlag cell (cross-cell chart read, PEDIGREE-LINK
# bug: e.g. "Victoria Welby gen. Welby v.d. Kleinen Chaoten" became a Farbschlag variant and
# spawned a phantom conflict). Reject such values so they don't pollute farbschlag/conflicts.
_NAME_MARKER = re.compile(r"\bv\.\s?d\.|\bvon\b|\bof\b|\bgen\.", re.IGNORECASE)


def looks_like_animal_name(text):
    """True if a candidate Farbschlag cell actually looks like an animal name (has a
    cattery/line connector). Real Farbschläge are short colour words without these."""
    return bool(_NAME_MARKER.search(text or ""))


def split_name_zucht(raw):
    """'Luna [ZdkC]' -> ('Luna','ZdkC'); 'Pikachu of Black Forest' ->
    ('Pikachu','Black Forest'); plain names -> (name, '')."""
    n = (raw or "").strip()
    m = _BRACKET_ZUCHT.match(n)
    if m:
        return m.group(1).strip(), m.group(2).strip()
    m = _SUFFIX_ZUCHT.match(n)
    if m:
        return m.group(1).strip(), m.group(2).strip()
    return n, ""


def norm_zucht(z):
    """Canonical Zucht key: drops Zucht/von/der fillers, folds declension
    ('kleinen Chaoten' == 'kleine Chaoten'), resolves known shorthands."""
    if not z:
        return ""
    n = z.lower()
    # TOLERANT KC-MATCHER FIX: trailing \b after '.' never fires when next char is ' '
    # (both are non-word chars), so "v.d. kleinen" was NOT stripped. Drop the trailing \b.
    n = re.sub(r"\bv\.\s?d\.", " ", n)
    n = re.sub(r"\b(zucht|privatzucht|der|die|den|des|dem|von|of)\b", " ", n)
    n = re.sub(r"[^a-z0-9äöüß ]", " ", n)
    words = [w[:-1] if len(w) > 4 and w.endswith("n") else w for w in n.split()]
    key = "".join(words)
    return ZUCHT_ALIASES.get(key, key)


def is_clan_zucht(z):
    """True if the zucht name (raw or canonical) identifies the Kleine Chaoten home cattery.

    Accepts all known spellings: 'Zucht der Kleinen Chaoten', 'kleinen Chaoten',
    'v.d. Kleinen Chaoten', '[ZdkC]', 'kleinechaote', etc.
    """
    return norm_zucht(z) == "kleinechaote"


def canon_pair(raw):
    """Full raw name -> (normalised call-name, canonical zucht)."""
    name, zucht = split_name_zucht(raw)
    return norm_name(name), norm_zucht(zucht)


# --------------------------------------------------- Stammbaum extraction ----
def parse_detail(text):
    """From a string that contains *DOB and/or genotype, pull (dob, death, geno_str).

    For compact lines ("Name,*DOB[/+death], genotype") the genotype is everything
    after the date — we must NOT scan from the first locus-looking letter, or stray
    name words ("den", "of") get swallowed as genotype tokens.
    """
    dob = DOB.search(text)
    death = DEATH.search(text)
    geno = ""
    if dob:
        tail = text[dob.end():]
        tail = re.sub(r"^\s*/?\+?\s?\d[\d.]*", "", tail)   # drop any /+death remnant
        tail = tail.lstrip(" ,").strip()
        # FIX-4 (Skarlett): strip trailing "/ +YEAR" death-year artifacts leaked from compact
        # chart cells (e.g. "… rere / +2018"). The DEATH regex still captures the year from
        # the full cell text, so it appears as a death-date conflict — not a genotype conflict.
        tail = re.sub(r"\s*/\s*\+\d{4}\s*$", "", tail).strip()
        if gt.looks_like_genotype(tail):
            geno = tail
    return (dob.group(1) if dob else "",
            death.group(1) if death else "",
            geno)


def extract_stammbaum(path):
    """Return list of animal dicts for one chart file."""
    fname = os.path.basename(path)
    z = __import__("zipfile").ZipFile(path)
    ss = xu.shared_strings(z)
    sheets = xu.sheet_paths(z)
    cells = xu.read_cells(z, sheets[0], ss)
    fillsex = xu.cell_fill_sex(z, sheets[0])     # box colour -> sex (blue=male, white=female)

    # group cells by column for block reconstruction
    by_col = {}
    for (c, r), t in cells.items():
        by_col.setdefault(c, []).append((r, t))
    for c in by_col:
        by_col[c].sort()

    animals = []
    used = set()

    for (c, r), t in sorted(cells.items()):
        if (c, r) in used:
            continue
        compact = re.match(r"^(.+?),\s*\*", t)        # "Name,*DOB, genotype"
        is_block_dob = bool(re.match(r"^\*\s?\d", t))   # standalone "*DOB"
        if not compact and not is_block_dob:
            continue

        if compact:
            name = clean_name(compact.group(1))
            dob, death, geno = parse_detail(t)
            farbschlag = ""
            breeder = ""
            used.add((c, r))
        else:
            # full block: name above, farbschlag/genotype/breeder below
            dob, death, geno0 = parse_detail(t)
            name = ""
            for rr in range(r - 1, r - 4, -1):
                if (c, rr) in cells and not re.match(r"^\*?\s?\d", cells[(c, rr)]) \
                        and not gt.looks_like_genotype(cells[(c, rr)]):
                    name = clean_name(cells[(c, rr)])
                    used.add((c, rr))
                    break
            farbschlag = ""
            geno = geno0
            breeder = ""
            # BAND-AWARE (Julian-confirmed): early bands (gen 0-1, cols B/E/H) are 5-cell blocks
            # WITH a Farbschlag cell; deep bands (gen >= 2, cols K/N/Q...) are 3-cell blocks
            # (Name/DOB/Genotype) with NO Farbschlag — colour is derived from the genotype. So in
            # deep bands we must NOT grab the next block's name or a stray health note as Farbschlag.
            deep_band = gen_of(c) >= 2
            for rr in range(r + 1, r + 4):
                cell = cells.get((c, rr))
                if not cell:
                    continue
                if gt.looks_like_genotype(cell):
                    geno = cell
                    used.add((c, rr))
                elif re.search(r"\b(Zucht|Privatzucht)\b", cell) or cell.startswith("("):
                    breeder = cell
                    used.add((c, rr))
                elif not deep_band and not farbschlag and not re.match(r"^\*?\s?\d", cell) \
                        and not looks_like_animal_name(cell):
                    farbschlag = cell
                    used.add((c, rr))
            used.add((c, r))

        g = parse_detail(t) if compact else (dob, death, geno)
        genodict = gt.parse(geno)
        # Zucht: from the name's [tag]/of-von suffix, else from the breeder line
        _, zraw = split_name_zucht(name)
        if not zraw and breeder:
            zraw = breeder
        animals.append({
            "id": None,                       # assigned in dedup
            "name": name,
            "nameVariants": [],
            "dob": dob,
            "death": death,
            "gender": fillsex.get((c, r)),    # box colour: blue=male, white=female
            "farbschlag": farbschlag,
            "genotype": genodict,
            "deaf": genodict.get("deaf"),
            "tags": genodict.get("tags", []),
            "breeder": breeder,
            "zucht": zraw,
            "parentRefs": [],
            "photos": [],
            "sourceFiles": [fname],
            "_gen": gen_of(c),
            "_col": c,
            "_row": r,
            "_file": fname,
            "_zucht": norm_zucht(zraw),
        })

    # name-pair cells "X & Y" (deepest generation, names only)
    for (c, r), t in cells.items():
        if (c, r) in used:
            continue
        if " & " in t and not DOB.search(t) and len(t) < 90 and gen_of(c) >= 4:
            for part in t.split(" & "):
                part = clean_name(part)
                if part:
                    _, zraw = split_name_zucht(part)
                    animals.append({
                        "id": None, "name": part, "nameVariants": [],
                        "dob": "", "death": "", "gender": None, "farbschlag": "",
                        "genotype": gt.parse(""), "deaf": None, "tags": [],
                        "breeder": "", "zucht": zraw,
                        "parentRefs": [], "photos": [], "sourceFiles": [fname],
                        "_gen": gen_of(c), "_col": c, "_row": r, "_file": fname,
                        "_zucht": norm_zucht(zraw),
                    })

    _reconstruct_parents(animals)
    _attach_photos(z, sheets, animals, fname)
    return animals


def _reconstruct_parents(animals):
    """Positional: an animal's parents are the bracketing blocks one generation
    deeper (father = nearest block above, mother = nearest below). Role guess is
    by vertical position (German charts: Vater oben) — flagged for review; the
    Wurfchronik is authoritative for matched animals (Stage 3)."""
    by_gen = {}
    for a in animals:
        by_gen.setdefault(a["_gen"], []).append(a)
    for g, group in by_gen.items():
        nxt = sorted(by_gen.get(g + 1, []), key=lambda a: a["_row"])
        if not nxt:
            continue
        for a in group:
            r = a["_row"]
            above = [x for x in nxt if x["_row"] <= r]
            below = [x for x in nxt if x["_row"] > r]
            father = above[-1] if above else None
            mother = below[0] if below else None
            for parent, role in ((father, "father"), (mother, "mother")):
                if parent and parent["name"]:
                    a["parentRefs"].append({
                        "name": parent["name"],
                        "dob": parent["dob"],
                        "roleGuess": role,
                        "method": "chart-position",
                        "confidence": "medium",
                    })


def _attach_photos(z, sheets, animals, fname):
    anchors = [a for a in xu.image_anchors(z)]
    if not anchors:
        return
    by_gen = {}
    for a in animals:
        by_gen.setdefault(a["_gen"], []).append(a)
    media_dir = os.path.join(OUT, "photos")
    for i, (sp, col, row, media) in enumerate(anchors):
        g = gen_of(col)
        cands = by_gen.get(g, [])
        if not cands:
            # fall back to nearest animal by row across all gens
            cands = animals
        target = min(cands, key=lambda a: abs(a["_row"] - row)) if cands else None
        if not target:
            continue
        ext = os.path.splitext(media)[1] or ".img"
        sl = slug(target["name"], target["dob"])
        dest_dir = os.path.join(media_dir, sl)
        os.makedirs(dest_dir, exist_ok=True)
        rel = f"photos/{sl}/{os.path.basename(media)}"
        try:
            with z.open(media) as src, open(os.path.join(OUT, rel), "wb") as dst:
                shutil.copyfileobj(src, dst)
            target["photos"].append(rel)
        except KeyError:
            pass


# -------------------------------------------------- Wurfchronik extraction ---
def extract_wurfchronik(path):
    """Return list of litter dicts. Parses columns BY HEADER (sheets differ)."""
    fname = os.path.basename(path)
    z = __import__("zipfile").ZipFile(path)
    ss = xu.shared_strings(z)
    litters = []
    for sp in xu.sheet_paths(z):
        cells = xu.read_cells(z, sp, ss)
        if not cells:
            continue
        # build row -> {colnum: text}
        rows = {}
        for (c, r), t in cells.items():
            rows.setdefault(r, {})[c] = t
        hdr = xu.header_row(cells)            # colnum -> header label
        hdr_row = min(r for (_, r) in cells)  # the header row number, to skip it
        # map header label -> colnum (fuzzy by keyword)
        def find(*keys):
            for c, lbl in hdr.items():
                low = lbl.lower()
                if any(k in low for k in keys):
                    return c
            return None
        col_id = find("wurfbuchstabe", "buchstabe")
        col_date = find("geburtsdatum", "datum")
        col_dam = find("mutter")
        col_sire = find("vater")
        col_ws = find("ws", "wurfstärke", "wurfstaerke")
        col_breakdown = find("männchen", "maennchen", "weibchen")
        col_zn = find("zuchtnummer")
        col_note = find("bemerkung", "anmerkung")
        # Julian (authoritative): Tabelle1 has an UNLABELED numeric column between
        # Vater and Wurfstärke = "Überlebende bis zum Abgabedatum" (survivors to
        # go-home). Tabelle2 dropped it. Header-based find() cannot see it, so
        # detect it positionally.
        col_survived = None
        if col_sire and col_ws and col_ws - col_sire > 1:
            mapped = {col_id, col_date, col_dam, col_sire, col_ws,
                      col_breakdown, col_zn, col_note}
            for c in range(col_sire + 1, col_ws):
                if c not in mapped:
                    col_survived = c
                    break
        sheet_name = os.path.basename(sp)
        for r in sorted(rows):
            if r == hdr_row:                  # skip the header row itself
                continue
            row = rows[r]
            # skip empty-id + "Jahr YYYY" section rows
            txt_b = row.get(col_date, "") if col_date else ""
            if not row.get(col_id):
                continue
            if "jahr" in " ".join(row.values()).lower() and not DOB.search(txt_b):
                continue
            dob = DOB.search(txt_b)
            lid = row.get(col_id, "")
            datestr = dob.group(1) if dob else ""
            dam_raw = row.get(col_dam, "") if col_dam else ""
            sire_raw = row.get(col_sire, "") if col_sire else ""
            _, dam_zucht = split_name_zucht(dam_raw)
            # '&' = multiple sires possible (litter with uncertain/dual sire)
            sire_names = [s.strip() for s in sire_raw.split("&") if s.strip()]
            _, sire_zucht = split_name_zucht(sire_names[0] if sire_names else "")

            # Numeric layout is read VALUE-ADAPTIVELY per row: a handful of
            # Tabelle2 rows insert an extra numeric column (Überlebende, T1
            # order E,F) before WS and shift the breakdown right of its header.
            warnings = []
            scan_end = (col_note or (col_sire or 4) + 6) + 1
            bd_col = None
            for c in range((col_sire or 4) + 1, scan_end):
                if re.fullmatch(r"\d+(?:[,;]\d+){1,3}", row.get(c, "")):
                    bd_col = c
                    break
            if bd_col and col_breakdown and bd_col != col_breakdown:
                warnings.append(
                    f"Spaltenschema-Abweichung: Geschlechter-Aufschlüsselung in "
                    f"Spalte {xu.num_to_col(bd_col)} statt "
                    f"{xu.num_to_col(col_breakdown)} gefunden")
            # single numeric cells between Vater and the breakdown: E (Überlebende)
            # and/or F (Wurfstärke), in T1 order
            singles = []
            for c in range((col_sire or 4) + 1, bd_col or scan_end):
                v = row.get(c, "")
                if re.fullmatch(r"\d+", v):
                    singles.append(int(v))
            if len(singles) >= 2:
                survived, total_born = singles[0], singles[1]
                if not col_survived:
                    warnings.append(
                        "Spaltenschema-Abweichung: zusätzliche Zahlenspalte als "
                        "„Überlebende“ interpretiert (bitte prüfen)")
            elif len(singles) == 1:
                total_born = singles[0]
                survived = None
            else:
                total_born = _to_int(row.get(col_ws)) if col_ws else None
                survived = _to_int(row.get(col_survived)) if col_survived else None

            # breakdown "2,0,2,0" = Männchen, Weibchen, Totgeburt, später
            # verstorben ('s' = died after birth, before Abgabe — Julian).
            bd = row.get(bd_col, "") if bd_col else ""
            m = re.findall(r"\d+", bd)
            males = females = stillborn = died_later = None
            if m:
                vals = [int(x) for x in m] + [None] * 4
                males, females, stillborn, died_later = vals[:4]
            # Validation (Julian): Überlebende E should equal F − TG − s.
            # Mismatch = data-quality signal for the review report, NOT a blocker.
            if survived is not None and total_born is not None:
                expected = total_born - (stillborn or 0) - (died_later or 0)
                if survived != expected:
                    warnings.append(
                        f"Überlebende ({survived}) ≠ Wurfstärke ({total_born}) "
                        f"− Totgeburten ({stillborn or 0}) − später verstorben "
                        f"({died_later or 0}) = {expected}")
            litters.append({
                "id": f"{sheet_name.replace('.xml','')}-{lid}-{norm_dob(datestr)}",
                "litterId": lid,
                "date": datestr,
                "damName": dam_raw,
                "damZucht": dam_zucht,
                "sireName": sire_raw,
                "sireNames": sire_names,
                "sireZucht": sire_zucht,
                "totalBorn": total_born,
                "survivedToGoHome": survived,
                "males": males,
                "females": females,
                "stillborn": stillborn,
                "diedLater": died_later,
                "breakdownRaw": bd,
                "zuchtnummer": row.get(col_zn, "") if col_zn else "",
                "note": row.get(col_note, "") if col_note else "",
                "warnings": warnings,
                "sourceFile": fname,
                "sheet": sheet_name,
                "row": r,
            })
    return litters


def _to_int(s):
    if not s:
        return None
    m = re.search(r"\d+", s)
    return int(m.group()) if m else None


# ------------------------------------------------------------- stage 2: dedup
def _geno_key(genodict):
    """Canonical, order-independent key of a genotype's mapped loci — used for conflict
    detection so Uw==G (and allele ordering) no longer count as a conflict."""
    m = genodict.get("mapped8locus", {})
    return "|".join(f"{locus}:{','.join(sorted(m[locus]))}" for locus in sorted(m))


# --- "presence wins" merge rule (Julian) -------------------------------------
# When two source variants of the SAME animal differ ONLY by a token PRESENT in one and
# ABSENT in the other — a whole locus (e.g. spsp recorded in one chart, omitted in another)
# or a modifier on the same base allele (e^f vs e, i.e. the [f] marker) — keep the present
# token; that is NOT a conflict. A genuine VALUE contradiction (different filled alleles:
# E vs e, D vs d, c^h vs c^chm) OR unknown-vs-filled (D- vs DD, the '?' second allele) STILL
# quarantines for human decision. (Markers/flags WP/DP/WFNZ/hörend are already tags/flags,
# never part of the genotype, so they never reach here.)
def _split_allele(a):
    return tuple(a.split("^", 1)) if "^" in a else (a, "")


def _alleles_compatible(a, b):
    if a == b:
        return True
    if a == "?" or b == "?":
        return True                        # specific-wins: unknown allele is compatible with any
                                           # specified value (C- vs CC -> CC; G- vs Gg -> Gg)
    (ba, ma), (bb, mb) = _split_allele(a), _split_allele(b)
    if ba != bb:
        return False                       # different base allele = real value diff (E vs e, D vs d)
    return ma == "" or mb == ""            # same base, modifier present-vs-absent -> presence wins


def _pair_compatible(p, q):
    if len(p) != 2 or len(q) != 2:
        return p == q
    return ((_alleles_compatible(p[0], q[0]) and _alleles_compatible(p[1], q[1])) or
            (_alleles_compatible(p[0], q[1]) and _alleles_compatible(p[1], q[0])))


def _genotype_conflict(mapped_list):
    """True only if two variants GENUINELY contradict at a shared locus. A locus present in
    one variant and absent in another is fine (presence wins); so is a modifier present-vs-
    absent on the same base allele. Replaces the old `len(distinct geno keys) > 1` test."""
    loci = set()
    for m in mapped_list:
        loci.update(m.keys())
    for locus in loci:
        pairs = [m[locus] for m in mapped_list if locus in m]
        for i in range(len(pairs)):
            for j in range(i + 1, len(pairs)):
                if not _pair_compatible(pairs[i], pairs[j]):
                    return True
    return False


def dedup(animals):
    """Merge by normalise(call-name)+DOB, with the canonical Zucht as
    DISCRIMINATOR (Julian: same name+DOB+Zucht = same animal; different Zucht =
    different animal). Returns (merged, conflicts, orphans, zucht_splits)."""
    groups = {}
    orphans = []
    for a in animals:
        call, _ = split_name_zucht(a["name"])
        key = (norm_name(call), norm_dob(a["dob"]))
        if not key[0] or not key[1]:
            orphans.append(a)
            # orphans still get a stable id but are not merged
            key = ("__orphan__", id(a))
        groups.setdefault(key, []).append(a)

    # split groups whose members carry DIFFERENT (non-empty) canonical Zuchten;
    # members without a Zucht merge into the group only if it is unambiguous.
    final_groups = []
    zucht_splits = []
    for key, grp in groups.items():
        by_zucht = {}
        for a in grp:
            by_zucht.setdefault(a.get("_zucht", ""), []).append(a)
        nonempty = sorted(z for z in by_zucht if z)
        if len(nonempty) <= 1:
            final_groups.append(grp)
            continue
        # genuine split: same call-name+DOB, different Zucht
        for z in nonempty:
            final_groups.append(by_zucht[z])
        if "" in by_zucht:
            # Zucht-less mentions cannot be attributed -> own (flagged) entry
            final_groups.append(by_zucht[""])
        zucht_splits.append({
            "name": grp[0]["name"],
            "dob": norm_dob(grp[0]["dob"]),
            "zuechte": sorted(set(a["zucht"] for a in grp if a.get("zucht"))),
            "files": sorted(set(f for a in grp for f in a["sourceFiles"])),
        })

    merged = []
    conflicts = []
    for grp in final_groups:
        base = dict(grp[0])
        variants = set([base["name"]])
        files = set(base["sourceFiles"])
        photos = list(base["photos"])
        parent_refs = list(base["parentRefs"])
        genos = set()
        geno_keys = set()   # GEN-3b: conflict on NORMALIZED genotype (Uw==G) not raw text
        mapped_variants = []  # mapped8locus per variant — for the 'presence wins' conflict test
        farb = set()
        deaths = set()
        deaf_seen = set()
        tags_set = set()
        genders = []
        for a in grp:
            variants.add(a["name"])
            files.update(a["sourceFiles"])
            photos.extend(a["photos"])
            parent_refs.extend(a["parentRefs"])
            if a.get("gender"):
                genders.append(a["gender"])
            if a["genotype"]["mapped8locus"]:
                genos.add(a["genotype"]["rawGenotype"])
                geno_keys.add(_geno_key(a["genotype"]))
                mapped_variants.append(a["genotype"]["mapped8locus"])
            if a["farbschlag"]:
                farb.add(a["farbschlag"])
            if a["death"]:
                deaths.add(norm_dob(a["death"]))
            if a.get("deaf") is not None:
                deaf_seen.add(a["deaf"])
            tags_set.update(a.get("tags", []))
        # pick the richest genotype: most mapped loci, then fewest unknowns ('?' alleles = specific
        # wins, FIX-2), then longest raw string as final tiebreaker.
        def _specificity(gd):
            return sum(1 for pair in gd["mapped8locus"].values() for a in pair if a != "?")
        best = max((a["genotype"] for a in grp),
                   key=lambda gd: (len(gd["mapped8locus"]), _specificity(gd), len(gd["rawGenotype"])))
        out = {
            "id": slug(base["name"], base["dob"]),
            "name": base["name"],
            "nameVariants": sorted(v for v in variants if v),
            "dob": norm_dob(base["dob"]),
            "death": sorted(deaths)[0] if deaths else "",
            # box-colour sex (blue=male, white=female): majority across mentions, else None.
            "gender": Counter(genders).most_common(1)[0][0] if genders else None,
            "farbschlag": sorted(farb)[0] if farb else "",
            "farbschlagVariants": sorted(farb),
            "genotype": best,
            "breeder": next((a["breeder"] for a in grp if a["breeder"]), ""),
            "zucht": next((a["zucht"] for a in grp if a.get("zucht")), ""),
            "zuchtCanon": next((a["_zucht"] for a in grp if a.get("_zucht")), ""),
            "parentRefs": _dedup_parentrefs(parent_refs),
            "photos": sorted(set(photos)),
            "sourceFiles": sorted(files),
            "mentions": len(grp),
            # GEN-3b: hearing/deaf phenotype flag (deaf wins if any mention says so) + tags.
            "deaf": (True if True in deaf_seen else (False if False in deaf_seen else None)),
            "tags": sorted(tags_set),
            # FEAT-8c: machine-readable quarantine marker so the API loader can skip
            # conflicting records without parsing the German review report.
            "conflict": False,
        }
        merged.append(out)
        # conflict: same animal, GENUINELY disagreeing genotype (presence-vs-absence is NOT a
        # conflict — Julian's 'presence wins') or >1 distinct farbschlag or >1 distinct death.
        if _genotype_conflict(mapped_variants) or len(farb) > 1 or len(deaths) > 1:
            out["conflict"] = True
            conflicts.append({
                "id": out["id"], "name": base["name"], "dob": out["dob"],
                "genotypes": sorted(genos), "farbschlaege": sorted(farb),
                "deaths": sorted(deaths), "files": sorted(files),
            })
    merged.sort(key=lambda a: (a["dob"], a["name"]))
    # ids must stay unique even when a Zucht split duplicates name+DOB slugs
    seen_ids = {}
    for a in merged:
        if a["id"] in seen_ids:
            seen_ids[a["id"]] += 1
            a["id"] = f"{a['id']}-{seen_ids[a['id']]}"
        else:
            seen_ids[a["id"]] = 1
    return merged, conflicts, orphans, zucht_splits


# ------------------------------------------- animal <-> litter matching ------
def match_litters(merged, litters):
    """Attach each animal to its Wurfchronik litter (Pam-validated build order:
    litters are canonical, animals match onto them via DOB + (Vater, Mutter)).
    Sets a['litterRef']; returns match statistics for the report."""
    by_date = {}
    for l in litters:
        d = norm_dob(l["date"])
        if d:
            by_date.setdefault(d, []).append(l)

    stats = {"parents": 0, "dateOnly": 0, "ambiguous": 0}
    for a in merged:
        if not a["dob"]:
            continue
        cands = by_date.get(a["dob"])
        if not cands:
            continue
        a_parents = set()
        for ref in a["parentRefs"]:
            cn, _ = canon_pair(ref["name"])
            if cn:
                a_parents.add(cn)

        def score(l):
            s = 0
            for nm in [l["damName"]] + l.get("sireNames", []):
                cn, _ = canon_pair(nm)
                if cn and cn in a_parents:
                    s += 1
            return s

        scored = sorted(((score(l), l["id"]) for l in cands), reverse=True)
        best_score, best_id = scored[0]
        if best_score > 0 and (len(scored) == 1 or scored[1][0] < best_score):
            a["litterRef"] = {"litterId": best_id,
                              "method": "geburtsdatum+eltern", "confidence": "hoch"}
            stats["parents"] += 1
        elif len(cands) == 1:
            a["litterRef"] = {"litterId": cands[0]["id"],
                              "method": "nur-geburtsdatum", "confidence": "niedrig"}
            stats["dateOnly"] += 1
        else:
            a["litterRef"] = {"litterId": None, "method": "mehrdeutig",
                              "candidates": [l["id"] for l in cands]}
            stats["ambiguous"] += 1
    return stats


def _dedup_parentrefs(refs):
    seen = {}
    for r in refs:
        k = (norm_name(r["name"]), r["roleGuess"])
        if k not in seen:
            seen[k] = r
    return list(seen.values())


# ------------------------------------------------------------------ reporting
def write_report(merged, conflicts, orphans, raw_count, litters, photo_count,
                 zucht_splits, match_stats):
    multi = [a for a in merged if a["mentions"] > 1]
    with_dob = [a for a in merged if a["dob"]]
    val_warn = [l for l in litters if any("≠" in w for w in l["warnings"])]
    schema_warn = [l for l in litters if any("Abweichung" in w for w in l["warnings"])]
    matched = match_stats["parents"] + match_stats["dateOnly"]

    L = []
    L.append("# FEAT-8b — Import-Vorschau & Prüfbericht (Stammbäume + Wurfchronik)\n")
    L.append("_Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Datenbank geladen.** "
             "Bitte prüfen, bevor importiert wird._\n")
    L.append("## Überblick\n")
    L.append(f"- Rohe Tier-Einträge aus den Stammbäumen: **{raw_count}**")
    L.append(f"- Nach Zusammenführung (eindeutige Tiere): **{len(merged)}**")
    L.append(f"  - davon mit Geburtsdatum: {len(with_dob)}")
    L.append(f"  - in mehreren Dateien gefunden (Dubletten zusammengeführt): {len(multi)}")
    L.append(f"- Konflikte zur Klärung: **{len(conflicts)}**")
    L.append(f"- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **{len(orphans)}**")
    L.append(f"- Fotos zugeordnet: **{photo_count}**")
    L.append(f"- Würfe aus der Wurfchronik: **{len(litters)}**")
    L.append(f"  - Tiere mit Wurf verknüpft: **{matched}** "
             f"(davon über Geburtsdatum **und** Eltern: {match_stats['parents']}, "
             f"nur über Geburtsdatum: {match_stats['dateOnly']}; "
             f"mehrdeutig: {match_stats['ambiguous']})")
    L.append(f"  - Würfe mit Datenqualitäts-Hinweisen: {len(val_warn)} "
             f"(+ {len(schema_warn)} Zeilen mit abweichendem Spaltenschema)\n")

    L.append("## Zusammenführungs-Schlüssel\n")
    L.append("Tiere wurden zusammengeführt über **normalisierter Rufname + Geburtsdatum**, "
             "mit der **Zucht als Unterscheidungsmerkmal** (Julians Regel: die `[Klammern]` "
             "in der Wurfchronik und das `of/von <Linie>`-Suffix der Stammbäume bezeichnen "
             "beide die Zucht und werden zusammengeführt — z. B. `[ZdkC]` ≙ "
             "`von den Kleinen Chaoten`). Namensvarianten (z. B. `v.d.` ↔ `von den`, "
             "`gen.`-Spitznamen) werden als `nameVariants` erhalten.\n")

    if zucht_splits:
        L.append("### Gleicher Name + Geburtsdatum, aber unterschiedliche Zucht "
                 "(NICHT zusammengeführt — bitte prüfen)\n")
        L.append("| Tier | Geburtsdatum | Zuchten | Dateien |")
        L.append("|---|---|---|---|")
        for s in zucht_splits[:50]:
            L.append("| {} | {} | {} | {} |".format(
                split_name_zucht(s["name"])[0], s["dob"],
                " // ".join(s["zuechte"]),
                ", ".join(os.path.splitext(f)[0] for f in s["files"])))
        L.append("")

    L.append("## ⚠️ Konflikte (bitte prüfen)\n")
    if conflicts:
        L.append("Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Dateien:\n")
        L.append("| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |")
        L.append("|---|---|---|---|---|---|")
        for c in conflicts[:200]:
            L.append("| {} | {} | {} | {} | {} | {} |".format(
                c["name"], c["dob"],
                " // ".join(c["genotypes"]) or "—",
                " // ".join(c["farbschlaege"]) or "—",
                " // ".join(c["deaths"]) or "—",
                ", ".join(os.path.splitext(f)[0] for f in c["files"])))
    else:
        L.append("_Keine._\n")

    L.append("\n## Mehrdeutige / unvollständige Einträge\n")
    L.append(f"{len(orphans)} Einträge ohne sichere Name+Datum-Kombination "
             "(z. B. `Name1 & Name2`-Paarzellen der tiefsten Generation, oder Zellen ohne Datum). "
             "Diese werden NICHT automatisch zusammengeführt.\n")
    sample = [o for o in orphans if o["name"]][:40]
    for o in sample:
        L.append(f"- {o['name']}  ·  {o.get('_file','')}")

    # orphan -> likely same-named full record (soft hint, not auto-merged)
    from collections import Counter
    name_index = {}
    for a in merged:
        if a["dob"]:
            name_index.setdefault(norm_name(a["name"]), []).append(a)
    matchable = []
    for o in orphans:
        if not o["name"]:
            continue
        cands = name_index.get(norm_name(o["name"]))
        if cands:
            matchable.append((o, cands))
    L.append("\n## Wahrscheinliche Zuordnungen unvollständiger Einträge\n")
    L.append(f"{len(matchable)} namenlose/datenlose Einträge tragen denselben Namen wie ein "
             "vollständiges Tier — vermutlich dasselbe Tier (zur Bestätigung):\n")
    for o, cands in matchable[:60]:
        opts = "; ".join(f"{c['name']} (*{c['dob']})" for c in cands[:3])
        L.append(f"- „{o['name']}“  →  {opts}")

    # unmapped-token summary (for Kevin / GEN-2 + the wife)
    tok = Counter()
    for a in merged:
        for t in a["genotype"]["unmappedTokens"]:
            tok[t] += 1
    L.append("\n## Nicht ins 8-Loci-Modell abgebildete Tokens (verbatim erhalten)\n")
    L.append("Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung "
             "(Modell erweitern vs. als Notiz) liegt bei Julian/Kevin:\n")
    L.append("| Token | Vorkommen | Bedeutung (Vermutung) |")
    L.append("|---|---|---|")
    hint = {"Uwuw[d]": "9. Locus Uw (nicht im Modell)", "UwUw": "9. Locus Uw",
            "[WFNZ]": "Marker", "[DP]": "Marker (Dunkelpigment?)", "DP": "Marker",
            "WP": "Marker", "[WP]": "Marker", "C(C)": "Schreibweise (C trägt c)",
            "chmchm": "Schreibweise (c[chm]c[chm])"}
    for t, n in tok.most_common(25):
        L.append(f"| `{t}` | {n} | {hint.get(t, '?')} |")

    # Wurfchronik data quality (Julian: E sollte = F − TG − s sein)
    L.append("\n## Wurfchronik — Datenqualitäts-Hinweise\n")
    L.append("Julians Spaltenregel: **Überlebende bis Abgabe (E) = Wurfstärke (F) − "
             "Totgeburten (TG) − später verstorben (s)**. Bei diesen Würfen geht die "
             "Rechnung nicht auf — kein Import-Hindernis, aber ein Hinweis auf "
             "Tippfehler oder fehlende Einträge:\n")
    if val_warn:
        L.append("| Wurf | Datum | Mutter × Vater | Hinweis |")
        L.append("|---|---|---|---|")
        for l in val_warn[:120]:
            L.append("| {} | {} | {} × {} | {} |".format(
                l["litterId"], l["date"], l["damName"], l["sireName"],
                "; ".join(w for w in l["warnings"] if "≠" in w)))
        if len(val_warn) > 120:
            L.append(f"\n… und {len(val_warn) - 120} weitere (siehe `litters.json`).")
    else:
        L.append("_Keine — alle Würfe sind rechnerisch konsistent._")

    L.append("\n### Zeilen mit abweichendem Spaltenschema (automatisch interpretiert)\n")
    L.append(f"{len(schema_warn)} Zeilen (überwiegend Tabelle2 ab 2014) tragen eine "
             "zusätzliche Zahlenspalte vor der Wurfstärke bzw. eine verschobene "
             "Geschlechter-Aufschlüsselung. Sie wurden nach dem Muster von Tabelle1 "
             "gelesen (**Überlebende, Wurfstärke, Aufschlüsselung**) — bei "
             f"{sum(1 for l in schema_warn if not any('≠' in w for w in l['warnings']))} "
             "davon geht die Rechnung E = F − TG − s damit exakt auf, was die Lesart "
             "bestätigt. Alle betroffenen Zeilen sind in `litters.json` mit "
             "`warnings` markiert. Beispiele:\n")
    for l in schema_warn[:8]:
        L.append(f"- Wurf {l['litterId']} ({l['date']}): E={l['survivedToGoHome']}, "
                 f"F={l['totalBorn']}, Aufschlüsselung `{l['breakdownRaw']}`")

    L.append("\n## Hinweise für den Import (Stufe 3, später)\n")
    L.append("- **Wurfchronik = Quelle der Würfe** (Datum, Wurfstärke, Eltern, Zuchtnummer); "
             "**Stammbäume = Abstammung + Genotyp + Fotos**. Verknüpfung über Geburtsdatum + Elternnamen.")
    L.append("- Eltern-Verknüpfungen (`parentRefs`) stammen aus der **Position im Stammbaum** "
             "(Vater oben / Mutter unten, mittlere Konfidenz) — die Wurfchronik korrigiert dies maßgeblich.")
    L.append("- Genotyp: `mapped8locus` (A C D E G P Sp Re), `rawGenotype` (wortgetreu), "
             "`unmappedTokens` (z. B. `Uw`, `Sls`, `Dea`, Marker wie `WFNZ/WP/DP`) — **nichts geht verloren**.")
    L.append("- `-` (unbekanntes zweites Allel) → `?` (Platzhalter; Annahme, bitte bestätigen).")
    with open(os.path.join(OUT, "review-report.md"), "w", encoding="utf-8") as f:
        f.write("\n".join(L) + "\n")


# ------------------------------------------------------------------------ main
def apply_dob_remaps(raw_animals, path):
    """PRE-dedup: a conflict-decision carrying `correctDob` marks a record as a DUPLICATE with a
    wrong birthdate — remap that raw record's DOB to correctDob so dedup MERGES it into the
    canonical same-named animal (e.g. Chelsea *15.10.2021 -> *02.04.2021). Match =
    canon_pair(name)+(dob) with same Zucht-aware logic as apply_conflict_decisions (see there).
    Tolerates a missing/garbled file. Returns the remap count.
    Must run BEFORE dedup (it changes the dedup identity). (god/HUMANQUESTION D — Dubletten.)"""
    remaps_full = {}   # (nameCanon, zuchtCanon, dob) -> correctDob  — decision carries Zucht
    remaps_name = {}   # (nameCanon, dob) -> correctDob              — no Zucht in decision
    try:
        with open(path, encoding="utf-8") as fh:
            for r in (json.load(fh).get("resolutions") or []):
                if r.get("correctDob"):
                    nc, zc = canon_pair(r.get("name", ""))
                    dob = norm_dob(r.get("dob", ""))
                    if zc:
                        remaps_full[(nc, zc, dob)] = r["correctDob"]
                    else:
                        remaps_name[(nc, dob)] = r["correctDob"]
    except (OSError, ValueError):
        return 0
    if not remaps_full and not remaps_name:
        return 0
    n = 0
    for a in raw_animals:
        nc, zc = canon_pair(a.get("name", ""))
        dob = norm_dob(a.get("dob", ""))
        new = remaps_full.get((nc, zc, dob)) or remaps_name.get((nc, dob))
        if new and a.get("dob") != new:
            a["dob"] = new
            n += 1
    return n


def apply_conflict_decisions(merged, conflicts, path):
    """Consume human conflict resolutions (tools/import/conflict-decisions.json) so the wife's
    answers UN-QUARANTINE animals. Schema: {"resolutions":[{name, dob, decision, genotype?,
    farbschlag?, source}]}. Match = canon_pair(name)+(dob):
      - When the decision name CARRIES a Zucht (zuchtCanon != ''), match on the FULL
        (nameCanon, zuchtCanon, dob) triple — preserves the C3 rule that same name+DOB but
        different Zucht = different animal.
      - When the decision has NO Zucht, fall back to (nameCanon, dob) name-only match.
    Both spellings v.d. / von den fold to the same canon. A matching animal: clear its conflict,
    mark resolvedByDecision; an explicit `genotype` (breeder notation) is parsed and becomes
    authoritative, `farbschlag` overrides too. Tolerates a missing/empty/garbled file.
    Returns the number of conflicts resolved. (god/HUMANQUESTION D.)"""
    decisions_full = {}   # (nameCanon, zuchtCanon, dob) -> r  — when decision carries a Zucht
    decisions_name = {}   # (nameCanon, dob) -> r              — fallback, decision has no Zucht
    try:
        with open(path, encoding="utf-8") as fh:
            for r in (json.load(fh).get("resolutions") or []):
                nc, zc = canon_pair(r.get("name", ""))
                dob = norm_dob(r.get("dob", ""))
                if zc:
                    decisions_full[(nc, zc, dob)] = r
                else:
                    decisions_name[(nc, dob)] = r
    except (OSError, ValueError):
        return 0
    if not decisions_full and not decisions_name:
        return 0

    resolved = 0
    for a in merged:
        nc, zc = canon_pair(a["name"])
        dob = norm_dob(a["dob"])
        d = decisions_full.get((nc, zc, dob)) or decisions_name.get((nc, dob))
        if not d:
            continue
        a["resolvedByDecision"] = True
        if d.get("genotype"):
            # CR-10: validate the parsed genotype — a typo'd decision string yields empty
            # mapped8locus and would silently blank the animal's genotype while marking it
            # 'resolved'. Only apply if the parse produces non-empty loci.
            parsed = gt.parse(d["genotype"])
            if parsed.get("mapped8locus"):
                a["genotype"] = parsed
            else:
                # Keep the existing genotype; flag as a warning in the report.
                a.setdefault("decisionWarnings", []).append(
                    f"Ungültiger Override-Genotyp '{d['genotype']}' — "
                    "konnte nicht geparst werden (mapped8locus leer). "
                    "Bestehender Genotyp behalten; Konflikt wurde trotzdem aufgelöst."
                )
        if d.get("farbschlag"):
            a["farbschlag"] = d["farbschlag"]
            a["farbschlagVariants"] = [d["farbschlag"]]
        if d.get("dateOfDeath"):                       # D5 death-date resolutions
            a["death"] = norm_dob(d["dateOfDeath"])
        if a.get("conflict"):
            a["conflict"] = False
            conflicts[:] = [c for c in conflicts if c.get("id") != a["id"]]
            resolved += 1
    return resolved


def main():
    try:
        sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    except Exception:
        pass
    ap = argparse.ArgumentParser(description="FEAT-8b extractor (stages 1-2)")
    ap.add_argument("--stammbaeume", default=DEFAULT_STAMMBAEUME)
    ap.add_argument("--wurfchronik", default=DEFAULT_WURFCHRONIK)
    args = ap.parse_args()

    if os.path.isdir(OUT):
        # keep the report stable across reruns but refresh data/photos
        for sub in ("photos",):
            p = os.path.join(OUT, sub)
            if os.path.isdir(p):
                shutil.rmtree(p)
    os.makedirs(OUT, exist_ok=True)

    raw_animals = []
    # Skip Excel lock/owner files ("~$...") that appear while a workbook is open.
    files = sorted(f for f in glob.glob(os.path.join(args.stammbaeume, "*.xlsx"))
                   if not os.path.basename(f).startswith("~$"))
    print(f"Stammbaum-Dateien: {len(files)}")
    for path in files:
        got = extract_stammbaum(path)
        print(f"  {len(got):4d}  {os.path.basename(path)}")
        raw_animals.extend(got)

    litters = []
    if os.path.isfile(args.wurfchronik):
        litters = extract_wurfchronik(args.wurfchronik)
        print(f"Wurfchronik: {len(litters)} Würfe")

    decisions_path = os.path.join(HERE, "conflict-decisions.json")
    dob_remaps = apply_dob_remaps(raw_animals, decisions_path)   # before dedup (changes identity)
    merged, conflicts, orphans, zucht_splits = dedup(raw_animals)
    resolved_by_decision = apply_conflict_decisions(merged, conflicts, decisions_path)
    match_stats = match_litters(merged, litters)
    photo_count = sum(len(a["photos"]) for a in merged)

    # strip private (_) fields from the JSON output
    def clean(a):
        return {k: v for k, v in a.items() if not k.startswith("_")}

    with open(os.path.join(OUT, "animals.json"), "w", encoding="utf-8") as f:
        json.dump([clean(a) for a in merged], f, ensure_ascii=False, indent=2)
    with open(os.path.join(OUT, "litters.json"), "w", encoding="utf-8") as f:
        json.dump(litters, f, ensure_ascii=False, indent=2)

    write_report(merged, conflicts, orphans, len(raw_animals), litters, photo_count,
                 zucht_splits, match_stats)

    print(f"\nRoh: {len(raw_animals)}  →  eindeutig: {len(merged)}  "
          f"| Konflikte: {len(conflicts)}  | per Entscheidung gelöst: {resolved_by_decision}  "
          f"| DOB-Remaps: {dob_remaps}  | Zucht-Splits: {len(zucht_splits)}  "
          f"| Orphans: {len(orphans)}  | Fotos: {photo_count}")
    print(f"Wurf-Verknüpfung: {match_stats['parents']} (Datum+Eltern), "
          f"{match_stats['dateOnly']} (nur Datum), {match_stats['ambiguous']} mehrdeutig "
          f"| Wurf-Warnungen: {sum(1 for l in litters if l['warnings'])}")
    print(f"Ausgabe in {OUT}")


if __name__ == "__main__":
    main()
