/**
 * 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]
}

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 rere".
 * The Sls locus is 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 rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
 * GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
 * convention) — e.g. ['C','?'] renders "C-".
 * GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]);
 * E-locus display order is E > e > e[f] (e before e[f] in het pairs).
 */
export function toDisplayString(g: Genotype): string {
  return LOCUS_ORDER.filter(
    (locus) => 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')
  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
  // 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(/\[-\]/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`)
    acc[locus] = canonicalPair(locus, a, b)
  }
  const base = wildType()
  return makeGenotype({ ...base, ...acc })
}

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