// QAF Business-Rule-Reconciliation-Engine — unabhängige Nachrechnung der
// Leitfaden-Formeln auf ZEILEN-Ebene (KAR-901 / P2.2).
//
// Abgrenzung zu reconciliation.ts (KAR-887/P0.2): jenes Modul prüft nur
// AGGREGATE (Sigma(Zeilen-Feld) vs. ein Summary-Wert, oder Summary-interne
// Kaskaden wie Materialkosten+Fertigungskosten=Herstellkosten). Es rechnet
// nie eine EINZELNE Zeile selbst nach — ob z.B. die in einer konkreten
// Fertigungskosten-Zeile stehende FEK-Zahl tatsächlich aus den in DERSELBEN
// Zeile stehenden Eingangsfeldern folgt, bleibt dort ungeprüft. Master-Prompt
// §12.5 ("Do not trust Excel-calculated results without independent
// validation") verlangt genau das auch auf Zeilen-Ebene — dieses Modul liefert
// das, für jede Formel, deren Eingangsgrößen heute tatsächlich geparst werden.
//
// Läuft PRO DATEI (ALT und NEU getrennt, wie reconciliation.ts) — kein
// Cross-File-Diff. Pure, keine I/O.
//
// ═══════════════════════════════════════════════════════════════════════════
// INVENTAR — welche Leitfaden-Formeln sind mit den heute geparsten Feldern
// (QAFRowValues aus lib/qaf-parser.ts, MaterialRowValues aus
// material-parser.ts, SbmRowValues aus sbm-parser.ts) vollständig
// nachrechenbar? Formel-für-Formel mit Seiten-Evidenz aus
// 02-leitfaden-teil1.md / 02-leitfaden-teil2.md (Brain:
// 01-Projekte/supplierpulse-qaf-adaptive-engine/).
// ═══════════════════════════════════════════════════════════════════════════
//
// ── MANUFACTURING (QAFRow) ──────────────────────────────────────────────────
//
// [IMPLEMENTIERT] rule_calc_fek — Leitfaden [30] (S.30):
//   "FEK [BW] = [Anzahl dir. MA × Direkter Lohn × (1+SGK) + MSS] ×
//   Zykluszeit / Teile pro Zyklus / 3600 + Rüstkosten pro Stück."
//   Felder: anzahlMA, lohnkosten, lohnzuschlagssaetze (SGK%), mss, zykluszeit,
//   teileProZyklus, ruestkosten → fek. Alle vorhanden.
//
// [IMPLEMENTIERT] rule_calc_fk — Leitfaden [31] (S.31):
//   "Fertigungskosten FK [BW] = FEK + RFGK Stundensatz × Zykluszeit /
//   Teile pro Zyklus / 3600."
//   Felder: fek, rfgk, zykluszeit, teileProZyklus → fk. Alle vorhanden.
//
// [IMPLEMENTIERT] rule_calc_ausschuss_fertigung — Leitfaden [29] Abbildung 20
//   (S.29): "Fertigungskosten FK [AW] × (1/(1 − Ausschuss pro Prozessschritt)
//   − 1) = Ausschusskosten Fertigung [AW]."
//   Felder: fkAW, ausschuss → ausschusskosten. Alle vorhanden.
//
// [TODO nicht nachrechenbar] Rohstoffzuschlag RoZ0 (Energie) — Leitfaden [29]
//   Abbildung 21: "Anzahl pro Angebotsteil × Energieverbrauch pro h [kWh] ×
//   Rohstoffnotierung Ro [AW/kWh] = Rohstoffzuschlag RoZ0 [AW]." Leitfaden
//   [31] listet die Felder Rohstoffbezeichnung/Energieverbrauch pro h/
//   Rohstoffnotierung Ro [AW/kWh]/Anteil Energie an MSS/Rohstoffzuschlag RoZ0
//   für MANUFACTURING — QAFRowValues (lib/qaf-parser.ts) hat KEINES dieser
//   Felder (nicht geparst). Fehlendes Feld: energyConsumptionPerHour /
//   rawMaterialQuotationEnergy / rawMaterialSurchargeEnergy (existieren nicht).
//
// [TODO nicht nachrechenbar] Wechselkurs [AW/BW] = Angebotswährung /
//   Beschaffungswährung (Leitfaden [31]) — angebotswaehrung/
//   beschaffungswaehrung sind Währungs-CODES (Text, z.B. "EUR"), keine
//   unabhängige numerische Marktkurs-Quelle vorhanden, gegen die man
//   `wechselkurs` nachrechnen könnte. Fehlendes Feld: externe FX-Rate-Quelle.
//
// [TODO nicht nachrechenbar] Fertigungskosten FK [AW] (aus FK[BW]) —
//   Leitfaden [31] listet fkAW nur als "berechnet: Fertigungskosten in
//   Angebotswaehrung", OHNE die Formel textuell auszuschreiben (anders als
//   FEK/FK[BW]/Ausschusskosten Fertigung, die alle eine explizite
//   Formel-Zeile haben). Eine Ableitung "fk × wechselkurs ×
//   anzahlProAngebotsteil" wäre reine Analogie zu MATERIAL — laut
//   Task-Vorgabe ("KEINE Spekulation") nicht implementiert ohne textuelle
//   Formel-Evidenz.
//
// ── MATERIAL (MaterialRow, material-parser.ts) ──────────────────────────────
//
// [IMPLEMENTIERT] rule_calc_material_cost_per_unit_aw — Leitfaden [19] (S.19):
//   "Kalkulatorische Materialkosten pro Mengeneinheit [AW] = Kosten pro ME ×
//   Wechselkurs + Verpackungskosten + Transportkosten + Zollkosten + MGK."
//   Felder: costPerUnitBw, exchangeRate, packagingCostPerUnit,
//   transportCostPerUnit, customsCbamCostPerUnit, overheadCost →
//   costPerUnitAw. Alle vorhanden.
//
// [IMPLEMENTIERT] rule_calc_material_scrap_cost — Leitfaden [17] Abbildung 10
//   (S.17): "Materialkosten [AW] × (1 / (1 − Material-Ausschuss [%]) − 1) =
//   Ausschusskosten Material [AW]."
//   Felder: materialCost, scrapRate → scrapCost. Alle vorhanden.
//
// [IMPLEMENTIERT] rule_calc_raw_material_surcharge — Leitfaden [17]
//   Abbildung 9 (S.17): "Anzahl pro Angebotsteil × Bezugsgewicht pro
//   Angebotsteil [kg] × Rohstoffnotierung Ro [AW/kg] = Rohstoffzuschlag RoZ0
//   [AW]." Felder: quantityPerQuotedPart, referenceWeight,
//   rawMaterialQuotation → rawMaterialSurcharge. Alle vorhanden. Betrifft nur
//   Zeilen mit befüllten RMR-Feldern (die überwiegende Mehrheit der
//   MATERIAL-Zeilen hat rawMaterialSurcharge = null und wird übersprungen,
//   kein Fehlalarm — s. "Gating"-Prinzip unten).
//
// [IMPLEMENTIERT] rule_calc_material_cost — Leitfaden [19] (S.19):
//   "Kaufteilkosten = Materialkosten pro ME × Anzahl der Verwendungen.
//   Rohmaterialkosten = [(Materialkosten pro ME × Einsatzmenge) −
//   Rückvergütung] × Anzahl Verwendungen." Zwei Formelvarianten je nach
//   Zeilentyp (Kaufteil vs. Rohmaterial) — Branch-Logik unten dokumentiert.
//   Feinere Zwischenschritte aus Abbildung 7/8 (z.B. "Kosten pro Stück
//   (DAP)") haben KEIN eigenes MaterialRow-Feld — nur der Kettenanfang
//   (costPerUnitAw, s.o.) und das Endergebnis (materialCost) sind geparst,
//   daher wird hier nur die END-zu-END-Formel geprüft, nicht jeder
//   Zwischenschritt einzeln.
//
// [TODO nicht nachrechenbar] Wechselkurs [AW/BW] (Leitfaden [18]) — s.o.,
//   dieselbe fehlende FX-Rate-Quelle wie bei MANUFACTURING.
//
// ── SBM-DEVICES-FWZ (SbmRow, sbm-parser.ts) ─────────────────────────────────
//
// [IMPLEMENTIERT] rule_calc_sbm_tool_fixture_cost — 02-leitfaden-teil2.md [37]
//   (S.37): "Summe Werkzeug-/Vorrichtungskosten [AW] = Werkzeug- bzw.
//   Vorrichtungskosten in Beschaffungswährung × Wechselkurs × Anzahl der
//   Werkzeuge bzw. Vorrichtungen." Felder: toolFixtureCostBw, exchangeRate,
//   toolFixtureCount → totalToolFixtureCostAw. Alle vorhanden (sbm-parser.ts
//   existiert bereits im Repo, vorgezogen gegenüber dem Backlog-Item P1.7).
//
// [TODO nicht nachrechenbar] Wechselkurs [AW/BW] (teil2 [37]) — s.o.
//
// [TODO nicht implementiert — Redundanz, nicht fehlendes Feld] "Summe
//   Werkzeugkosten SBM [AW]" (teil2 [37], bedingt nur bei Verrechnungsform =
//   SBM) — dieselbe Formel wie rule_calc_sbm_tool_fixture_cost auf denselben
//   Eingangsfeldern (toolFixtureCostBw/exchangeRate/toolFixtureCount), nur
//   für einen anderen Ergebnisfeld-Namen (totalSbmToolCostAw statt
//   totalToolFixtureCostAw) im bedingten SBM-Fall. Kein unabhängiger
//   zusätzlicher Prüfwert — ein zweiter Check hier würde denselben
//   Eingangsfehler doppelt melden, ohne neue Information zu liefern.
//
// ── Außerhalb des P2.2-Scope (laut Backlog 05-backlog-phasenplan.md, andere
// Phase-2-Items) ─────────────────────────────────────────────────────────────
//   - RAW MATERIAL RISKS-Aggregation (S.42-45) = P2.3, eigener Parser fehlt.
//   - LOGISTICS&CUSTOM-Formeln (S.39-41) = P2.4, eigener Parser fehlt.
//   - SBM-Wechselkurs/WAF-Angebotspreis-Formel (teil3, "Angebotspreis =
//     (ΣMaterial+ΣFertigung)×(1+Zuschläge)+Logistik") — WAF ist ein
//     EIGENSTÄNDIGES externes Formular (Werkzeuganalyseformular), wird nicht
//     als Teil des QAF-Workbooks geparst (kein waf-parser.ts im Repo) — ohne
//     geparste Eingangsfelder nicht nachrechenbar.
//
// ── Formula-Engine-Synergie (KAR-900/P2.1) ──────────────────────────────────
// Reine Text-Anreicherung, KEINE neue Kopplung der Engines (Task-Vorgabe):
// businessRuleResultToPlausibilityIssue nimmt optional den
// FormulaFinding.explanation-Text des GLEICHEN Zielfelds entgegen (vom
// Aufrufer — compare.ts — über stepComparisons/fieldDiffs ermittelt, siehe
// dort) und hängt bei einer 'abweichung' einen Hinweissatz an: "Nachrechnung
// weicht ab UND Formel wurde geändert" — der stärkste verfügbare
// Manipulations-Indikator. Nur für MANUFACTURING-Felder möglich (fek/fk/
// ausschusskosten) — MATERIAL/SBM-Zeilen werden nicht Schritt-für-Schritt
// gedifft (kein FieldDiff/formulaFinding existiert dafür, s. compare.ts
// QafFileParsed.materialRows/sbmRows-Dokumentation: "does not produce
// MATERIAL/SBM stepComparisons").
//
// ── Gating-Prinzip (Anti-Spam, analog reconciliation.ts's SBM-"beide Seiten
// leer"-Kurzschluss) ─────────────────────────────────────────────────────────
// Dieses Modul läuft PRO ZEILE — bei N Zeilen × mehreren Checks könnte eine
// naive "immer ein Ergebnis pro Zeile" Implementierung Dutzende
// nicht_pruefbar-Hinweise für Formeln erzeugen, die für die betreffende Zeile
// schlicht NICHT GELTEN (z.B. RoZ0 für eine gewöhnliche Kaufteil-Zeile ohne
// Rohstoffrisikobeteiligung). Daher: ein Check wird nur DANN ausgewertet
// (liefert bestanden/abweichung/nicht_pruefbar), wenn das ZIELFELD der Zeile
// (der Excel-Ist-Wert) überhaupt befüllt ist — ist es leer, gibt es nichts
// nachzurechnen, die Zeile wird für DIESEN Check komplett übersprungen (kein
// Ergebnis, nicht einmal nicht_pruefbar). Ist das Zielfeld befüllt, aber ein
// Eingangsfeld fehlt/ist n.a., IST das ein berichtenswerter nicht_pruefbar-Fall
// (Excel zeigt einen Wert, den wir nicht verifizieren können).
//
// ── NIE null-als-0 ───────────────────────────────────────────────────────────
// Ein fehlendes (null, nicht explizit n.a. markiertes) Eingangsfeld wird NIE
// als 0 in die Formel eingesetzt — auch nicht bei fachlich "meist leer"
// Feldern wie Rückvergütung (rebate). Eine leere Zelle kann echte 0 ODER eine
// Datenlücke bedeuten; ohne expliziten n.a.-Marker ist das nicht
// unterscheidbar, also: nicht_pruefbar statt Rateannahme.

