import { describe, expect, it } from 'vitest'
import {
  add,
  divide,
  frac,
  multiply,
  toNumber,
  toString,
  equals,
} from '../fraction'
import {
  fromDisplayString,
  makeGenotype,
  toDisplayString,
  toJSON,
  fromJSON,
  wildType,
} from '../genotype'
import { combineLocus } from '../punnett'
import { LOCI, type LocusKey } from '../loci'
import {
  farbschlagFor,
  genotypeToFarbschlag,
  representativeGenotype,
  BASE_COLORS,
  CATALOG,
  CATALOG_SIZE,
} from '../catalog'

/** The first 18 entries are the frozen contract names (must round-trip exactly). */
const FROZEN_COUNT = 18
import { breed } from '../breed'
import { GeneticsWarningCode } from '../warnings'

describe('Fraction', () => {
  it('reduces and does exact arithmetic', () => {
    expect(toString(frac(2, 4))).toBe('1/2')
    expect(toString(add(frac(1, 4), frac(1, 4)))).toBe('1/2')
    expect(toString(multiply(frac(1, 2), frac(1, 2)))).toBe('1/4')
    expect(toString(divide(frac(1, 4), frac(3, 4)))).toBe('1/3')
    expect(equals(frac(3, 6), frac(1, 2))).toBe(true)
  })
})

describe('Genotype serialization', () => {
  it('round-trips display string <-> structured form', () => {
    const g = fromDisplayString('Aa CC Dd EE GG Pp Spsp rere')
    expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp rere')
    expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp rere')
  })

  it('parses multi-char C-series alleles via maximal munch', () => {
    const g = fromDisplayString('aa cchmch DD EE GG PP spsp rere')
    expect(g.C).toEqual(['cchm', 'ch'])
  })

  it('orders allele pairs most-dominant-first', () => {
    const g = makeGenotype({
      A: ['a', 'A'],
      C: ['ch', 'C'],
      D: ['D', 'D'],
      E: ['E', 'E'],
      G: ['G', 'G'],
      P: ['p', 'P'],
      Sp: ['sp', 'sp'],
      Re: ['re', 're'],
    })
    expect(g.A).toEqual(['A', 'a'])
    expect(g.C).toEqual(['C', 'ch'])
    expect(g.P).toEqual(['P', 'p'])
  })

  it('wild type is AA CC DD EE GG PP spsp rere', () => {
    expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
  })
})

describe('Punnett single locus', () => {
  it('Aa x Aa -> 1/4 AA, 1/2 Aa, 1/4 aa', () => {
    const dist = combineLocus('A', ['A', 'a'], ['A', 'a'])
    expect(toString(dist.get('AA')!.probability)).toBe('1/4')
    expect(toString(dist.get('Aa')!.probability)).toBe('1/2')
    expect(toString(dist.get('aa')!.probability)).toBe('1/4')
  })

  it('AA x aa -> all Aa', () => {
    const dist = combineLocus('A', ['A', 'A'], ['a', 'a'])
    expect(dist.size).toBe(1)
    expect(toString(dist.get('Aa')!.probability)).toBe('1')
  })
})

describe('Worked example from research report', () => {
  // Agouti AA CC DD EE GG PP  x  Platin aa CC DD EE GG pp  ->  all Aa Cc Dd Ee Gg Pp
  // (every locus where parents differ becomes heterozygous with prob 1)
  it('Agouti x Platin yields a single uniform heterozygous genotype', () => {
    const father = fromDisplayString('AA CC DD EE GG PP spsp rere')
    const mother = fromDisplayString('aa CC DD EE GG pp spsp rere')
    const result = breed(father, mother)

    expect(result.offspring).toHaveLength(1)
    const only = result.offspring[0]
    expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp rere')
    expect(only.probability.text).toBe('1')
    expect(result.warnings).toHaveLength(0)
  })
})

describe('Schecke × Schecke lethality (SpSp)', () => {
  it('removes SpSp, renormalises, and warns ~25% fewer young', () => {
    // Spsp x Spsp at the Sp locus -> 1/4 SpSp (lethal), 1/2 Spsp, 1/4 spsp.
    // After removing SpSp the survivors renormalise to 2/3 Spsp : 1/3 spsp.
    const father = fromDisplayString('AA CC DD EE GG PP Spsp rere')
    const mother = fromDisplayString('AA CC DD EE GG PP Spsp rere')
    const result = breed(father, mother)

    // No surviving genotype is SpSp.
    expect(result.offspring.every((o) => !o.genotype.includes('SpSp'))).toBe(true)

    // Probabilities renormalise to sum to exactly 1.
    const sum = result.offspring.reduce((acc, o) => acc + o.probability.value, 0)
    expect(sum).toBeCloseTo(1, 10)

    // Survivors: 2/3 checkered (Spsp), 1/3 non-checkered (spsp).
    const spsp = result.offspring.find((o) => o.genotype.includes('Spsp'))!
    const homRec = result.offspring.find((o) => o.genotype.includes('spsp'))!
    expect(spsp.probability.text).toBe('2/3')
    expect(homRec.probability.text).toBe('1/3')

    // Warning emitted, carrying the 1/4 young-lost stat.
    const warn = result.warnings.find((w) => w.code === GeneticsWarningCode.ScheckeLethal)
    expect(warn).toBeDefined()
    expect(warn!.detail?.youngLostFraction).toBe('1/4')
  })
})

