// QAF Summen-Rekonziliation Summary <-> Fertigungskosten-Zeilen (KAR-887 / P0.2).
//
// Deterministic, pure cross-check layer between the summary/Zusammenfassung
// sheet's aggregate money metrics (summary-metrics.ts) and the individual
// Fertigungskosten process-step rows (lib/qaf-parser.ts). Today nothing
// verifies that Sigma(row.fkAW) actually equals summary.manufacturingCosts, or
// that the summary's own cost cascade (Materialkosten + Fertigungskosten =
// SUMME HERSTELLKOSTEN etc.) is internally consistent — Master-Prompt §12.5
// ("summary totals reconcile with detail sheets") and the Leitfaden's formula
// chain ([12]/[13], 02-leitfaden-teil1.md) require exactly that.
//
// Runs PER FILE (ALT and NEU independently, not diffed against each other —
// distinct from plausibility.ts which cross-checks ALT vs NEU). Pure, no I/O.
//
// ── Implemented checks ──────────────────────────────────────────────────────
//
// (a) Detail-sum checks (row-level Sigma vs. a single summary metric):
//     - fk_detail_sum:                  Sigma(row.fkAW)          vs. manufacturingCosts
//     - scrap_manufacturing_detail_sum: Sigma(row.ausschusskosten) vs. scrapManufacturing
//
//     Uses the AW-denominated fields (fkAW/ausschusskosten), NOT the BW fields
//     (fk) the backlog item's prose names in passing — summaryMetrics.manufac-
//     turingCosts is read from the AW-currency column (summary-metrics.ts
//     locateAwColumn), while `fk` is "Fertigungskosten FK [BW]" (Beschaffungs-
//     waehrung) and `fkAW` is explicitly "Fertigungskosten in Angebotswaehrung"
//     (Leitfaden [31]) — summing the BW field against an AW total would silently
//     compare two different currencies whenever BW != AW or wechselkurs != 1.
//     ausschusskosten is already AW-only in the row model (no BW counterpart).
//
// (b) Kaskaden-Rekonziliation (summary-internal, no detail rows needed — every
//     field below is one of the 19 SUMMARY_METRIC_KEYS in summary-metrics.ts):
//     - herstellkosten_cascade:      materialCosts + manufacturingCosts        = totalProductionCosts   (Leitfaden [12])
//     - gesamtkosten_cascade:        totalProductionCosts + devicesAndTools
//                                     + scrapMaterial + scrapManufacturing     = totalCosts              (Leitfaden [13])
//     - angebotsbasispreis_cascade:  totalCosts + otherSurcharges             = quotationBasePrice       (Leitfaden [13])
//     - angebotspreis_cascade:       quotationBasePrice + rawMaterialPriceShareMaterial
//                                     + rawMaterialPriceShareEnergy + customsSupplierToBMW
//                                     + transportSupplierToBMW                = quotationPrice           (Leitfaden [13])
//
// ── Materialkosten-Detail-Rekonziliation (KAR-897/P1.6 follow-up) ──────────
//
// Sigma(row.materialCost) vs. summary.materialCosts — the check this file's
// header used to list under "Deliberately NOT implemented" (P0.2 explicitly
// deferred it "bis P1.6 existiert"). Runs ONLY when the caller actually
// attempted a MATERIAL parse for this side (input.materialRows !== undefined
// — see ReconciliationInput doc comment below): a caller that never touches
// materialRows (every pre-P1.6 test/call site, rehydrate.ts, which does not
// reconstruct MATERIAL rows) gets no new check and no new issue at all — the
// P0.2-era "fully consistent summary -> zero issues" guarantee stays intact
// for anyone who hasn't opted into MATERIAL parsing.
//
// ── Deliberately NOT implemented (documented TODO, not guessed) ────────────
//
// ── SBM-Detail-Rekonziliation (KAR-898/P1.7) ────────────────────────────────
//
// Sigma(row.totalToolFixtureCostAw) vs. summary.devicesAndTools — the check
// this file's header used to list under "Deliberately NOT implemented" (P0.2
// explicitly deferred it "nach P1.7"). Same tri-state/currency-guard/
// n.a.-marker contract as material_detail_sum above (sbmRows: undefined = no
// parse attempted, check skipped entirely; null = no SBM sheet found in the
// workbook at all OR the sheet was found but its header was too degraded to
// trust, sbm-parser.ts's sbmRowsForReconciliation() collapses both into this
// one value; SbmRow[] = the parsed rows, a CONFIRMED count including 0).
//
// One deliberate behavioural deviation from material_detail_sum: when sbmRows
// is a CONFIRMED-empty array (sbmRows.length === 0, i.e. the sheet WAS parsed
// successfully — intact header, genuinely zero data rows) AND
// summary.devicesAndTools is also absent/zero, the result is `bestanden`
// (0 == 0), NOT `nicht_pruefbar` — devicesAndTools is legitimately empty on
// the majority of real quotations (most parts carry no Sonderbetriebsmittel,
// but the SBM-DEVICES-FWZ tab is still part of the standard template with an
// intact, just-empty body), so treating that combination as a review-worthy
// finding would flood every plain comparison with a "keine SBM-Zeilen
// vorhanden" hint issue (task instruction: "kein False-Positive-Spam").
//
// Critically, this shortcut applies ONLY to a CONFIRMED-empty array, never to
// `sbmRows === null` — a degraded/unreadable header is evaluated and reported
// `nicht_pruefbar` UNCONDITIONALLY, even when the summary side is also empty
// (adversarial-review finding on the original KAR-898 PR, confidence 82: an
// earlier version folded `sbmRows === null` into the same "both sides empty"
// shortcut, which let a degraded-but-possibly-costly sheet silently pass as
// clean whenever the summary happened to have no devicesAndTools value
// either — see evaluateSbmDetailSumCheck below for the corrected ordering).
// Everything else (including the already-known reconciliation staleness gap
// tracked separately under KAR-899) is intentionally left exactly as
// lookalike to material_detail_sum, not hardened further here (task
// instruction: "baue hier exakt dasselbe (bekannt lueckenhafte) Verhalten wie
// material_detail_sum, nicht mehr und nicht weniger").
//
// AW-Waehrungsdimension ("fkAW-Lehre"): SBM rows carry their own
// quotationCurrency (Angebotswaehrung AW, Leitfaden [37]) independent of the
// MANUFACTURING/MATERIAL rows' currency — summing across a currency mismatch
// vs. the summary's currency is refused (nicht_pruefbar), identical guard to
// sumMaterialCost below.
//
// ── LOGISTICS-Detail-Rekonziliation (KAR-903/P2.4) ──────────────────────────
//
// Sigma(row.transportCostPerPart) vs. summary.transportSupplierToBMW
// (logistics_transport_detail_sum) and Sigma(row.customsCostPerDeliverySite)
// vs. summary.customsSupplierToBMW (logistics_customs_detail_sum).
//
// ── Evidenzlage + Korrektur (post-merge adversarial-review, Konfidenz 82) ──
// Die ursprüngliche Version dieses Kommentars zitierte einen Satz aus
// Leitfaden [39] ("im Summary-Blatt als 'Enthaltene Zölle, CBAM' bzw. // allow-customer-string
// 'Logistikkosten Lieferant-BMW' aggregiert einfließend"), der im // allow-customer-string
// Quellmaterial NICHT existiert — ein fabriziertes Zitat, ersatzlos entfernt
// (Verstoß gegen "never silently guess", Master-Prompt §2). Das darauf
// aufbauende Feld-Mapping war zusätzlich sachlich falsch: die tatsächlich
// real-file-verifizierten Summary-Labels (summary-metrics.ts SYNONYMS/
// METRIC_LABELS_DE, gegen echte BMW-Dateien gematcht) heißen // allow-customer-string
// "Transportkosten Lieferant - BMW" (transportSupplierToBMW) und "Zölle // allow-customer-string
// Lieferant - BMW" (customsSupplierToBMW) — NICHT "Logistikkosten // allow-customer-string
// Lieferant-BMW" bzw. "Enthaltene Zölle, CBAM" (letzteres ist tatsächlich das // allow-customer-string
// Label eines ANDEREN Metrics, customsIncluded). Der Leitfaden selbst
// dokumentiert an keiner Stelle explizit, ob "Transportkosten Lieferant -
// BMW" im Summary-Blatt nur den Transport-Anteil oder die vollen // allow-customer-string
// Logistikkosten (Transport+Verpackung+Vorverpackung, [40]) abbildet — die 9
// real verfügbaren BMW-Summary-QAFs unter // allow-customer-string
// /root/aria/work/qaf-compare-kar824/input/ (Brose FT_links/HT_links,
// Kiekert, Autoliv) enthalten AUSNAHMSLOS KEIN LOGISTICS&CUSTOM-Sheet
// (Sheet-Namen empirisch geprüft, 09.07.2026) — die Frage ist mit den
// aktuell verfügbaren Dateien nicht entscheidbar. Konservative,
// label-konsistente Wahl (kein Raten): dieser Check summiert
// `transportCostPerPart` (Transportkosten pro Bauteil Lieferant, [40]) —
// das Wort "Transportkosten" im Summary-Label deckt sich mit diesem Feld,
// nicht mit der breiteren "Logistikkosten"-Summe (die weiterhin rein
// row-intern, OHNE Summary-Pendant, per `log_calc_cost_per_delivery_site` in
// logistics-parser.ts geprüft wird — siehe dort). labelDe/labelEn der beiden
// Checks werden direkt aus METRIC_LABELS_DE abgeleitet statt erneut von Hand
// getippt, damit dieselbe Verwechslung nicht wieder unbemerkt auftreten kann.
// Sollte ein künftiges reales LOGISTICS&CUSTOM-File die kombinierte Semantik
// bestätigen, ist das eine neue, separat zu belegende Änderung — keine
// stillschweigende Korrektur im Nachhinein.
//
// Same tri-state/currency-guard/n.a.-marker contract as sbm_detail_sum above
// (logisticsRows: undefined = no parse attempted, both checks skipped
// entirely; null = an LOGISTICS parse WAS attempted but the file has none (or
// its header was too degraded to trust) — evaluated UNCONDITIONALLY as
// nicht_pruefbar, the SAME coreFieldsFound-Schutz ordering evaluateSbmDetail
// SumCheck's KAR-898-follow-up fix established (confidence 82 finding: a
// degraded/null basis must never share the "both sides empty -> bestanden"
// shortcut with a CONFIRMED-empty parsed array, task instruction "beide-leer-
// Semantik wie sbm_detail_sum NUR mit dem in #274 gefixten coreFieldsFound-
// Schutz"); LogisticsRow[] = the parsed rows, a CONFIRMED count including 0).
//
// AW-Waehrungsdimension ("fkAW-Lehre"): LOGISTICS rows carry their own
// quotationCurrency (Angebotswaehrung AW, Leitfaden [40]) independent of the
// MANUFACTURING/MATERIAL/SBM rows' currency — summing across a currency
// mismatch vs. the summary's currency is refused (nicht_pruefbar), identical
// guard to sumSbmToolFixtureCost/sumMaterialCost above.
//
// Both-sides-CONFIRMED-empty -> bestanden shortcut: same "unauffaellig, not
// nicht_pruefbar" reasoning sbm_detail_sum documents (most parts carry no
// customs cost at all, e.g. an intra-EU delivery route; the LOGISTICS&CUSTOM
// tab is still part of the standard template with an intact, just-empty
// body) — applies independently to EACH of the two checks (a file can
// legitimately have Transportkosten but zero Zollkosten, or vice versa).
//
// Severity bleibt 'pruefen' fuer beide Checks (DETAIL_CHECK_IDS-Zugehoerigkeit
// unten, wie jeder andere Detail-Sum-Check in diesem Modul) — nie 'kritisch'.
// Das war bereits vor dieser Korrektur so; die unbestaetigte Semantik ist ein
// zusaetzlicher Grund, hier bewusst NICHT zu eskalieren.
//
// ── Deliberately NOT implemented (documented TODO, not guessed) ────────────
//
// - G60-Aggregat-Rekonziliation: G60 has its own aggregate logic/parser
//   (internal/g60/*) and is explicitly out of scope for P0.2 ("G60 hat eigene
//   Aggregatlogik, siehe P1.3").
// - Rohstoffzuschlag RoZ0-Detail (Leitfaden [31]) has no persisted per-row
//   field in QAFRowValues today — not summable, not guessed at.