import type { QAFRow, QAFFieldKey } from '@/lib/qaf-parser'
import { isNotApplicableValue } from './normalizer'
import type { ComparisonSide } from './rule-engine'
import type { PlausibilityIssue, PlausibilitySeverity } from './plausibility'
import type { MaterialRow, MaterialFieldKey } from './material-parser'
import { MATERIAL_UNIT_RAW_MATERIAL_RISK, MATERIAL_UNIT_PURCHASED_PART } from './material-parser'
import type { SbmRow, SbmFieldKey } from './sbm-parser'
import { normalizeProcessName } from './normalizer'
import { QAF_FIELD_KEY_TO_CANONICAL, MATERIAL_FIELD_KEY_TO_CANONICAL, SBM_FIELD_KEY_TO_CANONICAL } from './canonical-fields'
import { byCanonicalId } from './canonical-model'

/** EN label for a field key, sourced from the canonical field registry
 * (KAR-906/P3.2 task instruction: use canonical labelEn instead of ad-hoc
 * translation) — falls back to the DE label on a registry miss (should not
 * happen for MANUFACTURING/MATERIAL/SBM, which canonical-fields.ts documents
 * as FULL/near-full coverage). */
function canonicalLabelEn(canonicalId: string, fallbackDe: string): string {
  return byCanonicalId(canonicalId)?.labelEn ?? fallbackDe
}

