"""Zero-dep tests for extract.py band-aware Farbschlag + name-bleed guard.

Run: python test_extract.py   (exit 0 = all pass)
Covers (PEDIGREE-LINK / Julian-confirmed): deep pedigree bands (gen >= 2, cols K/N/Q...)
are Name/DOB/Genotype ONLY — no Farbschlag cell — so a stray health note or the next
block's name must NOT be captured as Farbschlag; early bands (gen 0-1) keep their real
Farbschlag. Plus the looks_like_animal_name guard (a parent name must not be a Farbschlag).
"""
import os
import sys
import zipfile
import tempfile
import extract as e

failed = 0


def check(name, cond):
    global failed
    print(("ok:   " if cond else "FAIL: ") + name)
    if not cond:
        failed += 1


def _cell(ref, text):
    return f'<c r="{ref}" t="inlineStr"><is><t>{text}</t></is></c>'


def _make_xlsx(path, cells):
    """cells: {(colLetter+row): text}. Build a minimal single-sheet xlsx (no styles)."""
    rows = {}
    for ref, text in cells.items():
        r = int("".join(ch for ch in ref if ch.isdigit()))
        rows.setdefault(r, []).append(_cell(ref, text))
    body = "".join(f'<row r="{r}">{"".join(cs)}</row>' for r, cs in sorted(rows.items()))
    sheet = ('<?xml version="1.0"?><worksheet xmlns="http://x"><sheetData>'
             + body + "</sheetData></worksheet>")
    with zipfile.ZipFile(path, "w") as z:
        z.writestr("xl/worksheets/sheet1.xml", sheet)


# --- band-aware Farbschlag ---
# col E = gen 0 (early, HAS Farbschlag); col K = col 11 = gen 2 (deep, NO Farbschlag).
tmp = os.path.join(tempfile.gettempdir(), "bandtest.xlsx")
_make_xlsx(tmp, {
    # early band (E): Name / *DOB / Farbschlag / Genotype
    "E10": "Chesnut",
    "E11": "*13.11.2019",
    "E12": "Kohlfuchsschimmel",
    "E13": "aa CC DD ee GG PP spsp rere",
    # deep band (K): Name / *DOB / Genotype / stray NOTE (must NOT become Farbschlag)
    "K10": "DeepAnimal",
    "K11": "*01.01.2020",
    "K12": "aa CC DD EE GG PP spsp rere",
    "K13": "DD-Tumor",
})
try:
    animals = e.extract_stammbaum(tmp)
    by_name = {a["name"]: a for a in animals}
    check("early band keeps real Farbschlag",
          by_name.get("Chesnut", {}).get("farbschlag") == "Kohlfuchsschimmel")
    check("deep band has NO Farbschlag (note not grabbed)",
          by_name.get("DeepAnimal", {}).get("farbschlag") == "")
    check("deep-band animal still parsed (Name/DOB/Genotype)",
          "DeepAnimal" in by_name and by_name["DeepAnimal"]["dob"].startswith("01.01"))
finally:
    try: os.remove(tmp)
    except OSError: pass

gen = e.gen_of
check("gen_of: early bands < 2 (E,H)", gen(5) < 2 and gen(8) < 2)
check("gen_of: deep bands >= 2 (K,N,Q)", gen(11) >= 2 and gen(14) >= 2)

