/**
 * Genotype representation + canonical serialisation.
 *
 * ── SERIALISATION CONTRACT (must match DATA-1 DB storage) ──────────────────
 *
 * In-memory / JSON storage shape (source of truth, lossless):
 *   Genotype = { [locus]: [alleleA, alleleB] }
 *   e.g. { "A":["A","a"], "C":["C","ch"], "D":["D","D"], "E":["E","E"],
 *          "G":["G","G"], "P":["P","p"], "Sp":["Sp","sp"], "Re":["re","re"] }
 *   - Allele pair is stored most-dominant-first (canonical order).
 *   - Allele symbols are exactly: A a | C cchm ch | D d | E ef e | G g | P p | Sp sp | Re re
 *
 * Compact display string (German-breeder convention, human-facing):
 *   "Aa CC Dd EE GG Pp Spsp rere"  (locus tokens in fixed order A C D E G P Sp Re)
 *   - Multi-char alleles concatenate as-is, parsed via maximal-munch over the
 *     known allele set, so "Cch" = [C, ch], "cchmcchm" = [cchm, cchm].
 *
 * Partially-unknown genotypes: an allele may be the wildcard "?" meaning
 * "unknown / any". Wildcards are accepted by the engine (it expands them over
 * the locus' allele set, weighted uniformly) — see punnett.ts.
 * ───────────────────────────────────────────────────────────────────────────
 */
import {
  ALLELE_SYMBOLS,
  ALLELE_TO_LOCUS,
  LOCI,
  LOCUS_ORDER,
  dominanceRank,
  type LocusKey,
} from './loci'

export const WILDCARD = '?'

export type AllelePair = readonly [string, string]
export type Genotype = Readonly<Record<LocusKey, AllelePair>>

/** Order an allele pair most-dominant-first; wildcards sort last. */
export function canonicalPair(locus: LocusKey, a: string, b: string): AllelePair {
  const rank = (x: string) => (x === WILDCARD ? Number.MAX_SAFE_INTEGER : dominanceRank(locus, x))
  return rank(a) <= rank(b) ? [a, b] : [b, a]
}

/**
 * GEN-5 — the breeder's UNKNOWN-allele rule (ticket 3e643ef1, confirmed by the
 * Züchterin): an unknown allele '?' is a COPY of the known, visible partner
 * allele until the gene is determined.  So a single-unknown pair resolves to the
 * homozygote of the KNOWN allele:
 *   A?  → AA      D?  → DD      [e,?] → ee      [E,?] → EE      [ef,?] → ef/ef
 * Only when BOTH alleles are unknown is the locus genuinely undetermined; it then
 * falls back to the wild-type reading (most-dominant colour allele, but the
 * recessive UNMARKED allele for the Sp/Re/Sls markers so an unknown marker never
 * implies Schecke/Rex/WP).
 *
 * This single rule is shared by phenotype/catalog resolution (catalog.ts) and the
 * Punnett gamete weights (punnett.ts), so an unknown allele never invents a
 * recessive phenotype (no phantom Dilute/efef/Unbekannt in offspring lists).
 */
export function resolveAllelePair(locus: LocusKey, pair: AllelePair): [string, string] {
  const [a, b] = pair
  const aUnknown = a === WILDCARD
  const bUnknown = b === WILDCARD
  if (!aUnknown && !bUnknown) return [a, b]
  if (aUnknown && bUnknown) {
    // Fully unknown: wild-type reading (markers default to the unmarked recessive).
    const alleles = LOCI[locus].alleles
    const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
    const fb = isMarker ? alleles[alleles.length - 1] : alleles[0]
    return [fb, fb]
  }
  // Exactly one unknown → copy of the known partner allele (homozygous).
  const known = aUnknown ? b : a
  return [known, known]
}

function assertAllele(locus: LocusKey, allele: string): void {
  if (allele === WILDCARD) return
  if (!LOCI[locus].alleles.includes(allele)) {
    throw new Error(`Invalid allele "${allele}" for locus ${locus}`)
  }
}

