// Formel-Extraktion, -Normalisierung und -Vergleich (KAR-900 / Master-Prompt
// §12.4 / Backlog P2.1).
//
// Bis zu diesem Modul wurde im gesamten lib/qaf-differences-Baum ausschließlich
// der berechnete Zellwert gelesen (cell.value.result) — nie die Formel selbst.
// Ein Formel-Wechsel mit zufällig identischem Ergebnis blieb dadurch komplett
// unsichtbar (genau das Risiko, das Master-Prompt §12.4 als "hidden formula
// change must be reported even when the displayed value is unchanged" benennt).
//
// Scope (bewusst eng, siehe Backlog-Item P2.1 "NICHT rein"): kein vollständiger
// Excel-Formel-Parser, keine Formel-Ausführungs-Simulation/Neu-Berechnung, kein
// generisches DE/EN-Mapping für alle >400 Excel-Funktionen — nur ein
// struktureller Vergleich (gleiche Formel? geänderte Referenzen? geänderte
// Konstanten?) über die Teilmenge an Funktionen, die in QAF-Dateien
// realistischerweise vorkommt (SUMME/SUM, WENN/IF, RUNDEN/ROUND, Grundrechenarten).
//
// ── Shared-Formula-Slaves (Adversarial-Review-Fix, KAR-900, 10.07.2026) ────
//
// KRITISCHER Fund (Konfidenz 88): die ursprüngliche Extraktion las
// `cell.value.formula` — das trägt eine Formel NUR beim MASTER einer
// Excel-"Shared Formula"-Range (Copy-down-Spalten, in BMW-Fertigungskosten- // allow-customer-string
// Sheets üblich). Jede SLAVE-Zelle derselben Range hat `model.formula ===
// undefined` (nur `model.sharedFormula` zeigt auf den Master) — verifiziert
// in node_modules/exceljs/lib/doc/cell.js: FormulaValue._copyModel kopiert
// `formula` nur wenn `model.formula` truthy ist. Ergebnis vor dem Fix: jede
// Slave-Zelle lieferte `null` statt einer Formel — wechselt eine Zelle
// zwischen ALT und NEU (routinemäßiger Re-Save, Zeilen-Insert, Excel wählt
// intern einen anderen Anker) die Rolle Master↔Slave, feuerte
// compareFormulaPair fälschlich `formel_zu_konstante`/kritisch auf einer
// völlig unveränderten Datei.
//
// FIX: beide Extraktionsstellen (qaf-parser.ts, workbook-adapter.ts) lesen
// jetzt `cell.formula`/`cell.formulaType` (die Cell-Level-Getter, NICHT
// `cell.value`). `cell.formula` löst Shared-Slaves über ExcelJS' eigene
// `_getTranslatedFormula()`/`slideFormula()` auf: sie nimmt die Formel des
// Masters und verschiebt jede NICHT-$-verankerte Zellreferenz um den Zeilen/
// Spalten-Offset zwischen Master- und Slave-Adresse — der zurückgegebene
// String ist bereits positionskorrekt FÜR DIE SLAVE-ZELLE SELBST (verifiziert
// in shared-formula.js: `slideFormula(formula, fromCell, toCell)`).
//
// TRANSLATION-NORMALISIERUNGS-ENTSCHEIDUNG (wie vom Review gefordert
// dokumentiert): Da compareFormulaPair IMMER dieselbe logische Zellposition
// zwischen ALT und NEU vergleicht (z.B. "die fk-Zelle von Zeile 2" auf
// beiden Seiten), und `cell.formula` für JEDE Zelle — ob Master, Slave oder
// gar keine Shared-Range beteiligt — bereits den für GENAU DIESE Position
// korrekten Formeltext liefert, ist KEINE zusätzliche zeilen-relative
// Normalisierung nötig: die bestehende normalizeFormula/stripReferenceAnchors-
// Pipeline (die nur $-Anker vereinheitlicht, s.u.) operiert bereits auf dem
// schon positions-übersetzten Text. Ob eine Zelle intern Master oder Slave
// ist, ist reine Speicheroptimierung von Excel — semantisch identisch.
//
// TRI-STATE für den seltenen Fall, dass ExcelJS eine Shared-Formula NICHT
// auflösen kann (Master-Zelle fehlt/korrupt — `_getTranslatedFormula` findet
// `worksheet.findCell(sharedFormula)` nicht): `FormulaProvenance.unresolved`
// (s.u.) statt stillschweigend wie "keine Formel" zu behandeln — compareFormulaPair
// behandelt das explizit wie 'no_formula_data' und fällt NIE in den
// altFormula&&!neuFormula-Zweig (der `formel_zu_konstante` auslösen würde).
//
// Aufgeteilt in zwei Hälften mit unterschiedlicher Bundle-Disziplin:
//   - Extraktion (buildFormulaProvenance/formulaHash) nutzt node:crypto (sha256)
//     — wird NUR aus Parse-Zeit-Code aufgerufen. qaf-parser.ts läuft im Browser
//     (components/qaf/qaf-client.tsx importiert parseQAFTemplate direkt) und
//     muss dieses Modul daher per DYNAMISCHEM import laden (Bundle-Lehre #272,
//     exakt das bereits etablierte Muster für canonical-model.ts/
//     canonical-fields.ts in qaf-parser.ts — siehe dortigen Modul-Header).
//     workbook-adapter.ts ist bereits dokumentiert "Server-only" und darf
//     dieses Modul statisch importieren.
//   - Vergleich (compareFormulaPair/formulaFindingToPlausibilityIssue) braucht
//     KEIN crypto (vergleicht nur bereits berechnete Hash-Strings) — differ.ts/
//     summary-metrics.ts/compare.ts dürfen dieses Modul daher statisch
//     importieren, exakt wie template-fingerprint.ts (ebenfalls statisches
//     node:crypto, ebenfalls über den qaf-differences-Barrel re-exportiert)
//     bereits vorexistiert, ohne den Client-Bundle zu belasten.
//
// Pure. Kein I/O außer der reinen crypto-Berechnung (kein Netzwerk, keine DB).