export type BusinessRuleCheckId =
  | 'rule_calc_fek'
  | 'rule_calc_fk'
  | 'rule_calc_ausschuss_fertigung'
  | 'rule_calc_material_cost_per_unit_aw'
  | 'rule_calc_material_scrap_cost'
  | 'rule_calc_raw_material_surcharge'
  | 'rule_calc_material_cost'
  | 'rule_calc_sbm_tool_fixture_cost'

export type BusinessRuleStatus = 'bestanden' | 'abweichung' | 'nicht_pruefbar'

export interface BusinessRuleResult {
  checkId: BusinessRuleCheckId
  side: ComparisonSide
  /** Index in the source rows array (steps/materialRows/sbmRows) this result
   * belongs to. -1 for a file-level result (no MATERIAL/SBM sheet at all —
   * there is no row to index into). Used by callers (compare.ts) to
   * cross-reference a matching StepComparison for the formula-engine
   * synergy hook — see module header. */
  rowIndex: number
  /** Positionsnummer of the row (empty string for a file-level result). */
  positionsnummer: string
  /** QAFFieldKey / MaterialFieldKey / SbmFieldKey this check's recomputed
   * value targets — used by compare.ts to look up a matching
   * FieldDiff.formulaFinding. Untyped (string) because the three row
   * domains use disjoint key sets. */
  targetField: string
  status: BusinessRuleStatus
  expected: number | null
  actual: number | null
  deltaAbsolute: number | null
  deltaPercent: number | null
  /** Set only for status === 'nicht_pruefbar' — why no verdict could be reached. */
  reason?: string
  /** EN counterpart of `reason` (KAR-906/P3.2). Set whenever `reason` is. */
  reasonEn?: string
  messageDe?: string
  messageEn?: string
}

/**
 * Central tolerance config for this module — same STARTWERT-Fachentscheid
 * philosophy as reconciliation.ts's RECONCILIATION_CONFIG (0.5% relativ + 1
 * AW absolut, KAR-896-Muster), but its OWN named constant/section (task
 * instruction: "eigene businessRules-Sektion in engine-config.ts analog dem
 * etablierten Muster") — row-level rounding noise can differ in character
 * from summary-level rounding noise, so this is deliberately a distinct,
 * independently tunable config, not a re-export of RECONCILIATION_CONFIG.
 */
export interface BusinessRulesConfig {
  /** Fraction, e.g. 0.005 = 0.5%. */
  relativeTolerance: number
  /** Absolute floor in AW/BW, applied via max(relative, absolute). */
  absoluteToleranceMinor: number
}

export const BUSINESS_RULES_CONFIG: BusinessRulesConfig = {
  relativeTolerance: 0.005,
  absoluteToleranceMinor: 1,
}