describe('Rex × Rex semi-lethality (ReRe)', () => {
  it('keeps ReRe but emits a semi-lethal warning', () => {
    const father = fromDisplayString('AA CC DD EE GG PP spsp Rere')
    const mother = fromDisplayString('AA CC DD EE GG PP spsp Rere')
    const result = breed(father, mother)

    // ReRe survives (semi-lethal, not removed).
    expect(result.offspring.some((o) => o.genotype.includes('ReRe'))).toBe(true)
    const sum = result.offspring.reduce((acc, o) => acc + o.probability.value, 0)
    expect(sum).toBeCloseTo(1, 10)

    const warn = result.warnings.find((w) => w.code === GeneticsWarningCode.RexSemiLethal)
    expect(warn).toBeDefined()
  })
})

describe('Farbschlag catalog', () => {
  it('names the wild type Agouti', () => {
    expect(farbschlagFor(wildType()).name).toBe('Agouti')
  })

  it('names Platin (aa CC DD EE GG pp)', () => {
    expect(farbschlagFor(fromDisplayString('aa CC DD EE GG pp spsp rere')).name).toBe('Platin')
  })

  it('names Schwarz (aa CC DD EE GG PP)', () => {
    expect(farbschlagFor(fromDisplayString('aa CC DD EE GG PP spsp rere')).name).toBe('Schwarz')
  })

  it('appends Schecke and Rex modifiers', () => {
    expect(farbschlagFor(fromDisplayString('AA CC DD EE GG PP Spsp rere')).name).toBe('Agouti Schecke')
    expect(farbschlagFor(fromDisplayString('AA CC DD EE GG PP Spsp Rere')).name).toBe(
      'Agouti Schecke Rex',
    )
  })

  it('falls back to Unbekannter Farbschlag for uncatalogued genotypes', () => {
    // Colourpoint marked + dilute + grey combo not in the catalog.
    const match = farbschlagFor(fromDisplayString('aa cchmcchm dd ee gg PP spsp rere'))
    expect(match.unknown).toBe(true)
    expect(match.name).toBe('Unbekannter Farbschlag')
  })

  it('has the expected catalogue coverage (GEN-2: frozen 18 + 55 portal varieties)', () => {
    expect(CATALOG_SIZE).toBe(73)
  })

  it('frozen contract names round-trip to themselves (DB-key guard)', () => {
    // The first 18 are the frozen ColorVariety keys — their representative
    // genotype MUST resolve back to their own name, never a later variety.
    for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) {
      expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name)
    }
  })

  it('every entry resolves to a named variety (no self-Unbekannt)', () => {
    // Each catalogued variety's representative genotype must resolve to SOME
    // named variety — never "Unbekannt". Genotype-identical synonyms (e.g.
    // "-hell") or entries that omit a locus may resolve to an earlier primary;
    // that's expected first-match behaviour, not an error.
    for (const entry of BASE_COLORS) {
      expect(farbschlagFor(representativeGenotype(entry)).unknown).toBe(false)
    }
  })

  it('has no duplicate variety names and only valid allele tokens', () => {
    const names = BASE_COLORS.map((e) => e.name)
    expect(new Set(names).size).toBe(names.length)
    for (const entry of BASE_COLORS) {
      for (const [locus, value] of Object.entries(entry.tokens)) {
        expect(LOCI[locus as LocusKey].alleles).toContain(value)
      }
    }
  })

  it('CATALOG seed view mirrors the ColorVariety table shape', () => {
    expect(CATALOG).toHaveLength(CATALOG_SIZE)
    expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 })
    // Every row has a non-empty canonical genotype display string and unique name.
    expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
    expect(CATALOG.every((c) => /^[A-Za-z?]+( [A-Za-z?]+){7}$/.test(c.canonicalGenotype))).toBe(true)
  })

  it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
    expect(genotypeToFarbschlag(wildType())).toBe('Agouti')
    expect(genotypeToFarbschlag(fromDisplayString('aa CC DD EE GG pp spsp rere'))).toBe('Platin')
    expect(genotypeToFarbschlag(fromDisplayString('aa cchmcchm dd ee gg PP spsp rere'))).toBe(
      'Unbekannter Farbschlag',
    )
  })
})

describe('Partially-unknown parents (wildcards)', () => {
  it('handles an A? parent (phenotype agouti, genotype unknown)', () => {
    // A? x aa at the A locus -> father gamete: 1/2 A, 1/4 (each of A,a) from "?"
    //   = effectively 3/4 A, 1/4 a ; mother always a.
    // Offspring: 3/4 Aa, 1/4 aa.
    const father = makeGenotype({
      A: ['A', '?'],
      C: ['C', 'C'],
      D: ['D', 'D'],
      E: ['E', 'E'],
      G: ['G', 'G'],
      P: ['P', 'P'],
      Sp: ['sp', 'sp'],
      Re: ['re', 're'],
    })
    const mother = fromDisplayString('aa CC DD EE GG PP spsp rere')
    const result = breed(father, mother)

    const agouti = result.byFarbschlag.find((f) => f.farbschlag === 'Agouti')!
    const schwarz = result.byFarbschlag.find((f) => f.farbschlag === 'Schwarz')!
    expect(agouti.probability.text).toBe('3/4')
    expect(schwarz.probability.text).toBe('1/4')
  })
})

describe('Probabilities are exact fractions summing to 1', () => {
  it('Aa Cc x Aa Cc -> 9 genotypes summing to 1', () => {
    const father = fromDisplayString('Aa Cch DD EE GG PP spsp rere')
    const mother = fromDisplayString('Aa Cch DD EE GG PP spsp rere')
    const result = breed(father, mother)
    const sum = result.offspring.reduce(
      (acc, o) => add(acc, o.probability.fraction),
      frac(0, 1),
    )
    expect(toNumber(sum)).toBeCloseTo(1, 10)
  })
})