import { createHash } from 'node:crypto'
import type { PlausibilityIssue, PlausibilitySeverity } from './plausibility'

// ── DE -> EN Funktionsnamen-Mapping ─────────────────────────────────────────
//
// Konservative, dokumentierte Teilmenge — nur Funktionen, die in BMW-QAF- // allow-customer-string
// Kalkulationsblättern (Fertigungskosten/Summary) tatsächlich vorkommen bzw.
// dort plausibel vorkommen können (Grundrechenarten + gängige Summen-/Wenn-/
// Rundungs-Logik). KEIN Anspruch auf vollständige Excel-Funktionsabdeckung
// (Excel hat >400 Funktionen) — das wäre ein eigenständiges, viel größeres
// Vorhaben und liegt explizit außerhalb des P2.1-Scopes.
//
// Hinweis: In der zugrundeliegenden OOXML-Datei (.xlsx) werden Formeln von
// Excel selbst grundsätzlich bereits in der kanonischen EN-Form mit Komma als
// Argumenttrenner gespeichert, unabhängig von der Anzeige-Sprache, in der sie
// eingegeben wurden — ExcelJS liest also in aller Regel bereits EN-Namen. Das
// DE-Mapping ist trotzdem defensiv eingebaut (robust gegen abweichende
// Erzeuger-Tools/ältere Exporte), bleibt aber im Normalfall ein No-Op.
// Semikolon->Komma-Argumenttrennung wird bewusst NICHT normalisiert (kein
// beobachteter Bedarf, siehe Scope-Hinweis oben).
export const FUNCTION_NAME_DE_TO_EN: Readonly<Record<string, string>> = {
  SUMME: 'SUM',
  WENN: 'IF',
  WENNS: 'IFS',
  WENNFEHLER: 'IFERROR',
  RUNDEN: 'ROUND',
  ABRUNDEN: 'ROUNDDOWN',
  AUFRUNDEN: 'ROUNDUP',
  MITTELWERT: 'AVERAGE',
  ANZAHL: 'COUNT',
  ANZAHL2: 'COUNTA',
  ODER: 'OR',
  UND: 'AND',
  NICHT: 'NOT',
  SVERWEIS: 'VLOOKUP',
  WVERWEIS: 'HLOOKUP',
  VERGLEICH: 'MATCH',
  ISTFEHLER: 'ISERROR',
  ISTLEER: 'ISBLANK',
  ISTZAHL: 'ISNUMBER',
  ISTTEXT: 'ISTEXT',
  SUMMEWENN: 'SUMIF',
  SUMMEWENNS: 'SUMIFS',
  ZÄHLENWENN: 'COUNTIF',
  ZÄHLENWENNS: 'COUNTIFS',
  WURZEL: 'SQRT',
  POTENZ: 'POWER',
  VERKETTEN: 'CONCATENATE',
  LÄNGE: 'LEN',
  LINKS: 'LEFT',
  RECHTS: 'RIGHT',
  TEIL: 'MID',
  GLÄTTEN: 'TRIM',
  GROSS: 'UPPER',
  KLEIN: 'LOWER',
  DATUM: 'DATE',
  HEUTE: 'TODAY',
  JAHR: 'YEAR',
  MONAT: 'MONTH',
  TAG: 'DAY',
  REST: 'MOD',
}