export interface BusinessRuleInput {
  side: ComparisonSide
  steps: QAFRow[]
  /** Tri-state, identical contract to reconciliation.ts's
   * ReconciliationInput.materialRows: undefined = no MATERIAL parse
   * attempted (MATERIAL checks omitted entirely); null = a parse was
   * attempted but no usable MATERIAL sheet was found (one file-level
   * nicht_pruefbar per MATERIAL checkId); MaterialRow[] = parsed rows
   * (can legitimately be empty). */
  materialRows?: MaterialRow[] | null
  /** Tri-state, identical contract to reconciliation.ts's
   * ReconciliationInput.sbmRows. */
  sbmRows?: SbmRow[] | null
}

function fmt(n: number): string {
  return n.toFixed(4)
}

function withinTolerance(expected: number, actual: number, cfg: BusinessRulesConfig): boolean {
  const threshold = Math.max(Math.abs(expected) * cfg.relativeTolerance, cfg.absoluteToleranceMinor)
  return Math.abs(actual - expected) <= threshold
}

function judge(
  checkId: BusinessRuleCheckId,
  side: ComparisonSide,
  rowIndex: number,
  positionsnummer: string,
  targetField: string,
  expected: number,
  actual: number,
  labelDe: string,
  labelEn: string,
  cfg: BusinessRulesConfig,
): BusinessRuleResult {
  const deltaAbsolute = Number((actual - expected).toFixed(6))
  const deltaPercent = expected !== 0 ? Number((deltaAbsolute / Math.abs(expected)).toFixed(6)) : null
  const ok = withinTolerance(expected, actual, cfg)
  const posLabel = positionsnummer ? `Pos. ${positionsnummer}` : '(ohne Positionsnummer)'
  const messageDe = `${labelDe} (${side}, ${posLabel}): Excel weist ${fmt(actual)} aus, die unabhaengige Nachrechnung ergibt ${fmt(expected)} (Delta ${fmt(deltaAbsolute)}) — Abweichung ausserhalb der Toleranz (${(cfg.relativeTolerance * 100).toFixed(1)} % / min. ${cfg.absoluteToleranceMinor}).`
  const messageEn = `${labelEn} (${side}, ${posLabel}): Excel states ${fmt(actual)}, the independent recomputation yields ${fmt(expected)} (delta ${fmt(deltaAbsolute)}) — deviation exceeds tolerance (${(cfg.relativeTolerance * 100).toFixed(1)}% / min. ${cfg.absoluteToleranceMinor}).`
  return {
    checkId,
    side,
    rowIndex,
    positionsnummer,
    targetField,
    status: ok ? 'bestanden' : 'abweichung',
    expected,
    actual,
    deltaAbsolute,
    deltaPercent,
    ...(ok ? {} : { messageDe, messageEn }),
  }
}

function notPruefbar(
  checkId: BusinessRuleCheckId,
  side: ComparisonSide,
  rowIndex: number,
  positionsnummer: string,
  targetField: string,
  reason: string,
  reasonEn: string,
): BusinessRuleResult {
  return {
    checkId,
    side,
    rowIndex,
    positionsnummer,
    targetField,
    status: 'nicht_pruefbar',
    expected: null,
    actual: null,
    deltaAbsolute: null,
    deltaPercent: null,
    reason,
    reasonEn,
  }
}

// ── Required-field gathering (shared "missing/n.a. -> nicht_pruefbar" logic) ─

interface RequiredField {
  value: number | null
  labelDe: string
  /** EN label (KAR-906/P3.2), sourced from the canonical field registry by
   * manufacturingField/materialField/sbmField below — task instruction: use
   * canonical-fields.ts's labelEn instead of ad-hoc translation. */
  labelEn: string
  rawText: string | undefined
}

/** Returns a bilingual reason when the FIRST missing/n.a. required field is
 * found (in declaration order), or null when every field is present. Never
 * treats a missing field as 0 (module header "NIE null-als-0"). */
function missingReason(fields: readonly RequiredField[]): { de: string; en: string } | null {
  for (const f of fields) {
    if (f.value !== null) continue
    if (f.rawText !== undefined && isNotApplicableValue(f.rawText)) {
      return {
        de: `Eingangsfeld "${f.labelDe}" ist im Excel als "nicht anwendbar" markiert — Nachrechnung nicht moeglich.`,
        en: `Input field "${f.labelEn}" is marked "not applicable" in the Excel file — recomputation not possible.`,
      }
    }
    return {
      de: `Eingangsfeld "${f.labelDe}" ist leer oder nicht als Zahl erkennbar — Nachrechnung nicht moeglich.`,
      en: `Input field "${f.labelEn}" is empty or not recognizable as a number — recomputation not possible.`,
    }
  }
  return null
}

function manufacturingField(row: QAFRow, key: QAFFieldKey, labelDe: string): RequiredField {
  return { value: row[key] as number | null, labelDe, labelEn: canonicalLabelEn(QAF_FIELD_KEY_TO_CANONICAL[key], labelDe), rawText: row.rawText?.[key] }
}

function materialField(row: MaterialRow, key: MaterialFieldKey, labelDe: string): RequiredField {
  return { value: row[key] as number | null, labelDe, labelEn: canonicalLabelEn(MATERIAL_FIELD_KEY_TO_CANONICAL[key], labelDe), rawText: row.rawText?.[key] }
}

function sbmField(row: SbmRow, key: SbmFieldKey, labelDe: string): RequiredField {
  return { value: row[key] as number | null, labelDe, labelEn: canonicalLabelEn(SBM_FIELD_KEY_TO_CANONICAL[key], labelDe), rawText: row.rawText?.[key] }
}

// ── MANUFACTURING checks ─────────────────────────────────────────────────────

