/**
 * Suite du moteur paramétrique — « Réglage collectif » (BLUEPRINT-V2.md
 * Δ2, Δ3, Δ15, Δ16). Specs verrouillées testées ici :
 * médiane BASSE élément par élément (pair ⇒ élément central inférieur,
 * step respecté par construction), sum100 avec EXACTEMENT une part dérivée,
 * clamp + renormalisation proportionnelle à la cristallisation,
 * « Pour moi » linear-share (jamais de chiffre inventé), bimodalité
 * heuristique jamais bloquante, dégénérescences 0 vote.
 */
import type { ParamSpec } from '../../app/types/domain'
import { describe, expect, it } from 'vitest'
import {
  computeMyImpact,
  crystallize,
  detectBimodality,
  medianByElement,
  resolveDerived,
  validateParamSpec,
  validateVote,
} from '../../app/engine/parametric'

// ---------------------------------------------------------------------------
// Specs de référence
// ---------------------------------------------------------------------------

/** Spec sum100 : 2 parts votées + 1 part dérivée large (bornes 0–100). */
function sum100Spec(overrides: Partial<ParamSpec> = {}): ParamSpec {
  return {
    constraint: 'sum100',
    params: [
      { key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 },
      { key: 'metal', label: 'Atelier métal', kind: 'share', min: 0, max: 100, step: 5 },
      { key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true },
    ],
    ...overrides,
  }
}

/** Spec sans contrainte : 2 curseurs bornés. */
function sliderSpec(): ParamSpec {
  return {
    constraint: 'none',
    params: [
      { key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1, baseline: 12 },
      { key: 'taux', label: 'Taux (%)', kind: 'slider', min: 0, max: 10, step: 0.5 },
    ],
  }
}

// ---------------------------------------------------------------------------
// medianByElement — médiane basse élément par élément
// ---------------------------------------------------------------------------

describe('medianByElement — médiane basse élément par élément', () => {
  it('nombre impair de votes : élément central classique', () => {
    expect(medianByElement([[10], [30], [20]])).toEqual([20])
  })

  it('nombre pair de votes : élément central INFÉRIEUR (médiane basse)', () => {
    // Colonne triée [10, 20, 30, 40] : index floor((4-1)/2) = 1 ⇒ 20, pas 25.
    expect(medianByElement([[40], [10], [30], [20]])).toEqual([20])
  })

  it('la médiane paire est la valeur votée inférieure des deux centrales', () => {
    const votes = [[15], [25], [40], [60]]
    const [median] = medianByElement(votes)
    expect(median).toBe(25) // jamais 32.5 (moyenne des centrales)
    expect(votes.flat()).toContain(median) // une position que quelqu'un a votée
  })

  it('step respecté par construction : la médiane appartient à la grille votée', () => {
    // Votes conformes au pas 5 : la médiane basse est l'un d'eux, donc conforme.
    const votes = [[15], [25], [40], [60]]
    const [median] = medianByElement(votes)
    expect((median - 0) % 5).toBe(0)
  })

  it('élément par élément : chaque colonne a sa propre médiane', () => {
    const votes = [
      [10, 90],
      [20, 80],
      [30, 70],
    ]
    expect(medianByElement(votes)).toEqual([20, 80])
  })

  it('deux votes : médiane basse = le plus petit des deux', () => {
    expect(medianByElement([[50, 10], [40, 20]])).toEqual([40, 10])
  })

  it('0 vote ⇒ [] (l’appelant gère la baseline)', () => {
    expect(medianByElement([])).toEqual([])
  })
})

// ---------------------------------------------------------------------------
// validateParamSpec
// ---------------------------------------------------------------------------