/** Build a validated, canonically-ordered Genotype from a partial map. */
export function makeGenotype(input: Record<LocusKey, AllelePair>): Genotype {
  const out = {} as Record<LocusKey, AllelePair>
  for (const locus of LOCUS_ORDER) {
    const pair = input[locus]
    if (!pair) throw new Error(`Missing locus ${locus} in genotype`)
    assertAllele(locus, pair[0])
    assertAllele(locus, pair[1])
    out[locus] = canonicalPair(locus, pair[0], pair[1])
  }
  return out
}

/** The wild-type genotype: AA CC DD EE GG PP spsp rere. */
export function wildType(): Genotype {
  const out = {} as Record<LocusKey, AllelePair>
  for (const locus of LOCUS_ORDER) {
    // Wild-type is homozygous for the most dominant allele, EXCEPT the marker
    // loci Sp/Re/Sls whose wild form is the recessive (unmarked) allele.
    const alleles = LOCI[locus].alleles
    const marker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
    const a = marker ? alleles[alleles.length - 1] : alleles[0]
    out[locus] = [a, a]
  }
  return out
}

/**
 * GEN-3h: breeder bracket-notation display symbols.
 * STORAGE symbols (ef / cchm / ch) are frozen; only the rendered form changes.
 */
const DISPLAY_SYMBOL: Readonly<Partial<Record<string, string>>> = {
  ef: 'e[f]',
  cchm: 'c[chm]',
  ch: 'c[h]',
}
function displaySymbol(allele: string): string {
  return DISPLAY_SYMBOL[allele] ?? allele
}

/**
 * GEN-3h: E-locus display order — breeder convention is E > e > e[f].
 * Storage/dominance order is E > ef > e; display swaps ef and e so that
 * a Fuchsschimmel (E=[ef,e] stored) renders as "ee[f]" not "e[f]e".
 */
const E_DISPLAY_RANK: Readonly<Record<string, number>> = { E: 0, e: 1, ef: 2 }

function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
  if (locus !== 'E') return pair
  const rank = (x: string) => E_DISPLAY_RANK[x] ?? Number.MAX_SAFE_INTEGER
  return rank(pair[0]) <= rank(pair[1]) ? pair : [pair[1], pair[0]]
}

/**
 * Compact display string, e.g. "Aa CC Dd EE GG Pp spsp".
 * The Re locus is OMITTED when wild-type (re/re) — Julian: only show Rex when a
 * Rex allele is present (Rere/ReRe). Matches the 7-locus notation used by the
 * breeder. The Sls locus is likewise omitted when wild-type (sl/sl) so legacy
 * 8-locus strings and the colour catalog stay byte-identical; it only appears for
 * WP/Sls carriers (e.g. "… spsp Slsl"). Round-trips: missing Re → re/re; missing
 * Sls → sl/sl. GEN-3c: unknown alleles stored as '?' displayed as '-' (breeder
 * convention). GEN-3h: bracket notation (e[f], c[chm], c[h]); E-locus display
 * order E > e > e[f].
 */
