"""Parse the breeder's free-text genotype notation into our locus model while
losing nothing (FEAT-8b + GEN-3b normalization, per hive/agents/god/GENETIK-notation.md):

  - mapped8locus  : {locus: [allele1, allele2]} for A C D E G P Sp Re (+ Sls when present)
  - rawGenotype   : the verbatim source string
  - unmappedTokens: tokens we still couldn't place
  - deaf          : True (dea/taub) | False (Dea/hörend) | None (not stated) — phenotype FLAG, not a locus
  - tags          : provenance/breeding markers (WFNZ/RV/GV/DP/extern …) — never genotype

GEN-3b normalizations (wife + research confirmed):
  - Uw/uw == G/g  (international vs German notation for the SAME locus) -> aliased to G/g.
  - Sls/WP        is a SECOND spotting locus (S(l)s(l) = WP/Minimalschecke het). WP -> Sls het.
  - Dea/dea/taub  -> hearing/deaf flag (written after spsp), NOT a Punnett locus.
  - WFNZ/RV/GV    -> provenance/breeding tags, NOT genotype, NOT conflict-bearing.

Conventions in the source data:
  - allele superscripts are bracketed: c[chm] -> c^chm, c[h] -> c^h, e[f] -> e^f
  - a single '-' for the second allele means "unknown"  -> mapped to '?'
"""
import re

LOCI = ["A", "C", "D", "E", "G", "P", "Sp", "Re"]

_LOCUS_TOKEN = {
    "Sp": re.compile(r"^(Sp|sp)(Sp|sp|-)?$"),
    "Re": re.compile(r"^(Re|re)(Re|re|-)?$"),
    "A":  re.compile(r"^(A|a)(A|a|-)?$"),
    "C":  re.compile(r"^(C|c)(\[(?:chm|chl|ch|h|hm|e|-)\])?(C|c|-)?(\[(?:chm|chl|ch|h|hm|e|-)\])?$"),
    "D":  re.compile(r"^(D|d)(D|d|-)?$"),
    "E":  re.compile(r"^(E|e)(\[(?:f|-)\])?(E|e|-)?(\[(?:f|-)\])?$"),
    "G":  re.compile(r"^(G|g)(G|g|-)?$"),
    "P":  re.compile(r"^(P|p)(P|p|-)?$"),
}

_ALLELE_UNIT = {
    "Sp": re.compile(r"Sp|sp|-"),
    "Re": re.compile(r"Re|re|-"),
    "C":  re.compile(r"[Cc]\[(?:chm|chl|ch|hm|h|e|-)\]|[Cc]|-"),
    "E":  re.compile(r"[Ee]\[(?:f|-)\]|[Ee]|-"),
    "A":  re.compile(r"[Aa]|-"),
    "D":  re.compile(r"[Dd]|-"),
    "G":  re.compile(r"[Gg]|-"),
    "P":  re.compile(r"[Pp]|-"),
}

# Provenance/breeding tags (never genotype): Wildfangnachzucht, Rückverpaarung,
# Geschwisterverpaarung, DarkPatch, external origin.
_TAG = re.compile(r"^\[?(WFNZ|RV|GV|DP)\]?$|^\((RV|GV|extern[^)]*)\)$", re.I)


def _rewrite_uw(token):
    """Uw/uw notation -> G/g (same locus). 'Uwuw[d]' -> 'Gg', 'UwUw' -> 'GG', 'uw[d]uw[d]' -> 'gg'."""
    if "uw" not in token.lower():
        return token
    return token.replace("uw[d]", "g").replace("Uw", "G").replace("uw", "g")


def _sls_alleles(token):
    """Sls (second spotting locus) alleles, or None. WP == Sls het (Minimalschecke);
    S(l)S(l) homozygous = lethal. Allele symbols: 'Sl' / 'sl'."""
    n = token.strip("[]").replace("(l)", "l").replace("(L)", "l")
    if n in ("WP", "Sls"):
        return ["Sl", "sl"]          # heterozygous (WP phenotype)
    if n.lower() == "sls":
        return ["sl", "sl"]          # wild-type (no extra spotting)
    units = re.findall(r"Sl|sl", n)
    return units if len(units) == 2 else None