describe('validateParamSpec — validation de la spec à la création', () => {
  it('accepte une spec sum100 valide (exactement une part dérivée)', () => {
    expect(() => validateParamSpec(sum100Spec())).not.toThrow()
  })

  it('accepte une spec de curseurs sans contrainte', () => {
    expect(() => validateParamSpec(sliderSpec())).not.toThrow()
  })

  it('rejette plus de 7 paramètres', () => {
    const spec: ParamSpec = {
      constraint: 'none',
      params: Array.from({ length: 8 }, (_, i) => ({
        key: `p${i}`,
        label: `Paramètre ${i}`,
        kind: 'slider' as const,
        min: 0,
        max: 10,
        step: 1,
      })),
    }
    expect(() => validateParamSpec(spec)).toThrow(/maximum 7/)
  })

  it('rejette une spec sans aucun paramètre', () => {
    expect(() => validateParamSpec({ constraint: 'none', params: [] })).toThrow(/Au moins un paramètre/)
  })

  it('rejette un libellé métier manquant', () => {
    const spec = sliderSpec()
    spec.params[0].label = '  '
    expect(() => validateParamSpec(spec)).toThrow(/Libellé métier manquant/)
  })

  it('rejette sum100 sans part dérivée (0 derived)', () => {
    const spec = sum100Spec()
    delete spec.params[2].derived
    expect(() => validateParamSpec(spec)).toThrow(/exactement une part dérivée/)
  })

  it('rejette sum100 avec deux parts dérivées (2 derived)', () => {
    const spec = sum100Spec()
    spec.params[1].derived = true
    expect(() => validateParamSpec(spec)).toThrow(/2 trouvées/)
  })

  it('rejette un derived posé sur un curseur (kind slider)', () => {
    const spec = sum100Spec()
    spec.params[2].kind = 'slider'
    expect(() => validateParamSpec(spec)).toThrow(/part/)
  })

  it('rejette un derived sans contrainte pour le résoudre', () => {
    const spec = sliderSpec()
    spec.params[1].derived = true
    expect(() => validateParamSpec(spec)).toThrow(/sans contrainte/)
  })

  it('rejette des bornes incohérentes (min >= max)', () => {
    const spec = sliderSpec()
    spec.params[0].min = 24
    spec.params[0].max = 24
    expect(() => validateParamSpec(spec)).toThrow(/Bornes incohérentes/)
  })

  it('rejette un pas nul ou négatif (step <= 0)', () => {
    const spec = sliderSpec()
    spec.params[1].step = 0
    expect(() => validateParamSpec(spec)).toThrow(/Pas invalide/)
    spec.params[1].step = -1
    expect(() => validateParamSpec(spec)).toThrow(/Pas invalide/)
  })

  it('rejette une baseline hors bornes', () => {
    const spec = sliderSpec()
    spec.params[0].baseline = 25 // max 24
    expect(() => validateParamSpec(spec)).toThrow(/hors bornes/)
  })
})

// ---------------------------------------------------------------------------
// validateVote
// ---------------------------------------------------------------------------