import type { QAFFieldKey, QAFRow } from '@/lib/qaf-parser'
import { isNotApplicableValue, normalizeCurrency } from './normalizer'
import { METRIC_LABELS_DE, type SummaryMetricKey, type SummaryMetricsParse } from './summary-metrics'
import type { ComparisonSide } from './rule-engine'
import type { PlausibilityIssue, PlausibilitySeverity } from './plausibility'
import type { MaterialRow } from './material-parser'
import type { SbmRow } from './sbm-parser'
import type { LogisticsRow } from './logistics-parser'
import { SUMMARY_METRIC_KEY_TO_CANONICAL } from './canonical-fields'
import { byCanonicalId } from './canonical-model'

/** EN label for a SummaryMetricKey, sourced from the canonical field
 * registry (KAR-906/P3.2 task instruction: use canonical labelEn instead of
 * ad-hoc translation) — falls back to the raw key on a registry miss (should
 * never happen for the 19 SUMMARY_METRIC_KEYS, which canonical-fields.ts
 * documents as FULL coverage). */
function metricLabelEn(key: SummaryMetricKey): string {
  const canonicalId = SUMMARY_METRIC_KEY_TO_CANONICAL[key]
  return byCanonicalId(canonicalId)?.labelEn ?? key
}

export type ReconciliationCheckId =
  | 'fk_detail_sum'
  | 'scrap_manufacturing_detail_sum'
  | 'material_detail_sum'
  | 'sbm_detail_sum'
  | 'logistics_transport_detail_sum'
  | 'logistics_customs_detail_sum'
  | 'herstellkosten_cascade'
  | 'gesamtkosten_cascade'
  | 'angebotsbasispreis_cascade'
  | 'angebotspreis_cascade'

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