# --- conflict-decisions consumption (HUMANQUESTION D / C6) ---
dec_path = os.path.join(tempfile.gettempdir(), "conflict-decisions-test.json")
import json as _json
_json.dump({"resolutions": [
    {"name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019",
     "decision": "D-locus = D-", "genotype": "Aa c[chm]c[chm] D- Ee Gg PP Spsp",
     "source": "test"},
    {"name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017",
     "decision": "Todesdatum 10.05.2021 (2022 war Tippfehler)", "dateOfDeath": "10.05.2021",
     "source": "test"},
]}, open(dec_path, "w", encoding="utf-8"))
merged = [
    {"id": "x1", "name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019",
     "conflict": True, "farbschlag": "", "death": "",
     "genotype": {"mapped8locus": {"D": ["D", "D"]}, "rawGenotype": "DD", "unmappedTokens": []}},
    {"id": "x2", "name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017",
     "conflict": True, "farbschlag": "", "death": "10.05.2022",
     "genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
]
conflicts = [{"id": "x1", "name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019"},
             {"id": "x2", "name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017"}]
n = e.apply_conflict_decisions(merged, conflicts, dec_path)
check("decision un-quarantines (conflict cleared)", merged[0]["conflict"] is False)
check("decision marks resolvedByDecision", merged[0].get("resolvedByDecision") is True)
check("decision genotype is authoritative (D- not DD)", merged[0]["genotype"]["mapped8locus"]["D"] == ["D", "?"])
check("decision dateOfDeath is authoritative (D5)", merged[1]["death"] == "10.05.2021")
check("decision removes both entries from conflicts list", conflicts == [])
check("apply_conflict_decisions returns resolved count", n == 2)
check("missing decisions file tolerated (returns 0)",
      e.apply_conflict_decisions([], [], os.path.join(tempfile.gettempdir(), "does-not-exist.json")) == 0)

# FIX-1: decision matching uses canon_pair identity -> 'von den' decision matches 'v.d.' record
dec_vd = os.path.join(tempfile.gettempdir(), "decisions-vd.json")
_json.dump({"resolutions": [
    {"name": "Victoria Welby gen. Welby von den Kleinen Chaoten",  # written with 'von den'
     "dob": "16.01.2023", "decision": "E-locus = ee[f]",
     "genotype": "Aa CC D- ee[f] Gg pp Spsp", "source": "test"},
]}, open(dec_vd, "w", encoding="utf-8"))
merged_vd = [
    {"id": "vw", "name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten",  # record has 'v.d.'
     "dob": "16.01.2023", "conflict": True, "farbschlag": "", "death": "",
     "genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
]
conflicts_vd = [{"id": "vw"}]
n_vd = e.apply_conflict_decisions(merged_vd, conflicts_vd, dec_vd)
check("FIX-1: 'von den' decision matches 'v.d.' record (canon_pair identity)", n_vd == 1)
check("FIX-1: conflict cleared for v.d. record", merged_vd[0]["conflict"] is False)
# Also verify the workaround spelling (v.d. in decision) matches a 'von den' record
_json.dump({"resolutions": [
    {"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten",  # workaround: v.d. in decision
     "dob": "16.01.2023", "decision": "E-locus = ee[f]",
     "genotype": "Aa CC D- ee[f] Gg pp Spsp", "source": "test"},
]}, open(dec_vd, "w", encoding="utf-8"))
merged_vd2 = [
    {"id": "vw2", "name": "Victoria Welby gen. Welby von den Kleinen Chaoten",  # record 'von den'
     "dob": "16.01.2023", "conflict": True, "farbschlag": "", "death": "",
     "genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
]
conflicts_vd2 = [{"id": "vw2"}]
n_vd2 = e.apply_conflict_decisions(merged_vd2, conflicts_vd2, dec_vd)
check("FIX-1: v.d. decision also matches 'von den' record (both spellings match)", n_vd2 == 1)
try: os.remove(dec_vd)
except OSError: pass

# FIX-1 C3-rule: same name+DOB, two Zuchten -> decision hits ONLY the correct Zucht (C3 isolation)
dec_c3 = os.path.join(tempfile.gettempdir(), "decisions-c3.json")
_json.dump({"resolutions": [
    # Decision only for Luna from ZdkC, NOT Luna from Black Forest
    {"name": "Luna von den Kleinen Chaoten", "dob": "01.01.2020",
     "decision": "D-locus = DD", "genotype": "aa CC DD ee gg PP spsp rere", "source": "test"},
]}, open(dec_c3, "w", encoding="utf-8"))
merged_c3 = [
    {"id": "luna-kc", "name": "Luna von den Kleinen Chaoten", "dob": "01.01.2020",
     "conflict": True, "farbschlag": "", "death": "",
     "genotype": {"mapped8locus": {"D": ["D","?"]}, "rawGenotype": "D-", "unmappedTokens": []}},
    {"id": "luna-bf", "name": "Luna of Black Forest", "dob": "01.01.2020",
     "conflict": True, "farbschlag": "", "death": "",
     "genotype": {"mapped8locus": {"D": ["D","?"]}, "rawGenotype": "D-", "unmappedTokens": []}},
]
conflicts_c3 = [{"id": "luna-kc"}, {"id": "luna-bf"}]
n_c3 = e.apply_conflict_decisions(merged_c3, conflicts_c3, dec_c3)
check("FIX-1 C3: decision hits only the correct Zucht (luna-kc resolved)", n_c3 == 1)
check("FIX-1 C3: luna-kc conflict cleared (correct Zucht)", merged_c3[0]["conflict"] is False)
check("FIX-1 C3: luna-bf conflict NOT cleared (different Zucht)", merged_c3[1]["conflict"] is True)
check("FIX-1 C3: conflicts list has only luna-bf left", len(conflicts_c3) == 1 and conflicts_c3[0]["id"] == "luna-bf")
try: os.remove(dec_c3)
except OSError: pass

# --- correctDob: a wrong-birthdate duplicate is remapped BEFORE dedup so it merges ---
dec2 = os.path.join(tempfile.gettempdir(), "decisions-dob.json")
_json.dump({"resolutions": [
    {"name": "Chelsea von den Kleinen Chaoten", "dob": "15.10.2021",
     "decision": "duplicate wrong birthdate", "correctDob": "02.04.2021", "source": "test"},
]}, open(dec2, "w", encoding="utf-8"))
raw = [
    {"name": "Chelsea von den Kleinen Chaoten", "dob": "15.10.2021"},   # the wrong-dob duplicate
    {"name": "Chelsea von den Kleinen Chaoten", "dob": "02.04.2021"},   # canonical
    {"name": "Other Animal", "dob": "01.01.2020"},
]
rn = e.apply_dob_remaps(raw, dec2)
check("correctDob remaps the wrong-dob record", raw[0]["dob"] == "02.04.2021")
check("correctDob leaves the canonical record alone", raw[1]["dob"] == "02.04.2021")
check("correctDob leaves unrelated records alone", raw[2]["dob"] == "01.01.2020")
check("apply_dob_remaps returns remap count", rn == 1)
check("after remap both Chelsea share one dedup identity (name+dob)",
      e.norm_dob(raw[0]["dob"]) == e.norm_dob(raw[1]["dob"]))
check("missing decisions file tolerated for dob remaps (returns 0)",
      e.apply_dob_remaps([], os.path.join(tempfile.gettempdir(), "nope.json")) == 0)
try: os.remove(dec2)
except OSError: pass

try: os.remove(dec_path)
except OSError: pass

# --- "presence wins" + "specific wins" conflict rules (Julian) ---
# present-vs-absent (whole locus or [f] modifier) is NOT a conflict; differing FILLED values are.
# FIX-2 (specific-wins): unknown allele '?' vs any specified value is also NOT a conflict —
# the specific value wins (C- vs CC -> CC; G- vs Gg -> Gg; P? vs PP -> PP).
check("spsp present vs locus absent -> no conflict",
      not e._genotype_conflict([{"Sp": ["sp", "sp"]}, {}]))
check("ee[f] vs ee ([f] modifier present/absent) -> no conflict",
      not e._genotype_conflict([{"E": ["e", "e^f"]}, {"E": ["e", "e"]}]))
# FIX-2: '?' vs specified = specific wins (was: contradiction)
check("FIX-2: DD vs D- (specific wins: DD wins) -> NOT conflict",
      not e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "?"]}]))