// Precompiled once at module load (not per normalizeFormula call) — the DE
// name is always immediately followed by "(" in a formula (already
// whitespace-stripped by the time this runs, see normalizeFormula), \b is
// safe here because every DE key starts with an ASCII letter.
const FUNCTION_NAME_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = Object.entries(
  FUNCTION_NAME_DE_TO_EN,
).map(([de, en]) => [new RegExp(`\\b${de}\\(`, 'g'), `${en}(`] as const)

/** Split on quoted string literals, transform only the parts OUTSIDE quotes —
 * function names, references and whitespace live outside quotes; literal
 * text (e.g. "n.a.", "Ja") must survive untouched (case, spacing, content). */
function transformOutsideQuotes(input: string, transform: (segment: string) => string): string {
  const parts = input.split(/("[^"]*")/g)
  return parts.map((part, i) => (i % 2 === 1 ? part : transform(part))).join('')
}

/**
 * Normalize a raw Excel formula for structural comparison: uppercase
 * (function names + references, NOT quoted string literals), DE->EN
 * function-name mapping, whitespace stripped (outside quotes). Cell-reference
 * anchors ($) are DELIBERATELY KEPT here (display form) — stripped separately
 * for formulaHash (see stripReferenceAnchors), per task spec: "relative/
 * absolute Refs vereinheitlichen ($A$1->A1 für den Hash, aber Original
 * behalten". Pure.
 */
export function normalizeFormula(raw: string): string {
  let s = raw.trim()
  if (s.startsWith('=')) s = s.slice(1)
  s = transformOutsideQuotes(s, (seg) => seg.toUpperCase())
  s = transformOutsideQuotes(s, (seg) => seg.replace(/\s+/g, ''))
  s = transformOutsideQuotes(s, (seg) => {
    let out = seg
    for (const [pattern, replacement] of FUNCTION_NAME_PATTERNS) out = out.replace(pattern, replacement)
    return out
  })
  return s
}

/**
 * Strip absolute/mixed-reference anchors ($) from an already-normalized
 * formula, for hashing only — $A$1 / A$1 / $A1 / A1 all collapse to A1, so a
 * pure "someone toggled a reference's anchor mode" edit does not register as
 * a formula change. Does not touch quoted string literals (a literal "$"
 * price sign etc. must survive). Pure.
 */
export function stripReferenceAnchors(normalized: string): string {
  return transformOutsideQuotes(normalized, (seg) => seg.replace(/\$/g, ''))
}

/** sha256 hex digest of the reference-anchor-stripped normalized formula. Pure. */
export function formulaHash(normalized: string): string {
  return createHash('sha256').update(stripReferenceAnchors(normalized)).digest('hex')
}

/** Per-field formula provenance: original text, display-normalized form, and
 * the anchor-stripped structural hash. `raw` is exactly what ExcelJS returned
 * for cell.formula (no leading "=" — ExcelJS already excludes it).
 *
 * `unresolved` (adversarial-review fix, KAR-900, 10.07.2026): true when the
 * source cell IS part of a shared-formula range (`cell.formulaType !==
 * None`) but ExcelJS could not resolve/translate it (the shared range's
 * master cell is missing or corrupt — rare, but structurally possible). This
 * is NOT the same as "no formula" — it means "we could not determine
 * whether/what formula this cell has". `raw`/`normalized`/`hash` are empty
 * placeholders in this case, never to be read for comparison; see
 * compareFormulaPair, which short-circuits on this flag before any
 * presence/absence branching so an unresolved slave can never be
 * misclassified as a removed formula (formel_zu_konstante). */
export interface FormulaProvenance {
  raw: string
  normalized: string
  hash: string
  unresolved?: true
}

/** Build the full provenance triple for one raw, already-resolved formula
 * string (i.e. `cell.formula`, NOT `cell.value.formula` — see module header
 * "Shared-Formula-Slaves"). Pure. */
export function buildFormulaProvenance(raw: string): FormulaProvenance {
  const normalized = normalizeFormula(raw)
  return { raw, normalized, hash: formulaHash(normalized) }
}

/** Marker for a shared-formula cell ExcelJS could not resolve (see
 * FormulaProvenance.unresolved doc). Extraction sites (qaf-parser.ts,
 * workbook-adapter.ts) call this instead of buildFormulaProvenance when
 * `cell.formulaType !== FormulaType.None` but `cell.formula` came back
 * empty/undefined. */
export function unresolvedFormulaProvenance(): FormulaProvenance {
  return { raw: '', normalized: '', hash: '', unresolved: true }
}

/**
 * Shared sentinel for the raw-formula-grid tri-state used by
 * workbook-adapter.ts's worksheetToFormulaGrid (producer) and
 * summary-metrics.ts's formulaAt (consumer) — two different modules, hence
 * exported (unlike qaf-parser.ts, which keeps its own module-local sentinel
 * since production and consumption happen in the same file there). Distinct
 * from `null` (no formula at all) and from any real formula string.
 */
export const SHARED_FORMULA_UNRESOLVED: unique symbol = Symbol('unresolved-shared-formula')

// ── Vergleich (4 Semantiken, Master-Prompt §12.4 / Backlog P2.1) ───────────

/**
 * - 'no_formula_data': neither side has a formula for this field, OR a
 *   formula was ADDED where there was none before (benign — a manual value
 *   turning into a computed one is not a manipulation signal), OR a formula
 *   was removed but the other side is genuinely blank (no constant to flag).
 *   Today's behavior — no finding, "kein Rauschen" per task spec.
 * - 'unauffaellig': both sides carry a formula with an IDENTICAL structural
 *   hash. Whether the displayed value differs is orthogonal — see
 *   `inputsChanged` — a hash match means the calculation logic itself did
 *   not change, only (at most) the inputs it reads from elsewhere in the
 *   sheet did.
 * - 'formel_geaendert_wert_gleich': both sides carry a formula, the hash
 *   differs, but the displayed value is (numerically) unchanged — the
 *   silent-manipulation risk Master-Prompt §12.4 explicitly calls out.
 * - 'formel_geaendert_wert_geaendert': both sides carry a formula, the hash
 *   differs, AND the value also changed — still worth surfacing (the
 *   calculation logic changed, not just its inputs) but less urgent than the
 *   silent case above since the value diff is already visible through the
 *   ordinary delta/status-band machinery.
 * - 'formel_zu_konstante': one side had a formula, the other has a real
 *   (non-null) value with NO formula — the classic manipulation pattern from
 *   the offline tool (KAR-861): a computed field silently hardcoded.
 */
export type FormulaComparisonKind =
  | 'no_formula_data'
  | 'unauffaellig'
  | 'formel_geaendert_wert_gleich'
  | 'formel_geaendert_wert_geaendert'
  | 'formel_zu_konstante'

export interface FormulaComparisonResult {
  kind: FormulaComparisonKind
  /** Only meaningful (and only ever true) when kind === 'unauffaellig': the
   * formula itself is unchanged but the computed value differs — i.e. an
   * upstream input cell changed, not the calculation logic. Task spec: "(3)
   * Formel gleich + Wert geändert -> normale Wert-Diff-Semantik +
   * Kennzeichnung inputs_changed" — the normal delta/status-band computation
   * is untouched, this is a supplementary annotation only. */
  inputsChanged: boolean
  /** DE, human-readable — used verbatim as the PlausibilityIssue.explanation
   * for the three reportable kinds; empty for 'no_formula_data'/'unauffaellig'. */
  explanation: string
  /** EN counterpart of `explanation` (KAR-906/P3.2) — used verbatim as
   * PlausibilityIssue.explanationEn; empty for the two non-reportable kinds,
   * same as `explanation`. Optional: compare.ts reconstructs a partial
   * FormulaComparisonResult-shaped literal from the in-memory-only
   * FieldDiff.formulaFinding (kind+explanation, DE-only by design, see
   * types.ts) when re-deriving a plausibility issue for the recompare/
   * pin-match paths — that reconstruction has no EN text available, and
   * falls back to DE via PlausibilityIssue.explanationEn being undefined,
   * same tolerant-fallback contract as everywhere else in this PR. */
  explanationEn?: string
}

const DEFAULT_EPSILON = 1e-9

function valuesEqual(a: number | null, b: number | null, epsilon: number): boolean {
  if (a === null || b === null) return a === b
  return Math.abs(a - b) < epsilon
}

/**
 * Structured comparison of one field's formula provenance on both sides —
 * see FormulaComparisonKind for the exact state machine. Pure.
 */
export function compareFormulaPair(params: {
  altFormula?: FormulaProvenance
  neuFormula?: FormulaProvenance
  altValue: number | null
  neuValue: number | null
  epsilon?: number
}): FormulaComparisonResult {
  const { altFormula, neuFormula, altValue, neuValue } = params
  const epsilon = params.epsilon ?? DEFAULT_EPSILON

  // Adversarial-review fix (KAR-900, 10.07.2026): an unresolved shared
  // formula (see FormulaProvenance.unresolved doc) must NEVER be treated as
  // "this side has no formula" — that would let a mere extraction failure
  // fall into the altFormula&&!neuFormula branch below and misfire
  // formel_zu_konstante/kritisch on an unchanged file. Short-circuit BEFORE
  // any presence/absence branching, on EITHER side.
  if (altFormula?.unresolved || neuFormula?.unresolved) {
    return { kind: 'no_formula_data', inputsChanged: false, explanation: '', explanationEn: '' }
  }

  if (!altFormula && !neuFormula) return { kind: 'no_formula_data', inputsChanged: false, explanation: '', explanationEn: '' }

  // Asymmetric: only one side carries a formula at all.
  if (altFormula && !neuFormula) {
    if (neuValue === null) return { kind: 'no_formula_data', inputsChanged: false, explanation: '', explanationEn: '' }
    return {
      kind: 'formel_zu_konstante',
      inputsChanged: false,
      explanation: `Formel wurde entfernt und durch einen festen Wert ersetzt (ALT-Formel: "${altFormula.raw}", NEU-Wert: ${neuValue}).`,
      explanationEn: `Formula was removed and replaced by a fixed value (ALT formula: "${altFormula.raw}", NEU value: ${neuValue}).`,
    }
  }
  if (!altFormula && neuFormula) {
    // Formula added where there was none before — benign, not flagged
    // (deliberate asymmetry, see FormulaComparisonKind doc).
    return { kind: 'no_formula_data', inputsChanged: false, explanation: '', explanationEn: '' }
  }

  // Both sides carry a formula.
  const sameFormula = altFormula!.hash === neuFormula!.hash
  const sameValue = valuesEqual(altValue, neuValue, epsilon)

  if (sameFormula) {
    return { kind: 'unauffaellig', inputsChanged: !sameValue, explanation: '', explanationEn: '' }
  }
  if (sameValue) {
    return {
      kind: 'formel_geaendert_wert_gleich',
      inputsChanged: false,
      explanation: `Formel geändert, angezeigter Wert unverändert (ALT: "${altFormula!.raw}" -> NEU: "${neuFormula!.raw}").`,
      explanationEn: `Formula changed, displayed value unchanged (ALT: "${altFormula!.raw}" -> NEU: "${neuFormula!.raw}").`,
    }
  }
  return {
    kind: 'formel_geaendert_wert_geaendert',
    inputsChanged: false,
    explanation: `Formel UND Wert geändert (ALT: "${altFormula!.raw}" -> NEU: "${neuFormula!.raw}").`,
    explanationEn: `Formula AND value changed (ALT: "${altFormula!.raw}" -> NEU: "${neuFormula!.raw}").`,
  }
}

const FORMULA_FINDING_SEVERITY: Partial<Record<FormulaComparisonKind, PlausibilitySeverity>> = {
  formel_geaendert_wert_gleich: 'pruefen',
  formel_geaendert_wert_geaendert: 'hinweis',
  formel_zu_konstante: 'kritisch',
}

/**
 * Turn a reportable FormulaComparisonResult into a qaf_plausibility_issue-
 * shaped finding (same channel every other check in this module tree uses —
 * see plausibility.ts/reconciliation.ts/rule-engine.ts precedent). Returns
 * null for the two non-reportable kinds ('no_formula_data', 'unauffaellig')
 * — a clean/benign comparison produces no finding, exactly like every other
 * check here. Pure.
 */
export function formulaFindingToPlausibilityIssue(
  result: FormulaComparisonResult,
  field: string,
  step?: string,
): PlausibilityIssue | null {
  const severity = FORMULA_FINDING_SEVERITY[result.kind]
  if (!severity) return null
  return {
    type: result.kind,
    severity,
    field,
    step,
    explanation: result.explanation,
    explanationEn: result.explanationEn,
  }
}

// ── Engine-Konfiguration (P1.5-Muster, engine-config.ts) ────────────────────
//
// Eigene Sektion analog RULE_ENGINE_CONFIG/RECONCILIATION_CONFIG/
// G60_STRUCTURE_GUARD_CONFIG — engine-config.ts importiert diese Konstante
// unverändert (keine zweite Quelle der Wahrheit). `enabled: true` ist eine
// explizite Vorgabe für diesen PR (Task-Anweisung KAR-900, 10.07.2026) — die
// Backlog-Notiz zu P2.1 nennt ein Feature-Flag als "zwingend" mit einer
// vorsichtigeren Default-Haltung; hier bewusst enabled=true, siehe PR-Report/
// Linear-Kommentar für die Abwägung. Kann jederzeit ohne Migration auf false
// gesetzt werden (reiner Code-Config-Wert, kein DB-Zustand, siehe
// engine-config.ts-Modul-Header).
export interface FormulaEngineConfig {
  /** When false, compareQafPair skips all formula extraction-derived diffing
   * (no status overrides, no formulaFinding issues) — steps/summary metrics
   * still compare exactly as before P2.1. */
  enabled: boolean
}

export const FORMULA_ENGINE_CONFIG: FormulaEngineConfig = { enabled: true }