export interface ReconciliationResult {
  checkId: ReconciliationCheckId
  side: ComparisonSide
  status: ReconciliationStatus
  /** The value derived independently from the other numbers (row-sum or component-sum). */
  expected: number | null
  /** The pre-existing summary metric the derived value is expected to match. */
  actual: number | null
  /** actual - expected. null unless both sides are known (status !== 'nicht_pruefbar'). */
  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
}

export interface ReconciliationInput {
  side: ComparisonSide
  steps: QAFRow[]
  summaryMetrics: SummaryMetricsParse
  /**
   * MATERIAL rows for this side (KAR-897/P1.6), tri-state:
   *   - `undefined` (default, omitted): the caller never attempted a MATERIAL
   *     parse for this file — material_detail_sum is NOT included in the
   *     result set at all (not even as nicht_pruefbar). This is what keeps
   *     every pre-P1.6 caller (and rehydrate.ts, which does not reconstruct
   *     MATERIAL rows) byte-for-byte unaffected.
   *   - `null`: a MATERIAL parse WAS attempted but the file has no MATERIAL
   *     sheet (or its header was too degraded to trust) — the check runs and
   *     reports nicht_pruefbar with a reason, per task instruction ("nur wenn
   *     MATERIAL-Sheet geparst wurde, sonst weiterhin nicht_pruefbar mit
   *     Grund").
   *   - `MaterialRow[]`: the parsed rows (can legitimately be empty) — the
   *     check sums materialCost across them.
   */
  materialRows?: MaterialRow[] | null
  /**
   * SBM-DEVICES-FWZ rows for this side (KAR-898/P1.7), same tri-state
   * contract as materialRows above:
   *   - `undefined` (default, omitted): no SBM parse was attempted —
   *     sbm_detail_sum is NOT included in the result set at all.
   *   - `null`: an SBM parse WAS attempted but the file has no SBM sheet (or
   *     its header was too degraded to trust).
   *   - `SbmRow[]`: the parsed rows (can legitimately be empty).
   *
   * Unlike materialRows, an empty/absent basis on BOTH sides (no rows AND no/
   * zero summary.devicesAndTools) is `bestanden`, not `nicht_pruefbar` — see
   * module header "SBM-Detail-Rekonziliation" for why.
   */
  sbmRows?: SbmRow[] | null
  /**
   * LOGISTICS&CUSTOM rows (KAR-903/P2.4), same tri-state contract as sbmRows
   * above:
   *   - `undefined` (default, omitted): no LOGISTICS parse was attempted —
   *     BOTH logistics_transport_detail_sum and logistics_customs_detail_sum
   *     are NOT included in the result set at all.
   *   - `null`: a LOGISTICS parse WAS attempted but the file has no LOGISTICS
   *     sheet (or its header was too degraded to trust).
   *   - `LogisticsRow[]`: the parsed rows (can legitimately be empty).
   *
   * Same "both-sides-CONFIRMED-empty -> bestanden" exception as sbmRows,
   * applied independently per check — see module header "LOGISTICS-Detail-
   * Rekonziliation" for why.
   */
  logisticsRows?: LogisticsRow[] | null
}