check("FIX-2: C- vs Cc[h] (specific wins: c^h wins) -> NOT conflict",
      not e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "c^h"]}]))
check("FIX-2: C- vs CC (specific wins: CC) -> NOT conflict",
      not e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "C"]}]))
check("FIX-2: G- vs Gg (specific wins) -> NOT conflict",
      not e._genotype_conflict([{"G": ["G", "?"]}, {"G": ["G", "g"]}]))
check("FIX-2: PP vs P? (specific wins: PP) -> NOT conflict",
      not e._genotype_conflict([{"P": ["P", "P"]}, {"P": ["P", "?"]}]))
# Genuine value contradictions (both alleles specified but different) still quarantine
check("Ee vs ee (different base allele) -> conflict",
      e._genotype_conflict([{"E": ["E", "e"]}, {"E": ["e", "e"]}]))
check("DD vs Dd (both specified, D vs d) -> conflict",
      e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "d"]}]))
check("PP vs Pp (both specified) -> conflict",
      e._genotype_conflict([{"P": ["P", "P"]}, {"P": ["P", "p"]}]))
check("c[h] vs c[chm] (different modifiers, both specified) -> conflict",
      not e._alleles_compatible("c^h", "c^chm"))
check("identical genotypes -> no conflict",
      not e._genotype_conflict([{"A": ["A", "a"]}, {"A": ["A", "a"]}]))