export function toDisplayString(g: Genotype): string {
  return LOCUS_ORDER.filter(
    (locus) =>
      !(locus === 'Re' && g.Re[0] === 're' && g.Re[1] === 're') &&
      !(locus === 'Sls' && g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
  )
    .map((locus) => {
      const [a, b] = displayPair(locus, g[locus])
      return displaySymbol(a) + displaySymbol(b)
    })
    .map((s) => s.replace(/\?/g, '-'))
    .join(' ')
}

/** Stable JSON-storable object (already the in-memory shape; returned as a copy). */
export function toJSON(g: Genotype): Record<LocusKey, [string, string]> {
  const out = {} as Record<LocusKey, [string, string]>
  for (const locus of LOCUS_ORDER) out[locus] = [g[locus][0], g[locus][1]]
  return out
}

export function fromJSON(obj: Record<string, [string, string]>): Genotype {
  return makeGenotype(obj as Record<LocusKey, AllelePair>)
}

/**
 * Split a locus token like "Cch" or "cchmcchm" or "Aa" into its two alleles
 * via maximal-munch over the known allele symbols (longest-first).
 */
function splitToken(token: string): [string, string] {
  const alleles: string[] = []
  let rest = token
  while (rest.length > 0) {
    if (rest.startsWith(WILDCARD)) {
      alleles.push(WILDCARD)
      rest = rest.slice(1)
      continue
    }
    const sym = ALLELE_SYMBOLS.find((s) => rest.startsWith(s))
    if (!sym) throw new Error(`Cannot parse allele token "${token}" at "${rest}"`)
    alleles.push(sym)
    rest = rest.slice(sym.length)
  }
  if (alleles.length !== 2) {
    throw new Error(`Token "${token}" did not resolve to exactly two alleles`)
  }
  return [alleles[0], alleles[1]]
}

/**
 * GEN-3a: tokens that are NOT genotype loci — health/provenance metadata that may
 * appear in a herd-book genotype string. Stripped on parse (see extractGenotypeFlags).
 *  - dea/Dea/taub = deafness flag (after spsp); DP/DarkPatch = non-Mendelian patch flag
 *  - WFNZ/RV/GV = provenance/breeding-method annotations
 */
const FLAG_TOKENS = new Set(['DP', 'DarkPatch', 'dea', 'Dea', 'taub', 'WFNZ', 'RV', 'GV'])

/**
 * Normalize one whitespace-token to canonical allele symbols, or null if it is a
 * non-genotype flag/metadata token (to be stripped):
 *  - Uw/uw  -> G/g          (international Underwhite == German Grey locus)
 *  - S(l)/s(l) -> Sl/sl     (second spotting locus notation)
 *  - WP     -> Slsl         (WP is the visible S(l)s(l) heterozygote)
 */
function normalizeToken(tok: string): string | null {
  if (FLAG_TOKENS.has(tok)) return null
  let t = tok
  if (t === 'WP') t = 'Slsl'
  t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
  // GEN-5: dense-underwhite modifier uw[d]/Uw[d] (G-locus). The German "[d]"
  // dense marker is a shade qualifier, not a separate allele — strip it BEFORE
  // the Uw→G alias so e.g. "Uwuw[d]" / "uw[d]uw[d]" parse as Gg / gg, not "Gg[d]"
  // (which crashes splitToken). Mirrors tools/import/genotype.py _rewrite_uw.
  t = t.replace(/uw\[d\]/gi, 'uw')
  t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
  // GEN-3h: accept bracket display notation → canonical internal symbols.
  t = t.replace(/e\[f\]/g, 'ef')     // Schimmel allele display form → internal
  t = t.replace(/c\[chm\]/g, 'cchm') // Colourpoint display form → internal
  t = t.replace(/c\[h\]/g, 'ch')     // Himalayan display form → internal
  // #42 (E-locus): a visible Fuchs is RECESSIVE → MUST be homozygous "ee". The herdbook
  // form "ee[-]" (fox allele + unknown E-type partner) therefore resolves to "ee" (Fuchs),
  // NOT [e,?] — the recessive phenotype implies homozygosity. A bare "e-"/"e[-]" (a lone
  // recessive fox with an unknown partner) is genetically impossible and is rejected below.
  t = t.replace(/ee\[-\]/g, 'ee').replace(/ee-/g, 'ee')
  // CR-1a: allele-prefixed bracket-unknown like ee[-] (Silvain).
  // When e[-] is PRECEDED by a letter it is the second unknown allele in a
  // 2-allele token (e.g. ee[-] → e + e[-] → e + ?).  Lookbehind strips only
  // the e[-] part; the leading allele stays. Standalone e[-] falls through to
  // the generic [-]→? rule below (which makes the bracket-dash a wildcard,
  // leaving the leading allele intact for splitToken).
  t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?')
  t = t.replace(/(?<=[A-Za-z])c\[-\]/g, '?')
  t = t.replace(/(?<=[A-Za-z])c$/g, '?')
  t = t.replace(/^c\[-\]$/g, '??')
  t = t.replace(/^c\?$/g, '??')
  t = t.replace(/^c$/g, '??')
  t = t.replace(/\[-\]/g, '?')       // bare/standalone bracket-unknown → wildcard
  // GEN-3c: plain dash is the breeder's UNKNOWN marker on input; store internally as '?'.
  t = t.replace(/-/g, '?')
  return t
}

/**
 * Canonical normalized genotype string (flags/metadata removed, Uw/S(l)/WP
 * resolved). Exported so the import pipeline (GEN-3b) can mirror this exactly.
 */
export function normalizeGenotypeString(input: string): string {
  return input
    .trim()
    .split(/\s+/)
    .filter(Boolean)
    .map(normalizeToken)
    .filter((t): t is string => t !== null)
    .join(' ')
}

/**
 * Extract non-Punkett flags from a raw genotype string: deafness (dea/taub =
 * deaf, Dea = hearing) and provenance/pattern tags (WFNZ/RV/GV/DP).
 */
export function extractGenotypeFlags(input: string): { deaf?: boolean; tags: string[] } {
  const tokens = input.trim().split(/\s+/).filter(Boolean)
  let deaf: boolean | undefined
  const tags: string[] = []
  for (const tok of tokens) {
    if (tok === 'dea' || tok === 'taub') deaf = true
    else if (tok === 'Dea') deaf = false
    else if (tok === 'DP' || tok === 'DarkPatch' || tok === 'WFNZ' || tok === 'RV' || tok === 'GV')
      tags.push(tok)
  }
  return { deaf, tags }
}

/**
 * Parse a display string ("Aa CC Dd EE GG Pp Spsp rere [Slsl]") back into a
 * Genotype. Tokens may be in any order; each must belong to a distinct locus.
 * Uw/S(l)/WP are normalized and flag/metadata tokens (dea, WFNZ, …) are stripped.
 * Missing loci default to wild-type.
 */
export function fromDisplayString(input: string): Genotype {
  const tokens = input.trim().split(/\s+/).filter(Boolean)
  const acc = {} as Record<LocusKey, AllelePair>
  for (const raw of tokens) {
    const token = normalizeToken(raw)
    if (token === null) continue // flag/metadata token — not a locus
    const [a, b] = splitToken(token)
    const refAllele = a === WILDCARD ? b : a
    if (refAllele === WILDCARD) {
      // Fully-unknown token ("--"/"??", e.g. a positional placeholder): the locus
      // can't be inferred — skip it (defaults to wild-type), don't throw.
      continue
    }
    const locus = ALLELE_TO_LOCUS[refAllele]
    if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`)
    if (acc[locus]) throw new Error(`Locus ${locus} given twice`)
    // #42: a lone recessive Fuchs allele with an unknown partner ("e-"/"e[-]" → [e,?]) is
    // genetically impossible — fox is recessive, so a fox allele is only visible homozygous
    // ("ee", written "ee[-]"). Reject it so the UI surfaces the invalid-genotype message
    // instead of silently mis-computing the colour. ("ee[-]" was already normalized to "ee".)
    if (locus === 'E' && ((a === 'e' && b === WILDCARD) || (a === WILDCARD && b === 'e'))) {
      throw new Error(
        `Invalid E-locus token "${token}": lone recessive "e" with unknown partner (use "ee[-]" for Fuchs or "E-" for unknown)`,
      )
    }
    acc[locus] = canonicalPair(locus, a, b)
  }
  const base = wildType()
  return makeGenotype({ ...base, ...acc })
}

export function displayGenotypeSafe(raw: string | null | undefined): string {
  if (!raw) return ''
  try {
    const parsed = fromDisplayString(raw)
    return toDisplayString(parsed)
  } catch {
    // Fallback path:
    return raw
      .trim()
      .split(/\s+/)
      .filter((tok) => {
        const norm = normalizeToken(tok)
        return norm !== null && norm !== '?' && norm !== '??'
      })
      .map((tok) => tok.replace(/\?/g, '-'))
      .join(' ')
  }
}

export function hasUnknown(g: Genotype): boolean {
  return LOCUS_ORDER.some((l) => g[l][0] === WILDCARD || g[l][1] === WILDCARD)
}

/** Per-locus note about an allele that parent-inference filled in. */
export interface ParentInferredLocus {
  readonly locus: LocusKey
  /** The allele a homozygous parent forced onto the child. */
  readonly allele: string
  /** 'father' | 'mother' — which parent was homozygous. */
  readonly from: 'father' | 'mother'
}

export interface ParentInferenceResult {
  readonly genotype: Genotype
  /** Loci whose unknown allele was resolved from a parent (empty = nothing changed). */
  readonly inferred: ParentInferredLocus[]
}

/**
 * GEN-5 (tickets cc9ea3fe / 1a508c04, breeder rule via Mendel): a child's UNKNOWN
 * allele can be filled in from a HOMOZYGOUS parent, which can only pass that one
 * allele. E.g. Mamta Mini stored E = [E,?]; her father Geely is ee (homozygous
 * fox) so he must pass an 'e' — the child's unknown E allele therefore IS 'e',
 * giving Ee (not the copy-of-known EE default).
 *
 * Rule, per locus, ONLY for an allele still unknown ('?') in the child:
 *   - if a parent is homozygous (both alleles equal and known), that allele is
 *     forced onto the child's unknown slot.
 *   - the father is checked first; if he doesn't resolve it, the mother is tried.
 *   - a parent allele is only accepted if it is one the child could legitimately
 *     carry at that locus (it always is for a real parent, but we guard anyway).
 * Pairs with no unknown, or where no parent is homozygous, are left untouched
 * (still subject to the copy-of-known display/colour rule elsewhere).
 */
export function inferUnknownsFromParents(
  child: Genotype,
  father: Genotype | null | undefined,
  mother: Genotype | null | undefined,
): ParentInferenceResult {
  const out = {} as Record<LocusKey, AllelePair>
  const inferred: ParentInferredLocus[] = []
  for (const locus of LOCUS_ORDER) {
    const [a, b] = child[locus]
    const aUnknown = a === WILDCARD
    const bUnknown = b === WILDCARD
    if (!aUnknown && !bUnknown) {
      out[locus] = child[locus]
      continue
    }
    const homForced = (p: Genotype | null | undefined): string | null => {
      if (!p) return null
      const [pa, pb] = p[locus]
      if (pa === WILDCARD || pb === WILDCARD) return null
      return pa === pb ? pa : null
    }
    const fatherAllele = homForced(father)
    const forced = fatherAllele ?? homForced(mother)
    const from: 'father' | 'mother' = fatherAllele ? 'father' : 'mother'
    if (forced && (aUnknown !== bUnknown)) {
      // Exactly one unknown slot → fill it with the forced parent allele.
      const known = aUnknown ? b : a
      out[locus] = canonicalPair(locus, known, forced)
      inferred.push({ locus, allele: forced, from })
    } else if (forced && aUnknown && bUnknown) {
      // Both unknown but a parent is homozygous → that allele is certain on one
      // slot; the other stays unknown.
      out[locus] = canonicalPair(locus, forced, WILDCARD)
      inferred.push({ locus, allele: forced, from })
    } else {
      out[locus] = child[locus]
    }
  }
  return { genotype: makeGenotype(out), inferred }
}