def _deaf_value(token):
    """dea/taub -> True (deaf); Dea/hörend -> False (hearing); else None."""
    t = token.strip("()[]")
    t_lower = t.lower()
    if "taub" in t_lower:
        return True
    if "hörend" in t_lower or "hoerend" in t_lower:
        return False
    if "dea/dea" in t_lower or "dea/*" in t_lower:
        return True
    if t == "dea":
        return True
    if t == "Dea":
        return False
    return None


def _alleles_for(locus, token):
    pat = _ALLELE_UNIT.get(locus)
    units = pat.findall(token) if pat else re.findall(r"[A-Za-z](?:\[[a-z]+\])?|-", token)
    alleles = []
    for u in units:
        if u == "-":
            alleles.append("?")
        else:
            m = re.match(r"([A-Za-z]+)\[([a-z\-]+)\]", u)
            if m:
                alleles.append(m.group(1) if m.group(2) == "-" else f"{m.group(1)}^{m.group(2)}")
            else:
                alleles.append(u)
    if len(alleles) == 1:
        alleles.append("?")
    return alleles[:2]


def parse(raw):
    """raw: a genotype string (may include trailing markers/flags).

    Returns {mapped8locus, rawGenotype, unmappedTokens, deaf, tags}.
    """
    raw = (raw or "").strip()
    mapped = {}
    unmapped = []
    tags = []
    deaf = None

    for tok in raw.split():
        t = tok.strip().rstrip(",")
        if not t:
            continue

        # GEN-3b: Uw/uw is an alias of the G locus — rewrite before matching.
        t = _rewrite_uw(t)

        # 8 standard loci
        matched = False
        for locus in LOCI:
            pat = _LOCUS_TOKEN.get(locus)
            if pat and pat.match(t):
                alleles = _alleles_for(locus, t)
                # #42 (E-locus e-dash): Fuchs (e) is RECESSIVE — a visible fox MUST be
                # homozygous "ee" (written "ee[-]", which already parses to [e, e]).
                # A lone recessive "e" with an unknown partner ("e-"/"e[-]" -> [e, ?])
                # is genetically impossible; mirror the frontend by REJECTING it
                # (leave it unmapped/invalid) instead of mapping a wrong colour.
                if locus == "E" and alleles in (["e", "?"], ["?", "e"]):
                    break  # invalid -> falls through to unmappedTokens below
                mapped.setdefault(locus, alleles)   # first occurrence wins
                matched = True
                break
        if matched:
            continue

        # Sls (second spotting locus); WP is its heterozygous phenotype
        sls = _sls_alleles(t)
        if sls is not None:
            mapped.setdefault("Sls", sls)
            continue

        # deafness flag (after spsp): dea/taub vs Dea/hörend
        d = _deaf_value(t)
        if d is not None:
            deaf = d
            continue

        # provenance/breeding tags
        if _TAG.match(t):
            tags.append(re.sub(r"[()\[\]]", "", t).upper())
            continue

        unmapped.append(t)

    return {
        "mapped8locus": mapped,
        "rawGenotype": raw,
        "unmappedTokens": unmapped,
        "deaf": deaf,
        "tags": tags,
    }


def looks_like_genotype(text):
    """Heuristic: does this cell text contain >=3 recognisable locus tokens?"""
    n = 0
    for tok in text.split():
        t = _rewrite_uw(tok.rstrip(","))
        if any(p.match(t) for p in _LOCUS_TOKEN.values()):
            n += 1
    return n >= 3


# ────────────────────────────────────────────────────────────────────────────
# Genotype → Farbschlag (German variety name)
#
# A faithful Python port of gerbil-manager-web/src/genetics/catalog.ts
# (`genotypeToFarbschlag` + the loci/genotype helpers it relies on). The engine
# is the single source of truth for the colour names; the IMPORT mirrors it here
# so the stored colorVarietyId can be DERIVED from a known genotype instead of a
# fragile free-text colour label (ticket cluster genetics-farbschlag).
#
# Allele symbols here use the CATALOG form (cchm / ch / ef), so we normalise the
# parser's '^'-form ('c^chm' -> 'cchm', 'e^f' -> 'ef') and treat '?' as unknown.
# Keep this in lockstep with catalog.ts — when the TS catalog changes, change here
# too (the round-trip tests in test_genotype.py guard the mapping).
# ────────────────────────────────────────────────────────────────────────────