describe('validateVote — validation d’un vote', () => {
  it('accepte un vote valide (ordre des params hors derived)', () => {
    expect(() => validateVote(sum100Spec(), [60, 30])).not.toThrow()
    expect(() => validateVote(sliderSpec(), [12, 2.5])).not.toThrow()
  })

  it('rejette une longueur fausse (le derived ne se vote pas)', () => {
    expect(() => validateVote(sum100Spec(), [60, 30, 10])).toThrow(/Nombre de valeurs invalide/)
    expect(() => validateVote(sum100Spec(), [60])).toThrow(/Nombre de valeurs invalide/)
  })

  it('rejette NaN et Infinity (sanitize)', () => {
    expect(() => validateVote(sum100Spec(), [Number.NaN, 30])).toThrow(/nombre fini/)
    expect(() => validateVote(sum100Spec(), [60, Number.POSITIVE_INFINITY])).toThrow(/nombre fini/)
    expect(() => validateVote(sliderSpec(), [Number.NEGATIVE_INFINITY, 2])).toThrow(/nombre fini/)
  })

  it('rejette une valeur hors bornes', () => {
    expect(() => validateVote(sliderSpec(), [0, 2])).toThrow(/hors bornes/) // min 1
    expect(() => validateVote(sliderSpec(), [12, 10.5])).toThrow(/hors bornes/) // max 10
  })

  it('rejette une valeur non alignée sur le pas', () => {
    expect(() => validateVote(sum100Spec(), [62, 30])).toThrow(/pas/) // pas de 5
    expect(() => validateVote(sliderSpec(), [12, 2.3])).toThrow(/pas/) // pas de 0.5
  })

  it('tolérance flottante : 0.1 + 0.2 est accepté sur une grille de 0.1', () => {
    const spec: ParamSpec = {
      constraint: 'none',
      params: [{ key: 't', label: 'Taux', kind: 'slider', min: 0, max: 1, step: 0.1 }],
    }
    expect(() => validateVote(spec, [0.1 + 0.2])).not.toThrow() // 0.30000000000000004
  })

  it('sum100 : rejette le vote si le derived résolu sort de ses bornes', () => {
    const spec = sum100Spec()
    spec.params[2].max = 15 // réserve bornée [0, 15]
    // 60 + 30 = 90 ⇒ derived 10 : OK ; 50 + 30 = 80 ⇒ derived 20 : rejet.
    expect(() => validateVote(spec, [60, 30])).not.toThrow()
    expect(() => validateVote(spec, [50, 30])).toThrow(/sortirait de ses bornes/)
    // 105 votés ⇒ derived −5 < min 0 : rejet aussi.
    expect(() => validateVote(spec, [70, 35])).toThrow(/sortirait de ses bornes/)
  })

  it('sum100 : le derived exactement sur sa borne est accepté', () => {
    const spec = sum100Spec()
    spec.params[2].max = 5
    expect(() => validateVote(spec, [65, 30])).not.toThrow() // derived = 5 pile
  })

  it('sum100 : un curseur hors contrainte ne compte pas dans la somme', () => {
    const spec: ParamSpec = {
      constraint: 'sum100',
      params: [
        { key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 },
        { key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 },
        { key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true },
      ],
    }
    // Le curseur vaut 24 : s'il comptait, derived = 100 − 24 − 60 = 16 ;
    // il ne compte pas ⇒ derived = 40, dans [0, 100].
    expect(() => validateVote(spec, [24, 60])).not.toThrow()
  })
})

// ---------------------------------------------------------------------------
// resolveDerived
// ---------------------------------------------------------------------------

describe('resolveDerived — résolution linéaire du derived', () => {
  it('insère le derived à sa position dans l’ordre de spec.params', () => {
    expect(resolveDerived(sum100Spec(), [60, 30])).toEqual([60, 30, 10])
  })

  it('derived en position centrale : l’ordre est préservé', () => {
    const spec: ParamSpec = {
      constraint: 'sum100',
      params: [
        { key: 'a', label: 'Part A', kind: 'share', min: 0, max: 100, step: 1 },
        { key: 'r', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true },
        { key: 'b', label: 'Part B', kind: 'share', min: 0, max: 100, step: 1 },
      ],
    }
    expect(resolveDerived(spec, [40, 35])).toEqual([40, 25, 35])
  })

  it('les curseurs hors contrainte passent tels quels', () => {
    expect(resolveDerived(sliderSpec(), [12, 2.5])).toEqual([12, 2.5])
  })

  it('sum100 avec curseur : seul les parts entrent dans le calcul du derived', () => {
    const spec: ParamSpec = {
      constraint: 'sum100',
      params: [
        { key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 },
        { key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 },
        { key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true },
      ],
    }
    expect(resolveDerived(spec, [24, 60])).toEqual([24, 60, 40])
  })

  it('rejette une longueur fausse', () => {
    expect(() => resolveDerived(sum100Spec(), [60])).toThrow(/Nombre de valeurs invalide/)
  })
})

// ---------------------------------------------------------------------------
// crystallize
// ---------------------------------------------------------------------------

