// Biggest price movers (KAR-840, V11 section 4 — Tornado).
//
// Pairwise app data has two granularities, mirroring V11:
// - 'prozessschritt': one row per matched manufacturing step (fk delta from
//   qaf_manufacturing_diff) — the analytic tornado. Rows keep the caller's
//   uncertain-match flag so the chart can show the same '*' caveat as the
//   detail table for the identical underlying diffs.
// - 'vergleichsobjekt': summary QAFs WITHOUT any comparable steps fall back
//   to a single row for the whole comparison object (V11 summary branch).
//   Steps that existed but all netted to zero do NOT fall back — that would
//   mislabel the situation as "no comparable positions".
// Pure + unit-tested; the chart handles top-N filtering.

export type TopN = 5 | 12 | 20 | 'all'

export interface MoverRow {
  label: string
  delta: number
  /** Underlying step match requires review / is uncertain. */
  uncertain: boolean
}

export interface MoversInput {
  /** Step label + fk delta (null = not comparable) + uncertain-match flag. */
  rows: Array<{ stepLabel: string; delta: number | null; uncertain: boolean }>
  /** Comparison-object fallback (label + QP delta) for summary-only pairs. */
  fallback?: { label: string; delta: number | null }
  /**
   * Blattgrösse, gegen die sich die Zeilen rekonsilieren müssen (V2 R-20).
   * Ohne sie bleibt die Liste eine unbelegte Auswahl: im Anlassfall zeigten
   * drei Schritte zusammen +1,95 bei einem Blattdelta von +1,06, und nichts
   * erklärte die Differenz (Befund F-16). `delta: null` heisst „nicht
   * ermittelbar" und zählt wie keine Referenz — nie wie eine bestandene Prüfung.
   */
  reconcileTo?: { label: string; delta: number | null }
}

/** Abgleich der Zeilensumme gegen die Quelle, aus der sie stammt (R-20). */
export interface MoversReconciliation {
  /** Blattgrösse, gegen die geprüft wurde; null = keine übergeben. */
  reference: number | null
  /** reference − Summe der Zeilen; null ohne Referenz. */
  residual: number | null
  /** true bestanden, false Widerspruch, null nicht geprüft. */
  passed: boolean | null
  label: string | null
}

export interface Movers {
  kind: 'prozessschritt' | 'vergleichsobjekt'
  /** Sorted by |delta| descending; zero/null deltas dropped. */
  rows: MoverRow[]
  /** Summe aller Zeilen-Deltas — die Grösse, die sich rekonsilieren muss. */
  totalDelta: number
  reconciliation: MoversReconciliation
}

/** Toleranz der Rekonsiliation — dieselbe Grössenordnung wie die Brücken-Residuen. */
const RECONCILE_TOLERANCE = 0.005

function reconcile(rows: MoverRow[], to: MoversInput['reconcileTo']): MoversReconciliation {
  const total = rows.reduce((s, r) => s + r.delta, 0)
  if (!to || to.delta === null) {
    return { reference: null, residual: null, passed: null, label: to?.label ?? null }
  }
  const residual = to.delta - total
  return {
    reference: to.delta,
    residual,
    passed: Math.abs(residual) <= RECONCILE_TOLERANCE,
    label: to.label,
  }
}

/** Duplicate labels get a numeric suffix so tornado bars stay distinguishable. */
function dedupeLabels(rows: MoverRow[]): MoverRow[] {
  const seen = new Map<string, number>()
  return rows.map((r) => {
    const n = (seen.get(r.label) ?? 0) + 1
    seen.set(r.label, n)
    return n === 1 ? r : { ...r, label: `${r.label} (${n})` }
  })
}

export function buildMovers(input: MoversInput): Movers {
  const hadStepData = input.rows.some((r) => r.delta !== null)

  const rows: MoverRow[] = dedupeLabels(
    input.rows
      .filter((r): r is { stepLabel: string; delta: number; uncertain: boolean } => r.delta !== null && r.delta !== 0)
      .map((r) => ({ label: r.stepLabel, delta: r.delta, uncertain: r.uncertain }))
      .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)),
  )

  const withTotals = (kind: Movers['kind'], out: MoverRow[]): Movers => ({
    kind,
    rows: out,
    totalDelta: out.reduce((s, r) => s + r.delta, 0),
    reconciliation: reconcile(out, input.reconcileTo),
  })

  if (rows.length > 0 || hadStepData) return withTotals('prozessschritt', rows)

  if (input.fallback && input.fallback.delta !== null && input.fallback.delta !== 0) {
    return withTotals('vergleichsobjekt', [
      { label: input.fallback.label, delta: input.fallback.delta, uncertain: false },
    ])
  }
  return withTotals('vergleichsobjekt', [])
}

/** Was eine Top-N-Auswahl zeigt — und was sie weglässt (R-20). */
export interface MoversSelection {
  shown: MoverRow[]
  /** Gesamtzahl der Zeilen vor der Auswahl. */
  ofTotal: number
  /** Summe der gezeigten Zeilen. */
  shownSum: number
  /** Summe der weggelassenen Zeilen — zusammen ergeben sie die Gesamtsumme. */
  residualSum: number
  truncated: boolean
}

/**
 * Deklarierte Auswahl statt stillem Abschneiden. `topMovers` schnitt die Liste
 * ohne jeden Hinweis darauf, wie viel dabei unter den Tisch fiel — genau die
 * Lücke, die Befund F-16 beschreibt.
 */
export function selectMovers(rows: MoverRow[], topN: TopN): MoversSelection {
  const shown = topMovers(rows, topN)
  const shownSum = shown.reduce((s, r) => s + r.delta, 0)
  const total = rows.reduce((s, r) => s + r.delta, 0)
  return {
    shown,
    ofTotal: rows.length,
    shownSum,
    residualSum: total - shownSum,
    truncated: shown.length < rows.length,
  }
}

export function topMovers(rows: MoverRow[], topN: TopN): MoverRow[] {
  return topN === 'all' ? rows : rows.slice(0, topN)
}