function evaluateFekCheck(
  row: QAFRow,
  rowIndex: number,
  side: ComparisonSide,
  cfg: BusinessRulesConfig,
): BusinessRuleResult | null {
  const checkId: BusinessRuleCheckId = 'rule_calc_fek'
  const targetField = 'fek'
  const actual = row.fek
  if (actual === null) return null // Gating: nothing to check on this row.

  const required = [
    manufacturingField(row, 'anzahlMA', 'Anzahl direkte Mitarbeiter'),
    manufacturingField(row, 'lohnkosten', 'Kalkulatorisch angesetzte direkte Lohnkosten [BW/h]'),
    manufacturingField(row, 'lohnzuschlagssaetze', 'Kalkulatorisch angesetzte Lohnzuschlagssaetze SGK [%]'),
    manufacturingField(row, 'mss', 'Maschinenstundensatz MSS [BW/h]'),
    manufacturingField(row, 'zykluszeit', 'Zykluszeit [s]'),
    manufacturingField(row, 'teileProZyklus', 'Teile pro Zyklus'),
    manufacturingField(row, 'ruestkosten', 'Ruestkosten pro Stueck [BW]'),
  ]
  const reason = missingReason(required)
  if (reason) return notPruefbar(checkId, side, rowIndex, row.positionsnummer, targetField, reason.de, reason.en)
  if (row.teileProZyklus === 0) {
    return notPruefbar(
      checkId,
      side,
      rowIndex,
      row.positionsnummer,
      targetField,
      'Teile pro Zyklus ist 0 — Division durch 0, Nachrechnung nicht moeglich.',
      'Parts per cycle is 0 — division by zero, recomputation not possible.',
    )
  }

  const expected =
    (row.anzahlMA! * row.lohnkosten! * (1 + row.lohnzuschlagssaetze! / 100) + row.mss!) *
      (row.zykluszeit! / row.teileProZyklus!) /
      3600 +
    row.ruestkosten!

  return judge(
    checkId,
    side,
    rowIndex,
    row.positionsnummer,
    targetField,
    expected,
    actual,
    'FEK Fertigungseinzelkosten',
    'FEK direct manufacturing costs',
    cfg,
  )
}

function evaluateFkCheck(
  row: QAFRow,
  rowIndex: number,
  side: ComparisonSide,
  cfg: BusinessRulesConfig,
): BusinessRuleResult | null {
  const checkId: BusinessRuleCheckId = 'rule_calc_fk'
  const targetField = 'fk'
  const actual = row.fk
  if (actual === null) return null

  const required = [
    manufacturingField(row, 'fek', 'Fertigungseinzelkosten FEK [BW]'),
    manufacturingField(row, 'rfgk', 'Restfertigungsgemeinkosten (RFGK) Stundensatz [BW/h]'),
    manufacturingField(row, 'zykluszeit', 'Zykluszeit [s]'),
    manufacturingField(row, 'teileProZyklus', 'Teile pro Zyklus'),
  ]
  const reason = missingReason(required)
  if (reason) return notPruefbar(checkId, side, rowIndex, row.positionsnummer, targetField, reason.de, reason.en)
  if (row.teileProZyklus === 0) {
    return notPruefbar(
      checkId,
      side,
      rowIndex,
      row.positionsnummer,
      targetField,
      'Teile pro Zyklus ist 0 — Division durch 0, Nachrechnung nicht moeglich.',
      'Parts per cycle is 0 — division by zero, recomputation not possible.',
    )
  }

  const expected = row.fek! + (row.rfgk! * (row.zykluszeit! / row.teileProZyklus!)) / 3600

  return judge(
    checkId,
    side,
    rowIndex,
    row.positionsnummer,
    targetField,
    expected,
    actual,
    'FK Fertigungskosten',
    'FK manufacturing costs',
    cfg,
  )
}

function evaluateAusschussFertigungCheck(
  row: QAFRow,
  rowIndex: number,
  side: ComparisonSide,
  cfg: BusinessRulesConfig,
): BusinessRuleResult | null {
  const checkId: BusinessRuleCheckId = 'rule_calc_ausschuss_fertigung'
  const targetField = 'ausschusskosten'
  const actual = row.ausschusskosten
  if (actual === null) return null

  const required = [
    manufacturingField(row, 'fkAW', 'Fertigungskosten FK [AW]'),
    manufacturingField(row, 'ausschuss', 'Ausschuss pro Prozessschritt [%]'),
  ]
  const reason = missingReason(required)
  if (reason) return notPruefbar(checkId, side, rowIndex, row.positionsnummer, targetField, reason.de, reason.en)
  if (row.ausschuss! >= 100) {
    return notPruefbar(
      checkId,
      side,
      rowIndex,
      row.positionsnummer,
      targetField,
      'Ausschuss pro Prozessschritt ist >= 100% (unplausibler Eingangswert) — Nachrechnung nicht moeglich.',
      'Scrap per process step is >= 100% (implausible input value) — recomputation not possible.',
    )
  }

  const expected = row.fkAW! * (1 / (1 - row.ausschuss! / 100) - 1)

  return judge(
    checkId,
    side,
    rowIndex,
    row.positionsnummer,
    targetField,
    expected,
    actual,
    'Ausschusskosten Fertigung',
    'Scrap costs manufacturing',
    cfg,
  )
}

// ── MATERIAL checks ──────────────────────────────────────────────────────────