describe('crystallize — médiane basse des parts votées puis résolution', () => {
  it('cas nominal : médiane basse puis derived résolu, somme 100', () => {
    const votes = [
      [50, 20],
      [60, 30],
      [70, 40],
    ]
    const out = crystallize(sum100Spec(), votes)
    expect(out).toEqual([60, 30, 10])
    expect(out[0] + out[1] + out[2]).toBe(100)
  })

  it('derived dans ses bornes : aucun clamp, valeurs médianes intactes', () => {
    const spec = sum100Spec()
    spec.params[2].max = 15
    const out = crystallize(spec, [[60, 30], [65, 30], [55, 35]])
    expect(out).toEqual([60, 30, 10]) // derived 10 ≤ 15 : rien à clamper
  })

  it('cas chiffré exact : derived clampé à 5, parts renormalisées ×95/90', () => {
    // Médiane des parts votées = {60, 30} ⇒ derived résolu 10 > max 5.
    // Spec verrouillée : clamp à 5, puis parts × (100 − 5)/(60 + 30).
    const spec = sum100Spec()
    spec.params[2].max = 5
    const votes = [
      [50, 20],
      [60, 30],
      [70, 40],
    ]
    const out = crystallize(spec, votes)
    expect(out[2]).toBe(5)
    expect(out[0]).toBeCloseTo((60 * 95) / 90, 10)
    expect(out[1]).toBeCloseTo((30 * 95) / 90, 10)
    expect(out[0] + out[1] + out[2]).toBeCloseTo(100, 10)
  })

  it('clamp à la borne min : même renormalisation proportionnelle', () => {
    // Médiane {60, 30} ⇒ derived 10 < min 20 : clamp à 20, parts × 80/90.
    const spec = sum100Spec()
    spec.params[2].min = 20
    const out = crystallize(spec, [[60, 30]])
    expect(out[2]).toBe(20)
    expect(out[0]).toBeCloseTo((60 * 80) / 90, 10)
    expect(out[1]).toBeCloseTo((30 * 80) / 90, 10)
    expect(out[0] + out[1] + out[2]).toBeCloseTo(100, 10)
  })

  it('les curseurs hors contrainte ne sont jamais renormalisés', () => {
    const spec: ParamSpec = {
      constraint: 'sum100',
      params: [
        { key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 },
        { key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 },
        { key: 'metal', label: 'Atelier métal', kind: 'share', min: 0, max: 100, step: 5 },
        { key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 5, step: 1, derived: true },
      ],
    }
    const out = crystallize(spec, [[12, 60, 30]])
    expect(out[0]).toBe(12) // le curseur passe tel quel
    expect(out[3]).toBe(5)
    expect(out[1] + out[2] + out[3]).toBeCloseTo(100, 10)
  })

  it('0 vote ⇒ vecteur des baselines (baseline ?? min)', () => {
    const spec = sum100Spec()
    spec.params[0].baseline = 50
    spec.params[2].baseline = 20
    // params[1] sans baseline ⇒ min (0).
    expect(crystallize(spec, [])).toEqual([50, 0, 20])
  })

  it('0 vote sans aucune baseline ⇒ vecteur des min', () => {
    expect(crystallize(sliderSpec(), [])).toEqual([12, 0]) // duree a baseline 12, taux min 0
  })

  it('sans contrainte : médiane basse simple, aucun derived', () => {
    const out = crystallize(sliderSpec(), [[6, 2], [12, 4], [18, 3], [24, 5]])
    expect(out).toEqual([12, 3]) // médiane basse de chaque colonne
  })
})

// ---------------------------------------------------------------------------
// computeMyImpact
// ---------------------------------------------------------------------------