# Alleles per locus, MOST-DOMINANT FIRST (mirror of loci.ts LOCI).
_FARB_LOCI = {
    "A": ["A", "a"],
    "C": ["C", "cchm", "ch"],
    "D": ["D", "d"],
    "E": ["E", "ef", "e"],
    "G": ["G", "g"],
    "P": ["P", "p"],
    "Sp": ["Sp", "sp"],
    "Re": ["Re", "re"],
}
_MARKER_LOCI = ("Sp", "Re", "Sls")

UNKNOWN_FARBSCHLAG = "Unbekannter Farbschlag"

# BASE_COLORS — order matters (first match wins). Mirror of catalog.ts BASE_COLORS.
# Each entry: (name, {locus: token, ...}); omitted loci are wildcards.
_BASE_COLORS = [
    # ── Frozen names (DB-key contract) ──
    ("REW", {"C": "ch", "P": "p"}),
    ("Hermelin", {"A": "a", "C": "ch", "D": "D", "P": "P"}),
    ("Himalaya", {"A": "A", "C": "ch", "D": "D", "P": "P"}),
    ("Zobel", {"A": "a", "C": "cchm", "D": "D", "E": "E", "G": "g", "P": "P"}),
    ("Rotaugenschimmel", {"C": "C", "D": "D", "E": "ef", "G": "G", "P": "p"}),
    ("Agouti", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "P"}),
    ("Schwarz", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "P"}),
    ("Silberagouti", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "g", "P": "P"}),
    ("Anthrazit", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "g", "P": "P"}),
    ("Algierfuchs", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
    ("Blau", {"A": "a", "C": "C", "D": "d", "E": "E", "G": "G", "P": "P"}),
    ("Gold", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
    ("Platin", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
    ("Goldfuchs", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
    ("Rotfuchs", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
    ("Dilute Gold", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "G", "P": "p"}),
    ("Dilute Platin", {"A": "a", "C": "C", "D": "d", "E": "E", "G": "G", "P": "p"}),
    # ── baseportal.de varieties ──
    ("Altweiss (REW)", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "g", "P": "p"}),
    ("Apricot (Blassfuchs)", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "g", "P": "p"}),
    ("Blaufuchs", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
    ("C-Separator", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "g", "P": "p"}),
    ("Elfenbein", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "g", "P": "p"}),
    ("Kohlfuchs", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
    ("Polarfuchs", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
    ("Saphir", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
    ("Orangeschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
    ("Topas", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
    ("Platin-Hell", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
    ("Dilute Agouti", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "G", "P": "P"}),
    ("Dilute Silberagouti", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "g", "P": "P"}),
    ("Dilute Kohlfuchs", {"A": "a", "C": "C", "D": "d", "E": "e", "G": "G", "P": "P"}),
    ("Dilute Anthrazit", {"A": "a", "C": "C", "D": "d", "E": "E", "G": "g", "P": "P"}),
    ("Dilute Algierfuchs", {"A": "A", "C": "C", "D": "d", "E": "e", "G": "G", "P": "P"}),
    ("Dilute Goldfuchs", {"A": "A", "C": "C", "D": "d", "E": "e", "G": "G", "P": "p"}),
    ("Dilute Rotfuchs", {"A": "a", "C": "C", "D": "d", "E": "e", "G": "G", "P": "p"}),
    ("Dilute Polarfuchs", {"A": "A", "C": "C", "D": "d", "E": "e", "G": "g", "P": "P"}),
    ("Silberschimmel", {"C": "C", "D": "D", "E": "ef", "G": "g", "P": "P"}),
    ("Polarfuchsschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "g", "P": "P"}),
    ("Algierfuchsschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
    ("Kohlfuchsschimmel", {"A": "a", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
    ("Blaufuchsschimmel", {"A": "a", "C": "C", "D": "D", "E": "ef", "G": "g", "P": "P"}),
    ("Kohlfuchs, hell", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
    ("Goldfuchs, hell", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
    ("Goldfuchsschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "p"}),
    ("Gold-Hell", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
    ("Blaufuchs, hell", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
    ("Rotfuchsschimmel", {"A": "a", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "p"}),
    ("Polarfuchs, hell", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
    ("Kohlfuchsschimmel, hell", {"A": "a", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
    ("Rotfuchs, hell", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
    ("Kohlfuchs-Hell", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
    ("Algierfuchs, hell", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
    ("Dilute Topas", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "G", "P": "p"}),
    ("Dilute Blaufuchs", {"A": "a", "C": "C", "D": "d", "E": "e", "G": "g", "P": "P"}),
    # ── c^chm colourpoint varieties ──
    ("Marder", {"A": "a", "C": "cchm", "D": "D", "E": "E", "G": "G", "P": "P"}),
    ("Siam", {"A": "a", "C": "cchm/ch", "D": "D", "E": "E", "G": "G", "P": "P"}),
    ("Zobel-Hell", {"A": "a", "C": "cchm/ch", "D": "D", "E": "E", "G": "g", "P": "P"}),
    ("CP-Agouti", {"A": "A", "C": "cchm", "D": "D", "E": "E", "G": "G", "P": "P"}),
    ("CP-Agouti-Hell", {"A": "A", "C": "cchm/ch", "D": "D", "E": "E", "G": "G", "P": "P"}),
    ("CP-Silberagouti", {"A": "A", "C": "cchm", "D": "D", "E": "E", "G": "g", "P": "P"}),
    ("CP-Silberagouti-Hell", {"A": "A", "C": "cchm/ch", "D": "D", "E": "E", "G": "g", "P": "P"}),
    ("CP-Algierfuchs", {"A": "A", "C": "cchm", "D": "D", "E": "e", "G": "G", "P": "P"}),
    ("CP-Algierfuchs-Hell", {"A": "A", "C": "cchm/ch", "D": "D", "E": "e", "G": "G", "P": "P"}),
    ("CP-Polarfuchs", {"A": "A", "C": "cchm", "D": "D", "E": "e", "G": "g", "P": "P"}),
    ("CP-Polarfuchs-Hell", {"A": "A", "C": "cchm/ch", "D": "D", "E": "e", "G": "g", "P": "P"}),
    ("CP-Fuchs", {"A": "A", "C": "cchm", "D": "d", "E": "e", "G": "G", "P": "P"}),
    ("CP-Fuchs-Hell", {"A": "A", "C": "cchm/ch", "D": "d", "E": "e", "G": "G", "P": "P"}),
    ("CP-Blaufuchs", {"A": "A", "C": "cchm", "D": "d", "E": "e", "G": "g", "P": "P"}),
    ("CP-Orangeschimmel", {"C": "cchm", "D": "D", "E": "ef", "G": "G", "P": "P"}),
    ("CP-Orangeschimmel-Hell", {"C": "cchm/ch", "D": "D", "E": "ef", "G": "G", "P": "P"}),
]


def _normalize_allele(a):
    """Parser allele form -> catalog form. 'c^chm'->'cchm', 'e^f'->'ef', '-'/None->'?'."""
    if a is None or a == "-":
        return "?"
    return a.replace("^", "")


def _resolve_allele_pair(locus, pair):
    """GEN-5 unknown-allele rule (mirror of genotype.ts resolveAllelePair).
    An unknown '?' is a COPY of the known partner; both unknown -> wild-type
    (markers default to the unmarked recessive)."""
    a = _normalize_allele(pair[0] if len(pair) > 0 else "?")
    b = _normalize_allele(pair[1] if len(pair) > 1 else "?")
    a_unknown = a == "?"
    b_unknown = b == "?"
    if not a_unknown and not b_unknown:
        return [a, b]
    if a_unknown and b_unknown:
        alleles = _FARB_LOCI.get(locus, ["?"])
        fb = alleles[-1] if locus in _MARKER_LOCI else alleles[0]
        return [fb, fb]
    known = b if a_unknown else a
    return [known, known]


def _dominance_rank(locus, allele):
    alleles = _FARB_LOCI.get(locus, [])
    return alleles.index(allele) if allele in alleles else len(alleles)


def _dominant_allele(locus, a, b):
    return a if _dominance_rank(locus, a) <= _dominance_rank(locus, b) else b


def _locus_token(mapped, locus):
    """Expressed token at a locus (mirror of catalog.ts locusToken)."""
    x, y = _resolve_allele_pair(locus, mapped.get(locus, ["?", "?"]))
    if locus == "E":
        if x == y:
            return x
        if (x == "e" and y == "ef") or (x == "ef" and y == "e"):
            return "ef"
        return _dominant_allele("E", x, y)
    return _dominant_allele(locus, x, y)


def _e_family(mapped):
    """E-locus family tag ('Fuchs'/'Fuchsschimmel'/'Schimmel') or None."""
    x, y = _resolve_allele_pair("E", mapped.get("E", ["?", "?"]))
    if x == "e" and y == "e":
        return "Fuchs"
    if (x == "e" and y == "ef") or (x == "ef" and y == "e"):
        return "Fuchsschimmel"
    if x == "ef" and y == "ef":
        return "Schimmel"
    return None


def _entry_in_e_family(name, tokens, family):
    if tokens.get("E") is None:
        return False
    n = name.lower()
    if family == "Fuchsschimmel":
        return "fuchsschimmel" in n
    if family == "Schimmel":
        return "schimmel" in n and "fuchsschimmel" not in n
    return "schimmel" not in n


def _matches(mapped, tokens):
    return all(_locus_token(mapped, locus) == tok for locus, tok in tokens.items())


def _base_colour_for(mapped):
    family = _e_family(mapped)
    if family:
        for name, tokens in _BASE_COLORS:
            if _entry_in_e_family(name, tokens, family) and _matches(mapped, tokens):
                return name
        return None
    for name, tokens in _BASE_COLORS:
        if _matches(mapped, tokens):
            return name
    return None


def _colourpoint_name(mapped):
    """C-locus colourpoint NAMING transform (mirror of catalog.ts colourpointName)."""
    c = _resolve_allele_pair("C", mapped.get("C", ["?", "?"]))
    if "C" in c:
        return None
    if c[0] == "ch" and c[1] == "ch":
        return None
    both_cchm = c[0] == "cchm" and c[1] == "cchm"
    agouti = "A" in _resolve_allele_pair("A", mapped.get("A", ["?", "?"]))
    if not agouti and _e_family(mapped) is None:
        g1, g2 = _resolve_allele_pair("G", mapped.get("G", ["?", "?"]))
        grey = g1 == "g" and g2 == "g"
        if grey:
            return "Zobel" if both_cchm else "Zobel-Hell"
        return "Marder" if both_cchm else "Siam"
    # Name the colour as if C were full, then prefix 'CP-'.
    forced = dict(mapped)
    forced["C"] = ["C", "C"]
    base = _base_colour_for(forced)
    if not base:
        return None
    DILUTE = "Dilute "
    if base.startswith(DILUTE):
        return f"{DILUTE}CP-{base[len(DILUTE):]}{'' if both_cchm else '-Hell'}"
    return f"CP-{base}{'' if both_cchm else '-Hell'}"


_CATEGORY_NAMES = {
    "Standard", "Colourpoint", "Dilute",
    "Fuchs", "Fuchsschimmel", "Schimmel",
    "Colourpoint Dilute",
}


def genotype_to_farbschlag(mapped):
    """Resolve a parsed `mapped8locus` dict to its German Farbschlag (BASE name,
    WITHOUT Schecke/Rex modifiers), or `UNKNOWN_FARBSCHLAG`.

    `mapped` is genotype.parse(...)['mapped8locus'] (allele '^'-form / '-' / missing
    loci tolerated). Modifiers (Schecke/Rex) are intentionally OMITTED — the import
    stores them as the genotype's Sp/Re loci and via the colour label, not in the
    catalog colorVarietyId. Mirror of catalog.ts genotypeToFarbschlag (minus the
    appended modifiers).
    """
    if not mapped:
        return UNKNOWN_FARBSCHLAG
    # REW check: both C reduced (no full 'C') AND pink-eyed (pp).
    c0, c1 = _resolve_allele_pair("C", mapped.get("C", ["?", "?"]))
    p0, p1 = _resolve_allele_pair("P", mapped.get("P", ["?", "?"]))
    c_reduced = lambda c: c in ("cchm", "ch")
    if c_reduced(c0) and c_reduced(c1) and p0 == "p" and p1 == "p":
        return "REW"
    base_name = _colourpoint_name(mapped) or _base_colour_for(mapped)
    if not base_name or base_name in _CATEGORY_NAMES:
        return UNKNOWN_FARBSCHLAG
    return base_name


def farbschlag_from_genotype_string(raw):
    """Convenience: raw genotype string -> Farbschlag base name (or UNKNOWN)."""
    return genotype_to_farbschlag(parse(raw).get("mapped8locus") or {})