function evaluateMaterialCostPerUnitAwCheck(
  row: MaterialRow,
  rowIndex: number,
  side: ComparisonSide,
  cfg: BusinessRulesConfig,
): BusinessRuleResult | null {
  const checkId: BusinessRuleCheckId = 'rule_calc_material_cost_per_unit_aw'
  const targetField = 'costPerUnitAw'
  const actual = row.costPerUnitAw
  if (actual === null) return null

  const required = [
    materialField(row, 'costPerUnitBw', 'Kalkulatorisch angesetzte Materialkosten pro Mengeneinheit [BW]'),
    materialField(row, 'exchangeRate', 'Wechselkurs [AW/BW]'),
    materialField(row, 'packagingCostPerUnit', 'Verpackungskosten je Mengeneinheit [AW]'),
    materialField(row, 'transportCostPerUnit', 'Transportkosten je Mengeneinheit [AW]'),
    materialField(row, 'customsCbamCostPerUnit', 'Zollkosten, CBAM-Kosten je Mengeneinheit [AW]'),
    materialField(row, 'overheadCost', 'Materialgemeinkosten MGK'),
  ]
  const reason = missingReason(required)
  if (reason) return notPruefbar(checkId, side, rowIndex, row.positionNumber, targetField, reason.de, reason.en)

  const expected =
    row.costPerUnitBw! * row.exchangeRate! +
    row.packagingCostPerUnit! +
    row.transportCostPerUnit! +
    row.customsCbamCostPerUnit! +
    row.overheadCost!

  return judge(
    checkId,
    side,
    rowIndex,
    row.positionNumber,
    targetField,
    expected,
    actual,
    'Kalkulatorische Materialkosten pro Mengeneinheit [AW]',
    'Calculated material cost per unit [AW]',
    cfg,
  )
}

function evaluateMaterialScrapCostCheck(
  row: MaterialRow,
  rowIndex: number,
  side: ComparisonSide,
  cfg: BusinessRulesConfig,
): BusinessRuleResult | null {
  const checkId: BusinessRuleCheckId = 'rule_calc_material_scrap_cost'
  const targetField = 'scrapCost'
  const actual = row.scrapCost
  if (actual === null) return null

  const required = [
    materialField(row, 'materialCost', 'Kalkulatorische Materialkosten [AW]'),
    materialField(row, 'scrapRate', 'Materialausschuss [%]'),
  ]
  const reason = missingReason(required)
  if (reason) return notPruefbar(checkId, side, rowIndex, row.positionNumber, targetField, reason.de, reason.en)
  if (row.scrapRate! >= 100) {
    return notPruefbar(
      checkId,
      side,
      rowIndex,
      row.positionNumber,
      targetField,
      'Materialausschuss ist >= 100% (unplausibler Eingangswert) — Nachrechnung nicht moeglich.',
      'Material scrap is >= 100% (implausible input value) — recomputation not possible.',
    )
  }

  const expected = row.materialCost! * (1 / (1 - row.scrapRate! / 100) - 1)

  return judge(
    checkId,
    side,
    rowIndex,
    row.positionNumber,
    targetField,
    expected,
    actual,
    'Ausschusskosten Material',
    'Scrap costs material',
    cfg,
  )
}

function evaluateRawMaterialSurchargeCheck(
  row: MaterialRow,
  rowIndex: number,
  side: ComparisonSide,
  cfg: BusinessRulesConfig,
): BusinessRuleResult | null {
  const checkId: BusinessRuleCheckId = 'rule_calc_raw_material_surcharge'
  const targetField = 'rawMaterialSurcharge'
  const actual = row.rawMaterialSurcharge
  if (actual === null) return null // Gating: most rows have no RMR participation.

  const required = [
    materialField(row, 'quantityPerQuotedPart', 'Anzahl pro Angebotsteil'),
    materialField(row, 'referenceWeight', 'Bezugsgewicht [kg]'),
    materialField(row, 'rawMaterialQuotation', 'Rohstoffnotierung Ro [AW/kg]'),
  ]
  const reason = missingReason(required)
  if (reason) return notPruefbar(checkId, side, rowIndex, row.positionNumber, targetField, reason.de, reason.en)

  const expected = row.quantityPerQuotedPart! * row.referenceWeight! * row.rawMaterialQuotation!

  return judge(
    checkId,
    side,
    rowIndex,
    row.positionNumber,
    targetField,
    expected,
    actual,
    'Rohstoffzuschlag RoZ0',
    'Raw material surcharge RoZ0',
    cfg,
  )
}

/** Kaufteilkosten/Rohmaterialkosten branch — Leitfaden [16]/[19]. Two
 * mutually exclusive formula variants depending on the row's Mengeneinheit /
 * whether "Bezugsmenge (nur Rohmaterial)" is filled — see module header
 * "rule_calc_material_cost" for the evidence citation. */
