/**
 * Parametric decision engine — « Réglage collectif » (collective tuning).
 *
 * Pure functions, no I/O — the single implementation (BLUEPRINT-V2.md Δ2, Δ3,
 * Δ15, Δ16). Everything here is math over ParamSpec + vote vectors; the human
 * gesture (crystallization) lives in state.ts/UI, never here.
 *
 * LOCKED SPECS (blueprint repairs):
 * - LOW median, element by element: for an even vote count, take the LOWER
 *   central element (index floor((n-1)/2) after ascending sort). Invariant:
 *   every median value is a value someone actually voted, so the step is
 *   honored BY CONSTRUCTION — « une position que chacun aurait pu proposer ».
 * - constraint 'sum100' requires EXACTLY ONE 'share' param with derived:true
 *   (the absorption variable), resolved linearly: 100 − Σ other shares.
 *   'slider' params live outside the constraint and pass through untouched.
 * - At vote time the resolved derived must stay within its bounds, otherwise
 *   the vote is rejected (validateVote).
 * - At crystallization the derived is never aggregated: it is resolved from
 *   the median of the voted shares; if it exits its bounds ⇒ clamp to the
 *   violated bound + PROPORTIONAL renormalization of the non-derived shares
 *   (each multiplied by (100 − clampedDerived) / Σ median shares) so the
 *   sum-100 invariant is restored. The renormalized shares may leave the
 *   step grid — accepted and documented: this is the one specified exception.
 * - Degenerate cases: 0 votes ⇒ baseline vector, never an empty screen.
 * - computeMyImpact 'linear-share' NEVER invents a number: missing attribute
 *   or empty declaring corpus ⇒ null.
 * - detectBimodality is a simple documented heuristic, NEVER blocking:
 *   it returns false on any degenerate input instead of throwing.
 *
 * Code and comments in English; thrown error messages in French (UI-facing).
 */

import type { ParamDef, ParamSpec } from '../types/domain'

/** Blueprint limit: a small manipulable space (SejeteralO lesson). */
const MAX_PARAMS = 7

/**
 * Absolute tolerance for floating-point comparisons (bounds and step grid).
 * Vote values are human-scale (percent shares, bounded sliders), so an
 * absolute epsilon is safe: 0.1 + 0.2 must be accepted as a 0.3 step value.
 */
const FLOAT_EPS = 1e-6

/** Params that are actually voted, in spec order (derived excluded). */
function votableParams(spec: ParamSpec): ParamDef[] {
  return spec.params.filter(p => p.derived !== true)
}

/**
 * Guarded indexed access (project compiles with noUncheckedIndexedAccess).
 * Every call site is protected by a prior length check or loop bound —
 * this throw is an internal-invariant guard, not a reachable user error.
 */
function at(arr: number[], i: number): number {
  const v = arr[i]
  if (v === undefined) {
    throw new Error('Incohérence interne : index hors du vecteur.')
  }
  return v
}

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

/**
 * Validate a ParamSpec at creation time. Throws (French message) when:
 * - no param, or more than 7 params;
 * - a business label is missing (never raw a, b, c);
 * - bounds are inconsistent (min >= max) or step is not strictly positive;
 * - a baseline lies outside its own bounds;
 * - constraint 'sum100' does not have EXACTLY ONE 'share' param with
 *   derived:true (0 or 2+ derived, or derived on a 'slider');
 * - a derived param exists without a constraint able to resolve it.
 */
export function validateParamSpec(spec: ParamSpec): void {
  if (spec.params.length === 0) {
    throw new Error('Au moins un paramètre est requis.')
  }
  if (spec.params.length > MAX_PARAMS) {
    throw new Error(
      `Trop de paramètres : ${spec.params.length} (maximum ${MAX_PARAMS}).`,
    )
  }

  for (const p of spec.params) {
    if (typeof p.label !== 'string' || p.label.trim() === '') {
      throw new Error(`Libellé métier manquant pour le paramètre « ${p.key} ».`)
    }
    if (!(p.min < p.max)) {
      throw new Error(
        `Bornes incohérentes pour « ${p.key} » : min (${p.min}) doit être strictement inférieur à max (${p.max}).`,
      )
    }
    if (!(p.step > 0)) {
      throw new Error(
        `Pas invalide pour « ${p.key} » : ${p.step} (doit être strictement positif).`,
      )
    }
    if (p.baseline !== undefined && (p.baseline < p.min || p.baseline > p.max)) {
      throw new Error(
        `Statu quo hors bornes pour « ${p.key} » : ${p.baseline} (bornes ${p.min}–${p.max}).`,
      )
    }
  }

  const derived = spec.params.filter(p => p.derived === true)

  if (spec.constraint === 'sum100') {
    if (derived.some(p => p.kind !== 'share')) {
      throw new Error(
        'Un paramètre dérivé doit être une part (kind « share »), pas un curseur.',
      )
    }
    if (derived.length === 0) {
      throw new Error(
        'Contrainte sum100 : exactement une part dérivée est requise (aucune trouvée).',
      )
    }
    if (derived.length > 1) {
      throw new Error(
        `Contrainte sum100 : exactement une part dérivée est requise (${derived.length} trouvées).`,
      )
    }
  } else if (derived.length > 0) {
    throw new Error(
      'Paramètre dérivé sans contrainte : rien ne permet de le résoudre.',
    )
  }
}

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