/**
 * Central tolerance config — one section of DEFAULT_ENGINE_CONFIG since
 * P1.5/KAR-896 (engine-config.ts assembles it verbatim, does not redefine
 * it). Two components combine via max(): a relative percentage (catches
 * proportionally large mismatches) and an absolute floor in the offer
 * currency's minor unit (catches rounding noise on small totals where 0.5%
 * would be sub-cent and trigger on FP noise).
 *
 * FACHENTSCHEID (Kais, KAR-896 comment 10.07.2026): 0.5% relativ + 1 AW
 * absolut ist fachlich freigegeben als STARTWERT — nicht als für alle Zeit
 * festgeschriebene Zahl. Eine spätere Anpassung ist ausdrücklich möglich
 * (z.B. über ein künftiges DB-backed Config-Item), braucht dann aber wieder
 * eine bewusste Freigabe, kein stilles Code-Tuning.
 */
export interface ReconciliationConfig {
  /** Fraction, e.g. 0.005 = 0.5%. */
  relativeTolerance: number
  /** Absolute floor in AW, applied via max(relative, absolute). */
  absoluteToleranceMinor: number
}

export const RECONCILIATION_CONFIG: ReconciliationConfig = {
  relativeTolerance: 0.005,
  absoluteToleranceMinor: 1,
}

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

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