function evaluateMaterialCostCheck(
  row: MaterialRow,
  rowIndex: number,
  side: ComparisonSide,
  cfg: BusinessRulesConfig,
): BusinessRuleResult | null {
  const checkId: BusinessRuleCheckId = 'rule_calc_material_cost'
  const targetField = 'materialCost'
  const actual = row.materialCost
  if (actual === null) return null

  const isRmrRow = normalizeProcessName(row.unitOfMeasure) === normalizeProcessName(MATERIAL_UNIT_RAW_MATERIAL_RISK)
  if (isRmrRow) {
    return notPruefbar(
      checkId,
      side,
      rowIndex,
      row.positionNumber,
      targetField,
      'RMR-Zeile (variabler Materialkostenanteil, Mengeneinheit "Raw_material_risk_(kg)") — die Kaufteil-/Rohmaterialkosten-Formel gilt hier laut Leitfaden [17] nicht (Logistik-/MGK-/Ausschusskosten sind bei dieser Zeile bewusst nicht befuellt, siehe fixierte Zeile derselben Positionsnummer).',
      'RMR row (variable material cost share, unit of measure "Raw_material_risk_(kg)") — per Leitfaden [17], the purchased-part/raw-material-cost formula does not apply here (logistics/overhead/scrap costs are deliberately unfilled on this row, see the fixed row with the same item number).',
    )
  }

  const isRohmaterial = row.referenceQuantity !== null
  const isKaufteil =
    !isRohmaterial && normalizeProcessName(row.unitOfMeasure) === normalizeProcessName(MATERIAL_UNIT_PURCHASED_PART)

  if (isKaufteil) {
    const required = [
      materialField(row, 'costPerUnitAw', 'Kalkulatorische Materialkosten pro Mengeneinheit [AW]'),
      materialField(row, 'quantityPerQuotedPart', 'Anzahl pro Angebotsteil'),
    ]
    const reason = missingReason(required)
    if (reason) return notPruefbar(checkId, side, rowIndex, row.positionNumber, targetField, reason.de, reason.en)
    const expected = row.costPerUnitAw! * row.quantityPerQuotedPart!
    return judge(
      checkId,
      side,
      rowIndex,
      row.positionNumber,
      targetField,
      expected,
      actual,
      'Kaufteilkosten',
      'Purchased part cost',
      cfg,
    )
  }

  if (isRohmaterial) {
    const required = [
      materialField(row, 'costPerUnitAw', 'Kalkulatorische Materialkosten pro Mengeneinheit [AW]'),
      materialField(row, 'referenceQuantity', 'Bezugsmenge [Einheiten] (nur Rohmaterial)'),
      materialField(row, 'rebate', 'Kalkulatorisch angesetzte Rueckverguetung [AW] (nur Rohmaterial)'),
      materialField(row, 'quantityPerQuotedPart', 'Anzahl pro Angebotsteil'),
    ]
    const reason = missingReason(required)
    if (reason) return notPruefbar(checkId, side, rowIndex, row.positionNumber, targetField, reason.de, reason.en)
    const expected = (row.costPerUnitAw! * row.referenceQuantity! - row.rebate!) * row.quantityPerQuotedPart!
    return judge(
      checkId,
      side,
      rowIndex,
      row.positionNumber,
      targetField,
      expected,
      actual,
      'Rohmaterialkosten',
      'Raw material cost',
      cfg,
    )
  }

  return notPruefbar(
    checkId,
    side,
    rowIndex,
    row.positionNumber,
    targetField,
    `Mengeneinheit ("${row.unitOfMeasure}") und Bezugsmenge lassen keine eindeutige Zuordnung zur Kaufteil- oder Rohmaterialkosten-Formel zu.`,
    `Unit of measure ("${row.unitOfMeasure}") and reference quantity do not allow an unambiguous assignment to the purchased-part or raw-material-cost formula.`,
  )
}

// ── SBM checks ────────────────────────────────────────────────────────────────

function evaluateSbmToolFixtureCostCheck(
  row: SbmRow,
  rowIndex: number,
  side: ComparisonSide,
  cfg: BusinessRulesConfig,
): BusinessRuleResult | null {
  const checkId: BusinessRuleCheckId = 'rule_calc_sbm_tool_fixture_cost'
  const targetField = 'totalToolFixtureCostAw'
  const actual = row.totalToolFixtureCostAw
  if (actual === null) return null

  const required = [
    sbmField(row, 'toolFixtureCostBw', 'Kalkulatorisch angesetzte Werkzeug-/Vorrichtungskosten [BW]'),
    sbmField(row, 'exchangeRate', 'Wechselkurs [AW/BW]'),
    sbmField(row, 'toolFixtureCount', 'Anzahl Werkzeuge/Vorrichtungen'),
  ]
  const reason = missingReason(required)
  if (reason) return notPruefbar(checkId, side, rowIndex, row.positionNumber, targetField, reason.de, reason.en)

  const expected = row.toolFixtureCostBw! * row.exchangeRate! * row.toolFixtureCount!

  return judge(
    checkId,
    side,
    rowIndex,
    row.positionNumber,
    targetField,
    expected,
    actual,
    'Summe Werkzeug-/Vorrichtungskosten [AW]',
    'Total tool/fixture cost [AW]',
    cfg,
  )
}

// ── File-level nicht_pruefbar (materialRows/sbmRows === null) ──────────────

const MATERIAL_FILE_LEVEL_CHECKS: readonly [BusinessRuleCheckId, string][] = [
  ['rule_calc_material_cost_per_unit_aw', 'costPerUnitAw'],
  ['rule_calc_material_scrap_cost', 'scrapCost'],
  ['rule_calc_raw_material_surcharge', 'rawMaterialSurcharge'],
  ['rule_calc_material_cost', 'materialCost'],
]

function materialFileLevelNotPruefbar(side: ComparisonSide): BusinessRuleResult[] {
  return MATERIAL_FILE_LEVEL_CHECKS.map(([checkId, targetField]) =>
    notPruefbar(
      checkId,
      side,
      -1,
      '',
      targetField,
      'Kein MATERIAL-Sheet in dieser Datei erkannt (oder Header zu stark abweichend) — Nachrechnung nicht moeglich.',
      'No MATERIAL sheet detected in this file (or header too degraded) — recomputation not possible.',
    ),
  )
}

function sbmFileLevelNotPruefbar(side: ComparisonSide): BusinessRuleResult {
  return notPruefbar(
    'rule_calc_sbm_tool_fixture_cost',
    side,
    -1,
    '',
    'totalToolFixtureCostAw',
    'Kein SBM-Sheet in dieser Datei erkannt (oder Header zu stark abweichend) — Nachrechnung nicht moeglich.',
    'No SBM sheet detected in this file (or header too degraded) — recomputation not possible.',
  )
}

// ── Orchestrator ──────────────────────────────────────────────────────────────