/**
 * Validate one vote vector against the spec.
 * `values` follows the order of spec.params EXCLUDING derived params
 * (Vote.values contract). Throws (French message) when:
 * - the vector length does not match the number of votable params;
 * - a value is not a finite number (NaN / ±Infinity sanitization);
 * - a value is out of bounds or off the step grid (FLOAT_EPS tolerance);
 * - constraint 'sum100': the resolved derived (100 − Σ voted shares) would
 *   exit its own [min, max] bounds ⇒ the vote is rejected.
 */
export function validateVote(spec: ParamSpec, values: number[]): void {
  const votable = votableParams(spec)

  if (values.length !== votable.length) {
    throw new Error(
      `Nombre de valeurs invalide : ${values.length} reçues, ${votable.length} attendues.`,
    )
  }

  votable.forEach((p, i) => {
    const v = values[i]
    if (typeof v !== 'number' || !Number.isFinite(v)) {
      throw new Error(
        `Valeur invalide pour « ${p.label} » : un nombre fini est attendu.`,
      )
    }
    if (v < p.min - FLOAT_EPS || v > p.max + FLOAT_EPS) {
      throw new Error(
        `Valeur hors bornes pour « ${p.label} » : ${v} (bornes ${p.min}–${p.max}).`,
      )
    }
    // Step grid: v must equal min + k×step for an integer k (float tolerance).
    const k = Math.round((v - p.min) / p.step)
    if (Math.abs(p.min + k * p.step - v) > FLOAT_EPS) {
      throw new Error(
        `Valeur non alignée sur le pas pour « ${p.label} » : ${v} (pas de ${p.step} depuis ${p.min}).`,
      )
    }
  })

  if (spec.constraint === 'sum100') {
    const derivedParam = spec.params.find(p => p.derived === true)
    if (derivedParam) {
      let shareSum = 0
      votable.forEach((p, i) => {
        if (p.kind === 'share') shareSum += at(values, i)
      })
      const resolved = 100 - shareSum
      if (
        resolved < derivedParam.min - FLOAT_EPS
        || resolved > derivedParam.max + FLOAT_EPS
      ) {
        throw new Error(
          `La part calculée « ${derivedParam.label} » sortirait de ses bornes : ${resolved} (bornes ${derivedParam.min}–${derivedParam.max}).`,
        )
      }
    }
  }
}

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

/**
 * Expand a votable vector into the COMPLETE vector in spec.params order.
 * The derived share (sum100) is resolved linearly: 100 − Σ other shares.
 * 'slider' params live outside the constraint and pass through untouched.
 * Throws on a length mismatch (misuse guard — same message as validateVote).
 */
export function resolveDerived(spec: ParamSpec, values: number[]): number[] {
  const votable = votableParams(spec)

  if (values.length !== votable.length) {
    throw new Error(
      `Nombre de valeurs invalide : ${values.length} reçues, ${votable.length} attendues.`,
    )
  }

  let shareSum = 0
  votable.forEach((p, i) => {
    if (p.kind === 'share') shareSum += at(values, i)
  })

  let cursor = 0
  return spec.params.map(p => (p.derived === true ? 100 - shareSum : at(values, cursor++)))
}

// ---------------------------------------------------------------------------
// medianByElement
// ---------------------------------------------------------------------------

/**
 * LOW median, element by element.
 * Each column is sorted ascending and the element at index floor((n-1)/2) is
 * taken — for an even n this is the LOWER of the two central elements.
 * Invariant: every median value is a value actually voted by someone, so the
 * step grid is honored by construction (« une position que chacun aurait pu
 * proposer »).
 * 0 votes ⇒ [] (the caller falls back to the baseline vector).
 */
export function medianByElement(votesValues: number[][]): number[] {
  const n = votesValues.length
  if (n === 0) return []

  const width = votesValues[0]?.length ?? 0
  const lowMedianIndex = Math.floor((n - 1) / 2)
  const medians: number[] = []

  for (let j = 0; j < width; j++) {
    const column = votesValues.map(v => at(v, j)).sort((a, b) => a - b)
    medians.push(at(column, lowMedianIndex))
  }
  return medians
}

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

/**
 * Compute the crystallized position: LOW median of the VOTED vectors, then
 * derived resolution. Returns the complete vector in spec.params order.
 *
 * sum100 repair (locked spec): the derived is never aggregated — it is
 * resolved from the median of the voted shares. If it exits its bounds:
 * - clamp it to the violated bound;
 * - renormalize the non-derived shares PROPORTIONALLY to restore sum 100:
 *   each share is multiplied by (100 − clampedDerived) / Σ median shares.
 *   (Renormalized shares may leave the step grid — accepted, documented.)
 * - degenerate sub-case Σ median shares = 0: proportionality is undefined,
 *   the remainder (100 − clampedDerived) is spread equally instead.
 * 'slider' params are outside the constraint and are never renormalized.
 *
 * 0 votes ⇒ the baseline vector (spec.params[i].baseline ?? min) — never an
 * empty screen; the crystallization GESTURE itself stays human (Δ3).
 */