function judge(
  checkId: ReconciliationCheckId,
  side: ComparisonSide,
  expected: number,
  actual: number,
  labelDe: string,
  labelEn: string,
  cfg: ReconciliationConfig,
): ReconciliationResult {
  const deltaAbsolute = Number((actual - expected).toFixed(4))
  const deltaPercent = expected !== 0 ? Number((deltaAbsolute / Math.abs(expected)).toFixed(6)) : null
  const ok = withinTolerance(expected, actual, cfg)
  const messageDe = `${labelDe} (${side}): Summary weist ${fmt(actual)} aus, die Gegenrechnung ergibt ${fmt(expected)} (Delta ${fmt(deltaAbsolute)}) — Abweichung ausserhalb der Toleranz (${(cfg.relativeTolerance * 100).toFixed(1)} % / min. ${cfg.absoluteToleranceMinor}).`
  const messageEn = `${labelEn} (${side}): summary 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,
    status: ok ? 'bestanden' : 'abweichung',
    expected,
    actual,
    deltaAbsolute,
    deltaPercent,
    ...(ok ? {} : { messageDe, messageEn }),
  }
}

function notPruefbar(
  checkId: ReconciliationCheckId,
  side: ComparisonSide,
  reason: string,
  reasonEn: string,
): ReconciliationResult {
  return {
    checkId,
    side,
    status: 'nicht_pruefbar',
    expected: null,
    actual: null,
    deltaAbsolute: null,
    deltaPercent: null,
    reason,
    reasonEn,
  }
}

// ── Bilingual "nicht_pruefbar" reason templates (KAR-906/P3.2) ──────────────
//
// Every notPruefbar() call site below reports one of exactly 5 recurring
// reason shapes (metric missing from summary, no detail rows, currency
// mismatch across rows, unclear/unparseable row values, or no sheet found at
// all) — factored here once instead of hand-translating each call site
// independently, so the DE/EN pair cannot drift apart. `sheetLabel` is a
// BMW-template tab/section name (e.g. "Fertigungskosten", "MATERIAL", "SBM", // allow-customer-string
// "LOGISTICS&CUSTOM") — deliberately NOT translated (same precedent as
// rule-engine.ts's R4 message, which keeps „Fertigungskosten" verbatim in its
// EN text too: these are proper section names, not prose).

function metricMissingReason(labelDe: string, labelEn: string) {
  return {
    de: `${labelDe} nicht im Summary gefunden (kein Wert extrahiert) — Rekonziliation nicht moeglich.`,
    en: `${labelEn} not found in the summary (no value extracted) — reconciliation not possible.`,
  }
}

function noRowsReason(sheetLabel: string) {
  return {
    de: `Keine ${sheetLabel}-Zeilen vorhanden — keine Gegenrechnung moeglich.`,
    en: `No ${sheetLabel} rows present — no counter-calculation possible.`,
  }
}

function currencyMismatchReason(sheetLabel: string) {
  return {
    de: `Angebotswaehrung einzelner ${sheetLabel}-Zeilen weicht von der Summary-Waehrung ab — Summierung ueber Waehrungen hinweg nicht belastbar.`,
    en: `Quotation currency (AW) of individual ${sheetLabel} rows differs from the summary currency — summing across currencies is not reliable.`,
  }
}

function unclearValueReason(count: number, sheetLabel: string) {
  return {
    de: `${count} ${sheetLabel}-Zeile(n) mit unklarem Wert (weder Zahl noch erkennbarer n.a.-Marker) — Summe nicht belastbar.`,
    en: `${count} ${sheetLabel} row(s) with an unclear value (neither a number nor a recognizable n.a. marker) — sum is not reliable.`,
  }
}

function noSheetReason(sheetLabel: string) {
  return {
    de: `Kein ${sheetLabel}-Sheet in dieser Datei erkannt (oder Header zu stark abweichend) — Rekonziliation nicht moeglich.`,
    en: `No ${sheetLabel} sheet detected in this file (or header too degraded) — reconciliation not possible.`,
  }
}

// ── (a) Detail-sum checks ────────────────────────────────────────────────────

interface DetailSumSpec {
  checkId: ReconciliationCheckId
  summaryKey: SummaryMetricKey
  rowField: Extract<QAFFieldKey, 'fkAW' | 'ausschusskosten'>
  labelDe: string
  labelEn: string
}

const DETAIL_SUM_CHECKS: readonly DetailSumSpec[] = [
  {
    checkId: 'fk_detail_sum',
    summaryKey: 'manufacturingCosts',
    rowField: 'fkAW',
    labelDe: 'Fertigungskosten',
    labelEn: 'Manufacturing costs',
  },
  {
    checkId: 'scrap_manufacturing_detail_sum',
    summaryKey: 'scrapManufacturing',
    rowField: 'ausschusskosten',
    labelDe: 'Ausschusskosten Fertigung',
    labelEn: 'Scrap costs manufacturing',
  },
]

interface RowSum {
  sum: number
  blockingRows: number
  currencyMismatch: boolean
}

function sumRowField(steps: QAFRow[], field: DetailSumSpec['rowField'], summaryCurrency: string | null): RowSum {
  let sum = 0
  let blockingRows = 0
  let currencyMismatch = false
  for (const row of steps) {
    const rowCurrency = normalizeCurrency(row.angebotswaehrung)
    if (summaryCurrency && rowCurrency && rowCurrency !== summaryCurrency) currencyMismatch = true

    const value = row[field]
    if (typeof value === 'number') {
      sum += value
      continue
    }
    const rawText = row.rawText?.[field]
    if (rawText !== undefined && isNotApplicableValue(rawText)) continue // consciously zero contribution
    blockingRows++ // genuinely empty or unparseable — the whole sum becomes unreliable
  }
  return { sum, blockingRows, currencyMismatch }
}

function evaluateDetailSumCheck(
  spec: DetailSumSpec,
  side: ComparisonSide,
  steps: QAFRow[],
  summaryMetrics: SummaryMetricsParse,
  cfg: ReconciliationConfig,
): ReconciliationResult {
  const actual = summaryMetrics.metrics[spec.summaryKey]?.value ?? null
  if (actual === null) {
    const r = metricMissingReason(spec.labelDe, spec.labelEn)
    return notPruefbar(spec.checkId, side, r.de, r.en)
  }
  if (steps.length === 0) {
    const r = noRowsReason('Fertigungskosten')
    return notPruefbar(spec.checkId, side, r.de, r.en)
  }

  const { sum, blockingRows, currencyMismatch } = sumRowField(steps, spec.rowField, summaryMetrics.currency)
  if (currencyMismatch) {
    const r = currencyMismatchReason('Fertigungskosten')
    return notPruefbar(spec.checkId, side, r.de, r.en)
  }
  if (blockingRows > 0) {
    const r = unclearValueReason(blockingRows, 'Fertigungskosten')
    return notPruefbar(spec.checkId, side, r.de, r.en)
  }

  return judge(spec.checkId, side, sum, actual, spec.labelDe, spec.labelEn, cfg)
}

// ── (a2) Materialkosten-Detail-Rekonziliation (KAR-897/P1.6) ────────────────
//
// Structurally the same shape as evaluateDetailSumCheck above (row-Sigma vs.
// one summary metric), but MaterialRow is a distinct row type from QAFRow
// (different field domain, no shared QAFFieldKey) so it gets its own
// evaluator rather than being force-fit into DetailSumSpec/sumRowField.

function sumMaterialCost(rows: readonly MaterialRow[], summaryCurrency: string | null): RowSum {
  let sum = 0
  let blockingRows = 0
  let currencyMismatch = false
  for (const row of rows) {
    const rowCurrency = normalizeCurrency(row.quotationCurrency)
    if (summaryCurrency && rowCurrency && rowCurrency !== summaryCurrency) currencyMismatch = true

    const value = row.materialCost
    if (typeof value === 'number') {
      sum += value
      continue
    }
    const rawText = row.rawText?.materialCost
    if (rawText !== undefined && isNotApplicableValue(rawText)) continue // consciously zero contribution
    blockingRows++
  }
  return { sum, blockingRows, currencyMismatch }
}

function evaluateMaterialDetailSumCheck(
  side: ComparisonSide,
  materialRows: MaterialRow[] | null,
  summaryMetrics: SummaryMetricsParse,
  cfg: ReconciliationConfig,
): ReconciliationResult {
  const checkId: ReconciliationCheckId = 'material_detail_sum'
  const labelDe = 'Materialkosten'
  const labelEn = 'Material costs'

  if (materialRows === null) {
    const r = noSheetReason('MATERIAL')
    return notPruefbar(checkId, side, r.de, r.en)
  }

  const actual = summaryMetrics.metrics.materialCosts?.value ?? null
  if (actual === null) {
    const r = metricMissingReason(labelDe, labelEn)
    return notPruefbar(checkId, side, r.de, r.en)
  }
  if (materialRows.length === 0) {
    const r = noRowsReason('MATERIAL')
    return notPruefbar(checkId, side, r.de, r.en)
  }

  const { sum, blockingRows, currencyMismatch } = sumMaterialCost(materialRows, summaryMetrics.currency)
  if (currencyMismatch) {
    const r = currencyMismatchReason('MATERIAL')
    return notPruefbar(checkId, side, r.de, r.en)
  }
  if (blockingRows > 0) {
    const r = unclearValueReason(blockingRows, 'MATERIAL')
    return notPruefbar(checkId, side, r.de, r.en)
  }

  return judge(checkId, side, sum, actual, labelDe, labelEn, cfg)
}

// ── (a3) SBM-Detail-Rekonziliation (KAR-898/P1.7) ───────────────────────────
//
// Structurally the same shape as evaluateMaterialDetailSumCheck above, plus
// the deliberate "both sides empty -> bestanden" exception documented in the
// module header.

function sumSbmToolFixtureCost(rows: readonly SbmRow[], summaryCurrency: string | null): RowSum {
  let sum = 0
  let blockingRows = 0
  let currencyMismatch = false
  for (const row of rows) {
    const rowCurrency = normalizeCurrency(row.quotationCurrency)
    if (summaryCurrency && rowCurrency && rowCurrency !== summaryCurrency) currencyMismatch = true

    const value = row.totalToolFixtureCostAw
    if (typeof value === 'number') {
      sum += value
      continue
    }
    const rawText = row.rawText?.totalToolFixtureCostAw
    if (rawText !== undefined && isNotApplicableValue(rawText)) continue // consciously zero contribution
    blockingRows++
  }
  return { sum, blockingRows, currencyMismatch }
}

function evaluateSbmDetailSumCheck(
  side: ComparisonSide,
  sbmRows: SbmRow[] | null,
  summaryMetrics: SummaryMetricsParse,
  cfg: ReconciliationConfig,
): ReconciliationResult {
  const checkId: ReconciliationCheckId = 'sbm_detail_sum'
  const labelDe = 'Vorrichtungen und Folgewerkzeuge'
  const labelEn = 'Devices and tools'

  const actual = summaryMetrics.metrics.devicesAndTools?.value ?? null

  // sbmRows === null covers TWO situations sbm-parser.ts's
  // sbmRowsForReconciliation() deliberately collapses into one value: no
  // SBM-DEVICES-FWZ sheet in the workbook at all, OR the sheet exists but
  // its header was too degraded to trust (coreFieldsFound: false). In
  // EITHER case we genuinely do not know whether real, un-read SBM cost
  // rows exist — this must NEVER be treated as "confirmed empty" and fall
  // into the "both sides empty -> bestanden" shortcut below, even when the
  // summary side is also empty (adversarial-review finding on the original
  // KAR-898 PR, confidence 82: collapsing this to the shortcut let a
  // degraded-but-possibly-costly sheet silently pass as clean). This check
  // is intentionally evaluated BEFORE the shortcut, not folded into it.
  if (sbmRows === null) {
    const r = noSheetReason('SBM')
    return notPruefbar(checkId, side, r.de, r.en)
  }

  // Both sides CONFIRMED empty — the SBM sheet was parsed successfully (an
  // intact header was located; sbmRows is a genuine, not a degraded-then-
  // collapsed, empty array) and legitimately carries zero data rows, AND
  // the summary has no/zero devicesAndTools value. This is the normal,
  // unremarkable case for the majority of parts (most quotations carry no
  // Sonderbetriebsmittel, but the SBM-DEVICES-FWZ tab is still part of the
  // standard template with an intact — just empty — body) — "unauffaellig",
  // not "nicht_pruefbar", see module header. judge() with expected=actual=0
  // always yields 'bestanden' (0 is always within tolerance of itself).
  if (sbmRows.length === 0 && (actual === null || actual === 0)) {
    return judge(checkId, side, 0, actual ?? 0, labelDe, labelEn, cfg)
  }

  if (actual === null) {
    const r = metricMissingReason(labelDe, labelEn)
    return notPruefbar(checkId, side, r.de, r.en)
  }

  if (sbmRows.length === 0) {
    const r = noRowsReason('SBM')
    return notPruefbar(checkId, side, r.de, r.en)
  }

  const { sum, blockingRows, currencyMismatch } = sumSbmToolFixtureCost(sbmRows, summaryMetrics.currency)
  if (currencyMismatch) {
    const r = currencyMismatchReason('SBM')
    return notPruefbar(checkId, side, r.de, r.en)
  }
  if (blockingRows > 0) {
    const r = unclearValueReason(blockingRows, 'SBM')
    return notPruefbar(checkId, side, r.de, r.en)
  }

  return judge(checkId, side, sum, actual, labelDe, labelEn, cfg)
}

// ── (a4) LOGISTICS-Detail-Rekonziliation (KAR-903/P2.4) ─────────────────────
//
// Structurally the same shape as evaluateSbmDetailSumCheck above, generalized
// over ONE row field at a time (called twice — once per summary key — rather
// than duplicated as two near-identical functions, since the only difference
// between the two checks is which LogisticsRow field/SummaryMetricKey pair
// they sum/compare).

interface LogisticsDetailSumSpec {
  checkId: ReconciliationCheckId
  summaryKey: SummaryMetricKey
  rowField: 'transportCostPerPart' | 'customsCostPerDeliverySite'
  /** English label — hand-authored (no METRIC_LABELS_EN exists), kept
   * consistent with summary-metrics.ts's real-file-verified EN SYNONYMS
   * variant for this metric (see module header "Evidenzlage + Korrektur").
   * labelDe is NOT hand-authored here (post-merge adversarial-review fix,
   * confidence 82: a hand-typed labelDe previously drifted from the actual
   * summaryKey — "Logistikkosten Lieferant-BMW"/"Enthaltene Zoelle, CBAM" // allow-customer-string
   * neither of which is this metric's real label) — sourced directly from
   * METRIC_LABELS_DE[summaryKey] below instead, so the two can never drift
   * apart again. */
  labelEn: string
}

const LOGISTICS_DETAIL_SUM_CHECKS: readonly LogisticsDetailSumSpec[] = [
  {
    checkId: 'logistics_transport_detail_sum',
    summaryKey: 'transportSupplierToBMW',
    // Transport-only (NOT the combined logisticsCostPerDeliverySite) — see
    // module header "Evidenzlage + Korrektur": the real, code-verified
    // summary label says "Transportkosten Lieferant - BMW", and no real file // allow-customer-string
    // with a LOGISTICS&CUSTOM sheet was available to empirically confirm a
    // broader combined semantic, so the label-consistent (transport-only)
    // reading is used instead of guessing wider.
    rowField: 'transportCostPerPart',
    labelEn: 'Transport supplier -> BMW', // allow-customer-string
  },
  {
    checkId: 'logistics_customs_detail_sum',
    summaryKey: 'customsSupplierToBMW',
    rowField: 'customsCostPerDeliverySite',
    labelEn: 'Customs supplier -> BMW', // allow-customer-string
  },
]

function sumLogisticsField(
  rows: readonly LogisticsRow[],
  field: LogisticsDetailSumSpec['rowField'],
  summaryCurrency: string | null,
): RowSum {
  let sum = 0
  let blockingRows = 0
  let currencyMismatch = false
  for (const row of rows) {
    const rowCurrency = normalizeCurrency(row.quotationCurrency)
    if (summaryCurrency && rowCurrency && rowCurrency !== summaryCurrency) currencyMismatch = true

    const value = row[field]
    if (typeof value === 'number') {
      sum += value
      continue
    }
    const rawText = row.rawText?.[field]
    if (rawText !== undefined && isNotApplicableValue(rawText)) continue // consciously zero contribution
    blockingRows++
  }
  return { sum, blockingRows, currencyMismatch }
}

function evaluateLogisticsDetailSumCheck(
  spec: LogisticsDetailSumSpec,
  side: ComparisonSide,
  logisticsRows: LogisticsRow[] | null,
  summaryMetrics: SummaryMetricsParse,
  cfg: ReconciliationConfig,
): ReconciliationResult {
  // logisticsRows === null covers TWO situations logistics-parser.ts's
  // logisticsRowsForReconciliation() deliberately collapses into one value:
  // no LOGISTICS&CUSTOM sheet in the workbook at all, OR the sheet exists but
  // its header was too degraded to trust (coreFieldsFound: false). Evaluated
  // BEFORE the both-sides-empty shortcut below, UNCONDITIONALLY, exactly like
  // evaluateSbmDetailSumCheck's KAR-898-follow-up-fixed ordering (task
  // instruction: "NUR mit dem in #274 gefixten coreFieldsFound-Schutz") — a
  // degraded-but-possibly-costly sheet must never silently pass as clean just
  // because the summary side also happens to be empty.
  if (logisticsRows === null) {
    const r = noSheetReason('LOGISTICS&CUSTOM')
    return notPruefbar(spec.checkId, side, r.de, r.en)
  }

  // labelDe is sourced from METRIC_LABELS_DE[spec.summaryKey] — the same
  // real-file-verified label table CASCADE_CHECKS already uses for its own
  // "missing metric" messages below — instead of a hand-typed LogisticsDetail
  // SumSpec.labelDe field, so the (checkId, summaryKey, displayed label)
  // triple structurally cannot drift apart again (see LogisticsDetailSumSpec
  // doc comment / module header "Evidenzlage + Korrektur").
  const labelDe = METRIC_LABELS_DE[spec.summaryKey]
  const actual = summaryMetrics.metrics[spec.summaryKey]?.value ?? null

  // Both sides CONFIRMED empty — the LOGISTICS sheet was parsed successfully
  // (an intact header was located; logisticsRows is a genuine, not a
  // degraded-then-collapsed, empty array) and legitimately carries zero data
  // rows, AND the summary has no/zero value for this metric. Unremarkable —
  // see module header "LOGISTICS-Detail-Rekonziliation".
  if (logisticsRows.length === 0 && (actual === null || actual === 0)) {
    return judge(spec.checkId, side, 0, actual ?? 0, labelDe, spec.labelEn, cfg)
  }

  if (actual === null) {
    const r = metricMissingReason(labelDe, spec.labelEn)
    return notPruefbar(spec.checkId, side, r.de, r.en)
  }

  if (logisticsRows.length === 0) {
    const r = noRowsReason('LOGISTICS')
    return notPruefbar(spec.checkId, side, r.de, r.en)
  }

  const { sum, blockingRows, currencyMismatch } = sumLogisticsField(logisticsRows, spec.rowField, summaryMetrics.currency)
  if (currencyMismatch) {
    const r = currencyMismatchReason('LOGISTICS')
    return notPruefbar(spec.checkId, side, r.de, r.en)
  }
  if (blockingRows > 0) {
    const r = unclearValueReason(blockingRows, 'LOGISTICS')
    return notPruefbar(spec.checkId, side, r.de, r.en)
  }

  return judge(spec.checkId, side, sum, actual, labelDe, spec.labelEn, cfg)
}

// ── (b) Kaskaden-Rekonziliation ──────────────────────────────────────────────

interface CascadeSpec {
  checkId: ReconciliationCheckId
  componentKeys: readonly SummaryMetricKey[]
  resultKey: SummaryMetricKey
  labelDe: string
  labelEn: string
}

const CASCADE_CHECKS: readonly CascadeSpec[] = [
  {
    checkId: 'herstellkosten_cascade',
    componentKeys: ['materialCosts', 'manufacturingCosts'],
    resultKey: 'totalProductionCosts',
    labelDe: 'Summe Herstellkosten',
    labelEn: 'Total production costs',
  },
  {
    checkId: 'gesamtkosten_cascade',
    componentKeys: ['totalProductionCosts', 'devicesAndTools', 'scrapMaterial', 'scrapManufacturing'],
    resultKey: 'totalCosts',
    labelDe: 'Gesamtkosten',
    labelEn: 'Total costs',
  },
  {
    checkId: 'angebotsbasispreis_cascade',
    componentKeys: ['totalCosts', 'otherSurcharges'],
    resultKey: 'quotationBasePrice',
    labelDe: 'Angebotsbasispreis',
    labelEn: 'Quotation base price',
  },
  {
    checkId: 'angebotspreis_cascade',
    componentKeys: [
      'quotationBasePrice',
      'rawMaterialPriceShareMaterial',
      'rawMaterialPriceShareEnergy',
      'customsSupplierToBMW',
      'transportSupplierToBMW',
    ],
    resultKey: 'quotationPrice',
    labelDe: 'Angebotspreis',
    labelEn: 'Quotation price',
  },
]

function evaluateCascadeCheck(
  spec: CascadeSpec,
  side: ComparisonSide,
  summaryMetrics: SummaryMetricsParse,
  cfg: ReconciliationConfig,
): ReconciliationResult {
  const missing: SummaryMetricKey[] = []
  let expected = 0
  for (const key of spec.componentKeys) {
    const v = summaryMetrics.metrics[key]?.value ?? null
    if (v === null) missing.push(key)
    else expected += v
  }
  const actual = summaryMetrics.metrics[spec.resultKey]?.value ?? null
  if (actual === null) missing.push(spec.resultKey)

  if (missing.length > 0) {
    const namesDe = missing.map((k) => METRIC_LABELS_DE[k]).join(', ')
    const namesEn = missing.map((k) => metricLabelEn(k)).join(', ')
    return notPruefbar(
      spec.checkId,
      side,
      `${spec.labelDe}-Rekonziliation nicht moeglich, folgende Kennzahl(en) fehlen im Summary: ${namesDe}.`,
      `${spec.labelEn} reconciliation not possible, the following metric(s) are missing in the summary: ${namesEn}.`,
    )
  }

  return judge(spec.checkId, side, expected, actual as number, spec.labelDe, spec.labelEn, cfg)
}

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

export function evaluateReconciliation(
  input: ReconciliationInput,
  config: ReconciliationConfig = RECONCILIATION_CONFIG,
): ReconciliationResult[] {
  const { side, steps, summaryMetrics, materialRows, sbmRows, logisticsRows } = input
  return [
    ...DETAIL_SUM_CHECKS.map((spec) => evaluateDetailSumCheck(spec, side, steps, summaryMetrics, config)),
    // Only included when the caller actually attempted a MATERIAL/SBM/
    // LOGISTICS parse for this side (materialRows/sbmRows/logisticsRows !==
    // undefined) — see ReconciliationInput doc comments for the tri-state
    // contract.
    ...(materialRows !== undefined ? [evaluateMaterialDetailSumCheck(side, materialRows, summaryMetrics, config)] : []),
    ...(sbmRows !== undefined ? [evaluateSbmDetailSumCheck(side, sbmRows, summaryMetrics, config)] : []),
    ...(logisticsRows !== undefined
      ? LOGISTICS_DETAIL_SUM_CHECKS.map((spec) =>
          evaluateLogisticsDetailSumCheck(spec, side, logisticsRows, summaryMetrics, config),
        )
      : []),
    ...CASCADE_CHECKS.map((spec) => evaluateCascadeCheck(spec, side, summaryMetrics, config)),
  ]
}

// ── Persistence bridge ───────────────────────────────────────────────────────
//
// Reuses the qaf_plausibility_issue path (no schema change), namespaced via
// issue_type like the rule-engine (recon_<checkId> for a breached tolerance,
// recon_<checkId>_nicht_pruefbar for an incomplete basis). 'bestanden' never
// produces an issue — a passing reconciliation is silent, exactly like the
// existing plausibility checks ("bei uebereinstimmenden Summen keine Warnung").
const DETAIL_CHECK_IDS = new Set<ReconciliationCheckId>([
  ...DETAIL_SUM_CHECKS.map((s) => s.checkId),
  'material_detail_sum',
  'sbm_detail_sum',
  ...LOGISTICS_DETAIL_SUM_CHECKS.map((s) => s.checkId),
])

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

export function reconciliationResultToPlausibilityIssue(r: ReconciliationResult): PlausibilityIssue | null {
  if (r.status === 'bestanden') return null

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

  return {
    type: `recon_${r.checkId}`,
    severity: severityFor(r.checkId),
    step: r.side,
    explanation: r.messageDe ?? '',
    explanationEn: r.messageEn,
  }
}

export function checkReconciliation(
  input: ReconciliationInput,
  config: ReconciliationConfig = RECONCILIATION_CONFIG,
): PlausibilityIssue[] {
  return evaluateReconciliation(input, config)
    .map(reconciliationResultToPlausibilityIssue)
    .filter((x): x is PlausibilityIssue => x !== null)
}