export function evaluateBusinessRules(
  input: BusinessRuleInput,
  config: BusinessRulesConfig = BUSINESS_RULES_CONFIG,
): BusinessRuleResult[] {
  const { side, steps, materialRows, sbmRows } = input
  const results: BusinessRuleResult[] = []

  steps.forEach((row, idx) => {
    const fek = evaluateFekCheck(row, idx, side, config)
    if (fek) results.push(fek)
    const fk = evaluateFkCheck(row, idx, side, config)
    if (fk) results.push(fk)
    const ausschuss = evaluateAusschussFertigungCheck(row, idx, side, config)
    if (ausschuss) results.push(ausschuss)
  })

  if (materialRows === null) {
    results.push(...materialFileLevelNotPruefbar(side))
  } else if (materialRows) {
    materialRows.forEach((row, idx) => {
      const costPerUnitAw = evaluateMaterialCostPerUnitAwCheck(row, idx, side, config)
      if (costPerUnitAw) results.push(costPerUnitAw)
      const scrapCost = evaluateMaterialScrapCostCheck(row, idx, side, config)
      if (scrapCost) results.push(scrapCost)
      const rawMaterialSurcharge = evaluateRawMaterialSurchargeCheck(row, idx, side, config)
      if (rawMaterialSurcharge) results.push(rawMaterialSurcharge)
      const materialCost = evaluateMaterialCostCheck(row, idx, side, config)
      if (materialCost) results.push(materialCost)
    })
  }
  // materialRows undefined: tri-state "no parse attempted" -> no results at all.

  if (sbmRows === null) {
    results.push(sbmFileLevelNotPruefbar(side))
  } else if (sbmRows) {
    sbmRows.forEach((row, idx) => {
      const total = evaluateSbmToolFixtureCostCheck(row, idx, side, config)
      if (total) results.push(total)
    })
  }
  // sbmRows undefined: tri-state "no parse attempted" -> no results at all.

  return results
}

// ── Persistence bridge ───────────────────────────────────────────────────────
//
// Reuses the qaf_plausibility_issue path (no schema change), namespaced
// rule_calc_<checkId> for a breached tolerance, rule_calc_<checkId>_
// nicht_pruefbar for an incomplete basis — same pattern as reconciliation.ts's
// recon_<checkId>/recon_<checkId>_nicht_pruefbar. 'bestanden' never produces
// an issue.
//
// Severity rule (task instruction: "kritisch bei Kaskaden-relevanten
// Feldern, sonst pruefen"): 'kritisch' exactly when this check's targetField
// is a field reconciliation.ts's DETAIL_SUM_CHECKS/material_detail_sum/
// sbm_detail_sum literally sums against a Summary total (ausschusskosten via
// scrap_manufacturing_detail_sum, materialCost via material_detail_sum,
// totalToolFixtureCostAw via sbm_detail_sum) — those are the fields whose
// row-level correctness DIRECTLY determines whether the existing aggregate
// reconciliation can be trusted. Every other check here (fek, fk[BW],
// costPerUnitAw, scrapCost (MATERIAL — not summed against summary.
// scrapMaterial anywhere in reconciliation.ts today), rawMaterialSurcharge)
// is one hop further from a reconciled total, hence 'pruefen'.
const KRITISCH_CHECK_IDS = new Set<BusinessRuleCheckId>([
  'rule_calc_ausschuss_fertigung',
  'rule_calc_material_cost',
  'rule_calc_sbm_tool_fixture_cost',
])

function severityFor(checkId: BusinessRuleCheckId): PlausibilitySeverity {
  return KRITISCH_CHECK_IDS.has(checkId) ? 'kritisch' : 'pruefen'
}

/**
 * Converts one BusinessRuleResult into a PlausibilityIssue. `linkedFormulaChangeExplanation`
 * is the optional formula-engine synergy hook (module header) — the caller
 * (compare.ts) determines whether this result's (side, rowIndex, targetField)
 * corresponds to a MANUFACTURING FieldDiff with a formulaFinding and, if so,
 * passes that finding's explanation text here. Pure string append, never
 * changes status/severity — only applied to an 'abweichung', never to
 * 'bestanden' (returns null before this matters) or 'nicht_pruefbar' (no
 * comparison was even reached, linking a formula change to an unverifiable
 * result would be misleading).
 */
export function businessRuleResultToPlausibilityIssue(
  r: BusinessRuleResult,
  linkedFormulaChangeExplanation?: string,
): PlausibilityIssue | null {
  if (r.status === 'bestanden') return null

  if (r.status === 'nicht_pruefbar') {
    return {
      type: `${r.checkId}_nicht_pruefbar`,
      severity: 'hinweis',
      step: r.side,
      explanation: r.reason ?? 'Nachrechnung nicht moeglich (unvollstaendige Datenbasis).',
      explanationEn: r.reasonEn,
    }
  }

  // linkSuffix stays DE-only in both languages (KAR-906/P3.2 scope decision):
  // it quotes FieldDiff.formulaFinding.explanation, which is itself an
  // in-memory-only, never-persisted DE annotation (see types.ts FieldDiff
  // doc comment) — translating it would require threading an EN copy through
  // formula-engine.ts's FieldDiff.formulaFinding too, which is out of scope
  // (that field is documented as not surviving a DB round-trip anyway).
  const linkSuffix = linkedFormulaChangeExplanation
    ? ` ZUSAETZLICH wurde fuer dieses Feld eine Formel-Aenderung erkannt (${linkedFormulaChangeExplanation}) — Nachrechnung weicht ab UND Formel wurde geaendert: starker Manipulations-Indikator.`
    : ''
  const linkSuffixEn = linkedFormulaChangeExplanation
    ? ` ADDITIONALLY a formula change was detected for this field (${linkedFormulaChangeExplanation}) — recomputation deviates AND the formula was changed: a strong manipulation indicator.`
    : ''

  return {
    type: r.checkId,
    severity: severityFor(r.checkId),
    step: r.side,
    explanation: (r.messageDe ?? '') + linkSuffix,
    explanationEn: r.messageEn ? r.messageEn + linkSuffixEn : undefined,
  }
}

export function checkBusinessRules(
  input: BusinessRuleInput,
  config: BusinessRulesConfig = BUSINESS_RULES_CONFIG,
): PlausibilityIssue[] {
  return evaluateBusinessRules(input, config)
    .map((r) => businessRuleResultToPlausibilityIssue(r))
    .filter((x): x is PlausibilityIssue => x !== null)
}