# FIX-2 MERGE: specific allele must survive the merge regardless of which variant comes first.
# dedup() picks the most specific genotype (fewest '?' alleles); C- vs CC -> CC must win.
def _minimal_animal(name, dob, mapped):
    """Build a minimal raw animal dict suitable for dedup()."""
    from genotype import parse as gparse
    raw = " ".join(f"{l}{''.join(a)}" for l, pa in mapped.items() for a in [pa])
    return {
        "name": name, "dob": dob, "death": "", "gender": None,
        "farbschlag": "", "breeder": "", "zucht": "", "parentRefs": [],
        "photos": [], "sourceFiles": ["test.xlsx"], "tags": [],
        "deaf": None, "conflict": False,
        "genotype": {"mapped8locus": mapped, "rawGenotype": raw, "unmappedTokens": []},
        "_gen": 0, "_col": 5, "_row": 10, "_file": "test.xlsx",
        "_zucht": "",
    }

# Order A: C- first, CC second
animals_merge_a = [
    _minimal_animal("TestTier", "01.01.2020", {"C": ["C", "?"]}),  # C-
    _minimal_animal("TestTier", "01.01.2020", {"C": ["C", "C"]}),  # CC
]
merged_ma, _, _, _ = e.dedup(animals_merge_a)
check("FIX-2 merge A (C- first): result has CC not C-",
      merged_ma[0]["genotype"]["mapped8locus"].get("C") == ["C", "C"])

# Order B: CC first, C- second (must give same result)
animals_merge_b = [
    _minimal_animal("TestTier2", "02.02.2020", {"C": ["C", "C"]}),  # CC
    _minimal_animal("TestTier2", "02.02.2020", {"C": ["C", "?"]}),  # C-
]
merged_mb, _, _, _ = e.dedup(animals_merge_b)
check("FIX-2 merge B (CC first): result has CC not C-",
      merged_mb[0]["genotype"]["mapped8locus"].get("C") == ["C", "C"])

# G- vs Gg: Gg must win
animals_merge_g = [
    _minimal_animal("TestGGerbil", "03.03.2020", {"G": ["G", "?"]}),  # G-
    _minimal_animal("TestGGerbil", "03.03.2020", {"G": ["G", "g"]}),  # Gg
]
merged_mg, _, _, _ = e.dedup(animals_merge_g)
check("FIX-2 merge G (G- vs Gg): Gg wins",
      merged_mg[0]["genotype"]["mapped8locus"].get("G") == ["G", "g"])

# --- FIX-4: Skarlett parse artifact — trailing "/ +YEAR" stripped from geno, death captured ---
dob4, death4, geno4 = e.parse_detail("Skarlett,*17.04.2016, aa C- DD ee Gg PP spsp rere / +2018")
check("FIX-4: '/ +YEAR' artifact stripped from geno tail",
      geno4 == "aa C- DD ee Gg PP spsp rere")
check("FIX-4: death year still captured from full cell text",
      death4 == "2018")
check("FIX-4: DOB still correct",
      dob4 == "17.04.2016")
# Without artifact — must be unchanged
dob5, death5, geno5 = e.parse_detail("*01.01.2020, aa C- DD ee Gg PP spsp rere")
check("FIX-4: no artifact -> geno unchanged",
      geno5 == "aa C- DD ee Gg PP spsp rere")
check("FIX-4: no artifact -> no spurious death",
      death5 == "")

# --- name-bleed guard (a parent name is not a Farbschlag) ---
check("v.d. name rejected", e.looks_like_animal_name("Tennessee von den Kleinen Chaoten"))
check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. Welby v.d. Kleinen Chaoten"))
check("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel"))
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))