export function crystallize(spec: ParamSpec, votesValues: number[][]): number[] {
  if (votesValues.length === 0) {
    return spec.params.map(p => p.baseline ?? p.min)
  }

  const median = medianByElement(votesValues)
  const full = resolveDerived(spec, median)

  if (spec.constraint !== 'sum100') return full

  const derivedIndex = spec.params.findIndex(p => p.derived === true)
  const derivedParam = spec.params[derivedIndex]
  if (derivedIndex === -1 || derivedParam === undefined) {
    return full // unreachable on a validated spec
  }
  const resolved = at(full, derivedIndex)

  const withinBounds
    = resolved >= derivedParam.min - FLOAT_EPS
      && resolved <= derivedParam.max + FLOAT_EPS
  if (withinBounds) return full

  // Clamp to the violated bound, then restore the sum-100 invariant.
  const clamped = Math.min(Math.max(resolved, derivedParam.min), derivedParam.max)
  const remainder = 100 - clamped

  let shareSum = 0
  let shareCount = 0
  spec.params.forEach((p, i) => {
    if (p.derived !== true && p.kind === 'share') {
      shareSum += at(full, i)
      shareCount++
    }
  })

  return full.map((v, i) => {
    if (i === derivedIndex) return clamped
    const p = spec.params[i]
    if (p === undefined || p.kind !== 'share') return v // sliders pass through untouched
    if (shareSum === 0) return remainder / shareCount // degenerate: equal spread
    return v * (remainder / shareSum) // proportional renormalization
  })
}

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

/** One line of the « Pour moi » card: my quota for one share param. */
export interface ImpactLine {
  key: string
  label: string
  amount: number
}

/** « Pour moi » card content — per share param + total. */
export interface MyImpact {
  perParam: ImpactLine[]
  total: number
}

/**
 * 'linear-share' personal impact (Δ15 — « Pour moi » card).
 * Applies ONLY when constraint is 'sum100' AND resources.amount is set AND
 * spec.impactAttrKey is set. For EACH share param p (derived included):
 *   amount(p)  = resources.amount × value(p) / 100
 *   myQuota(p) = amount(p) × myAttr / Σ corpusAttrs
 * `fullValues` is the COMPLETE vector in spec.params order (resolveDerived /
 * crystallize output). 'slider' params are outside the constraint: no line.
 *
 * Returns null — NEVER an invented number — when myAttr is undefined,
 * when Σ corpusAttrs is 0 (nobody declared), or on a malformed input.
 */
export function computeMyImpact(
  spec: ParamSpec,
  resources: { amount?: number },
  fullValues: number[],
  myAttr: number | undefined,
  corpusAttrs: number[],
): MyImpact | null {
  if (spec.constraint !== 'sum100') return null
  if (!resources.amount || !Number.isFinite(resources.amount)) return null
  if (!spec.impactAttrKey) return null
  if (myAttr === undefined || !Number.isFinite(myAttr)) return null
  if (fullValues.length !== spec.params.length) return null // misuse guard

  const attrSum = corpusAttrs.reduce(
    (sum, a) => sum + (Number.isFinite(a) ? a : 0),
    0,
  )
  if (attrSum === 0) return null

  const amount = resources.amount
  const perParam: ImpactLine[] = []
  let total = 0

  spec.params.forEach((p, i) => {
    if (p.kind !== 'share') return
    const paramAmount = (amount * at(fullValues, i)) / 100
    const myQuota = (paramAmount * myAttr) / attrSum
    perParam.push({ key: p.key, label: p.label, amount: myQuota })
    total += myQuota
  })

  return { perParam, total }
}

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

/**
 * Simple documented heuristic over ONE param's voted values — NEVER blocking
 * (it only feeds the non-blocking banner and the crystallization reminder,
 * Δ16): two distinct positions are detected when, after ascending sort,
 * the LARGEST gap between consecutive values satisfies ALL of:
 * - n >= 4 (below that, no distribution to speak of);
 * - gap > 40% of the total range (max − min);
 * - at least 2 values on EACH side of the gap (a single outlier is not a
 *   second position).
 * Non-finite values are ignored; any degenerate input returns false.
 */
export function detectBimodality(values: number[]): boolean {
  const sorted = values.filter(v => Number.isFinite(v)).sort((a, b) => a - b)
  const n = sorted.length
  if (n < 4) return false

  const range = at(sorted, n - 1) - at(sorted, 0)
  if (range <= 0) return false

  let maxGap = 0
  for (let i = 0; i < n - 1; i++) {
    const gap = at(sorted, i + 1) - at(sorted, i)
    if (gap > maxGap) maxGap = gap
  }
  if (maxGap <= 0.4 * range) return false

  // The max gap must split the values 2+ / 2+ (ties: any qualifying position).
  for (let i = 1; i <= n - 3; i++) {
    if (at(sorted, i + 1) - at(sorted, i) === maxGap) return true
  }
  return false
}