describe('computeMyImpact — carte « Pour moi » (linear-share)', () => {
  function impactSpec(): ParamSpec {
    const spec = sum100Spec()
    spec.impactAttrKey = 'heures/mois'
    return spec
  }

  it('cas nominal chiffré : quote-parts au prorata de l’attribut déclaré', () => {
    // 1 200 € ; valeurs [50, 30, 20] ; moi 10 h, corpus [10, 20, 10] ⇒ Σ 40.
    // montants : 600, 360, 240 ; ma quote-part = montant × 10/40.
    const result = computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [10, 20, 10])
    expect(result).not.toBeNull()
    expect(result!.perParam).toEqual([
      { key: 'bois', label: 'Atelier bois', amount: 150 },
      { key: 'metal', label: 'Atelier métal', amount: 90 },
      { key: 'reserve', label: 'Réserve (calculé)', amount: 60 },
    ])
    expect(result!.total).toBeCloseTo(300, 10)
  })

  it('le derived est inclus dans les quote-parts (chaque part compte)', () => {
    const result = computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [10, 20, 10])
    expect(result!.perParam.map(l => l.key)).toContain('reserve')
  })

  it('null si mon attribut est absent — jamais de chiffre inventé', () => {
    expect(computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], undefined, [10, 20])).toBeNull()
  })

  it('null si Σ des attributs du corpus vaut 0', () => {
    expect(computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [0, 0, 0])).toBeNull()
    expect(computeMyImpact(impactSpec(), { amount: 1200 }, [50, 30, 20], 10, [])).toBeNull()
  })

  it('null hors sum100, sans montant ou sans impactAttrKey', () => {
    const noConstraint = sliderSpec()
    noConstraint.impactAttrKey = 'heures/mois'
    expect(computeMyImpact(noConstraint, { amount: 1200 }, [12, 2.5], 10, [10, 20])).toBeNull()

    expect(computeMyImpact(impactSpec(), {}, [50, 30, 20], 10, [10, 20])).toBeNull()

    const noKey = sum100Spec() // pas d'impactAttrKey
    expect(computeMyImpact(noKey, { amount: 1200 }, [50, 30, 20], 10, [10, 20])).toBeNull()
  })

  it('les curseurs hors contrainte n’ont pas de quote-part', () => {
    const spec: ParamSpec = {
      constraint: 'sum100',
      impactAttrKey: 'heures/mois',
      params: [
        { key: 'duree', label: 'Durée (mois)', kind: 'slider', min: 1, max: 24, step: 1 },
        { key: 'bois', label: 'Atelier bois', kind: 'share', min: 0, max: 100, step: 5 },
        { key: 'reserve', label: 'Réserve (calculé)', kind: 'share', min: 0, max: 100, step: 1, derived: true },
      ],
    }
    const result = computeMyImpact(spec, { amount: 1000 }, [12, 60, 40], 5, [5, 5])
    expect(result!.perParam.map(l => l.key)).toEqual(['bois', 'reserve'])
    expect(result!.total).toBeCloseTo(500, 10) // (600 + 400) × 5/10
  })
})

// ---------------------------------------------------------------------------
// detectBimodality
// ---------------------------------------------------------------------------

describe('detectBimodality — heuristique jamais bloquante', () => {
  it('bimodal net : deux paquets séparés par un grand écart ⇒ vrai', () => {
    // Triées : [10, 11, 12, 60, 61, 62] — étendue 52, gap max 48 > 40 %.
    expect(detectBimodality([10, 60, 11, 61, 12, 62])).toBe(true)
  })

  it('distribution uniforme ⇒ faux', () => {
    // Gaps réguliers de 10 sur une étendue de 40 : 10 < 16.
    expect(detectBimodality([10, 20, 30, 40, 50])).toBe(false)
  })

  it('moins de 4 valeurs ⇒ toujours faux', () => {
    expect(detectBimodality([0, 100])).toBe(false)
    expect(detectBimodality([0, 50, 100])).toBe(false)
    expect(detectBimodality([])).toBe(false)
  })

  it('un simple outlier (1 seule valeur d’un côté du gap) ⇒ faux', () => {
    // Gap max 50 entre 0 et 50, mais une seule valeur à gauche.
    expect(detectBimodality([0, 50, 51, 52, 53])).toBe(false)
  })

  it('valeurs toutes identiques (étendue nulle) ⇒ faux', () => {
    expect(detectBimodality([5, 5, 5, 5])).toBe(false)
  })

  it('deux paires exactement de part et d’autre du gap ⇒ vrai (cas limite n=4)', () => {
    expect(detectBimodality([0, 1, 99, 100])).toBe(true)
  })

  it('jamais bloquante : les valeurs non finies sont ignorées, pas d’exception', () => {
    expect(() => detectBimodality([Number.NaN, Number.POSITIVE_INFINITY, 1, 2])).not.toThrow()
    expect(detectBimodality([Number.NaN, 0, 1, 99, 100])).toBe(true)
  })
})