# --- CR-10: malformed decision genotype must NOT blank the existing genotype ---
dec_cr10 = os.path.join(tempfile.gettempdir(), "decisions-cr10.json")
_json.dump({"resolutions": [
    # Valid decision (genotype parses OK) -> should be applied
    {"name": "Agouti OK", "dob": "01.01.2020", "decision": "test",
     "genotype": "aa CC DD ee GG PP spsp rere", "source": "test"},
    # Malformed genotype (typo'd) -> must NOT blank genotype; conflict still resolved
    {"name": "Siamese Bad", "dob": "02.02.2020", "decision": "test",
     "genotype": "BLÖDSINN!!!", "source": "test"},
]}, open(dec_cr10, "w", encoding="utf-8"))
merged_cr10 = [
    {"id": "g1", "name": "Agouti OK", "dob": "01.01.2020", "conflict": True, "farbschlag": "", "death": "",
     "genotype": {"mapped8locus": {"A": ["a","a"]}, "rawGenotype": "aa", "unmappedTokens": []}},
    {"id": "g2", "name": "Siamese Bad", "dob": "02.02.2020", "conflict": True, "farbschlag": "", "death": "",
     "genotype": {"mapped8locus": {"C": ["c^h","c^h"]}, "rawGenotype": "chmchm", "unmappedTokens": []}},
]
conflicts_cr10 = [{"id": "g1"}, {"id": "g2"}]
n_cr10 = e.apply_conflict_decisions(merged_cr10, conflicts_cr10, dec_cr10)
check("CR-10: valid decision genotype is applied (A-locus updated)",
      merged_cr10[0]["genotype"]["mapped8locus"].get("C") == ["C","C"])
check("CR-10: malformed decision genotype NOT applied (C-locus preserved)",
      merged_cr10[1]["genotype"]["mapped8locus"].get("C") == ["c^h","c^h"])
check("CR-10: malformed decision still un-quarantines the animal",
      merged_cr10[1].get("conflict") is False)
check("CR-10: malformed decision adds a decisionWarning",
      bool(merged_cr10[1].get("decisionWarnings")))
check("CR-10: apply returns correct resolved count (2 conflicts cleared)", n_cr10 == 2)
try: os.remove(dec_cr10)
except OSError: pass

# --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' ---
# Julian-Entscheidung: Zucht = Kleine Chaoten wenn 'klein'+'chaoten' ODER bekannte Abkürzungen.
# The v.d. fix: trailing \b after '.' failed when next char is ' ' (non-word), so
# "v.d. kleinen chaoten" was NOT stripped before. Fix: drop the trailing \b.
check("KC-matcher: 'Zucht der Kleinen Chaoten'",
      e.is_clan_zucht("Zucht der Kleinen Chaoten"))
check("KC-matcher: 'kleinen Chaoten' (no prefix)",
      e.is_clan_zucht("kleinen Chaoten"))
check("KC-matcher: 'v.d. Kleinen Chaoten' (v.d. prefix — was broken before fix)",
      e.is_clan_zucht("v.d. Kleinen Chaoten"))
check("KC-matcher: '[ZdkC]' shorthand (bracket form, alias in ZUCHT_ALIASES)",
      e.is_clan_zucht("ZdkC"))
check("KC-matcher: 'von den Kleinen Chaoten' (full long form)",
      e.is_clan_zucht("von den Kleinen Chaoten"))
check("KC-matcher: empty string -> NOT clan",
      not e.is_clan_zucht(""))
check("KC-matcher: 'Black Forest' -> NOT clan",
      not e.is_clan_zucht("Black Forest"))
check("KC-matcher: norm_zucht regression — 'Kleine Chaoten' (base form still works)",
      e.norm_zucht("Kleine Chaoten") == "kleinechaote")
check("KC-matcher: norm_zucht regression — 'von den Kleinen Chaoten'",
      e.norm_zucht("von den Kleinen Chaoten") == "kleinechaote")
# Decision-matching FIX-1 already tested above; v.d. in decision matches 'von den' in record
# because both reduce to the same canon_pair. Verify norm_zucht directly for v.d.:
check("KC-matcher: norm_zucht('v.d. Kleinen Chaoten') == 'kleinechaote' (was broken before fix)",
      e.norm_zucht("v.d. Kleinen Chaoten") == "kleinechaote")

if failed:
    print(f"\n{failed} test(s) FAILED")
    sys.exit(1)
print("\nALL PASS")
