// Summary-sheet metric extraction + diff (KAR-840 engine foundation).
//
// Ports the verified V11 summary normalizer: the 19 canonical money metrics of
// the Zusammenfassung/SUMMARY sheet are located via label anchoring in column G
// (expected-row prior ±3), the value column is resolved from the quotation
// currency code (C26 legacy / C25 V9) matched against the row-10 column
// headers M..R (fallback P). scrapManufacturing has no own label and is read
// from the row below scrapMaterial; the one-time payment block hangs below its
// 'Einmalzahlungen' anchor. Every value keeps its A1 provenance.
//
// Pure: operates on the same 0-based row-major grid as summary-parser.ts.
// The diff side reuses the shared status bands from differ.ts so summary rows
// and step rows speak the same status language (qaf_summary_diff.status).

import { parseLocaleNumber, normalizeCurrency } from './normalizer'
import { a1, cellToString, parseA1 } from './summary-parser'
import { computeNumericDelta } from './differ'
import type { DiffStatus } from './types'
import type { ValueState } from './cell-state'
import {
  buildFormulaProvenance,
  unresolvedFormulaProvenance,
  compareFormulaPair,
  SHARED_FORMULA_UNRESOLVED,
  type FormulaProvenance,
} from './formula-engine'

// Order mirrors V11's SUMMARY_DIFF_ORDER — it is also the display order.
export const SUMMARY_METRIC_KEYS = [
  'materialCosts',
  'manufacturingCosts',
  'totalProductionCosts',
  'packagingTransportIncluded',
  'customsIncluded',
  'devicesAndTools',
  'scrapMaterial',
  'scrapManufacturing',
  'totalCosts',
  'otherSurcharges',
  'quotationBasePrice',
  'rawMaterialPriceShareMaterial',
  'rawMaterialPriceShareEnergy',
  'customsSupplierToBMW',
  'transportSupplierToBMW',
  'quotationPrice',
  'oneTimeDevelopment',
  'oneTimeTools',
  'totalOneTimePayment',
  // KAR-910/fehlerreport-analyse.md §5 Fall #23 — no Leitfaden coverage at
  // all (see canonical-fields.ts sum_cost_breakdown_aw1 for the full
  // evidence writeup); located via locateRowUnanchored below, not the
  // TEMPLATE_CONFIG.rows expected-row prior every other metric uses.
  'costBreakdownAw1',
] as const

export type SummaryMetricKey = (typeof SUMMARY_METRIC_KEYS)[number]

/** V11 template vocabulary — persisted to qaf_file.template_type. */
export type SummaryTemplateType = 'QAF_LEGACY_DE_SUMMARY' | 'QAF_V9_SUMMARY'

export type MetricLocateMethod = 'labelMatch' | 'fixedRow' | 'aggregate' | 'labelCollision'

export interface SummaryMetricValue {
  value: number | null
  /** A1 address in the summary sheet, null when nothing was read. */
  cell: string | null
  howLocated: MetricLocateMethod
  /** 0..1 — 1 label-anchored, 0.6 fixed-row fallback, 0 collision/absent. */
  confidence: number
  /**
   * Label wörtlich aus Spalte G der gelesenen Zeile (KAR-996 / V2 R-04) — null,
   * wenn die Zeile dort nichts trägt oder gar keine Zeile bestimmt wurde.
   * Bewusst unnormalisiert: die Anzeige soll zeigen, was in der Datei steht.
   */
  labelFile: string | null
  /**
   * Passt `labelFile` zur Synonymliste der Metrik?
   *
   * `true` label-verifiziert · `false` Zeile über den Zeilen-Prior gelesen,
   * das Label bestätigt sie NICHT (Befund F-02: Block 7 trägt "JIS", während
   * die Synonymliste dort das Zoll-Label erwartet) · `null` strukturell nicht
   * prüfbar, weil die Zeile im Template kein eigenes Label trägt
   * (scrapManufacturing steht unter scrapMaterial). `null` ist kein Mangel und
   * darf keinen Befund auslösen.
   */
  labelVerified: boolean | null
}

export interface SummaryMetricsParse {
  template: SummaryTemplateType
  /** Quotation currency (AW1 code from C25/C26), normalized when possible. */
  currency: string | null
  metrics: Record<SummaryMetricKey, SummaryMetricValue>
  /**
   * Blattname des Summary-Blatts (Kriterium-2-Provenienz: „bis Datei, Blatt,
   * Zelle, Formel und Wertzustand"). Auf dem Mappen-Pfad ws.name zur
   * Parse-Zeit; auf dem DB-Pfad aus der `sheet`-Spalte rehydriert — fehlt bei
   * Zeilen, die vor der Provenance-Migration ingestiert wurden.
   */
  sheet?: string
  /**
   * Per-metric formula provenance (KAR-900/P2.1) — parallel map, same
   * pattern as QAFRow.formulas (qaf-parser.ts): only set for metrics whose
   * cell actually carried a formula (the "berechnet" rows — SUMME
   * HERSTELLKOSTEN, GESAMTKOSTEN, ANGEBOTSBASISPREIS, ANGEBOTSPREIS, …).
   * Absent (undefined) when formulas were never surveyed — no formulaGrid
   * passed AND no persisted formula column (pre-migration rows). Seit
   * Kriterium 2: ein leeres Objekt {} heißt „erhoben, keine Formeln
   * gefunden" — der Unterschied trägt die value_state-Ableitung.
   * summaryMetricsFromRows rekonstruiert die Provenienz inzwischen aus der
   * persistierten `formula`-Spalte (buildFormulaProvenance ist
   * deterministisch über dem Rohtext).
   */
  formulas?: Partial<Record<SummaryMetricKey, FormulaProvenance>>
  /**
   * Wertzustand je Metrik (Spec Kap. 6.3), abgeleitet an EINER Stelle — im
   * Parser, der die Grid-Existenz kennt: Formel → 'formula_and_cached',
   * Wert ohne Formel bei erhobenem Grid → 'constant'. Kein Eintrag für
   * unresolved shared formulas (Zustand nicht bestimmbar) und wertlose
   * Zellen (werden nicht persistiert). Absent, wenn nie erhoben.
   */
  valueStates?: Partial<Record<SummaryMetricKey, ValueState>>
}

export interface SummaryMetricDiff {
  metricKey: SummaryMetricKey
  altValue: number | null
  neuValue: number | null
  deltaAbsolute: number | null
  deltaPercent: number | null
  status: DiffStatus
  currency: string | null
  sourceAlt: string | null
  sourceNeu: string | null
  /** Zeilenlabel wörtlich aus der jeweiligen Datei (KAR-996 / V2 R-04). */
  labelFileAlt: string | null
  labelFileNeu: string | null
  /** Siehe SummaryMetricValue.labelVerified — je Seite getrennt. */
  labelVerifiedAlt: boolean | null
  labelVerifiedNeu: boolean | null
  /** KAR-900/P2.1 — same in-memory-only annotation as FieldDiff.formulaInputsChanged
   * (differ.ts): set when both sides' formulas share the same hash but the
   * value differs (an upstream input changed, not the calc logic). */
  formulaInputsChanged?: boolean
  /** KAR-900/P2.1 — same in-memory-only annotation as FieldDiff.formulaFinding
   * (differ.ts), present for all 3 "reportable" formula-comparison kinds;
   * compare.ts turns this into a qaf_plausibility_issue-shaped finding. */
  formulaFinding?: {
    kind: 'formel_geaendert_wert_gleich' | 'formel_geaendert_wert_geaendert' | 'formel_zu_konstante'
    explanation: string
  }
  /**
   * Zell-Provenienz je Stand (Kriterium 2) — nur gesetzt, wo der Parser den
   * Wertzustand erhoben hat (Mappen-Pfad mit Grid oder rehydrierte Zeilen
   * nach der Provenance-Migration). Der Differenzkatalog baut daraus echte
   * DifferenceCells; fehlt sie, bleibt der Satz nach Weg 4 ohne Fundstelle
   * und `validateTraceability` meldet das.
   */
  provenanceAlt?: { sheet: string | null; formula: string | null; valueState: ValueState }
  provenanceNeu?: { sheet: string | null; formula: string | null; valueState: ValueState }
}

// ── Label synonyms (V11 §5.1, DE from real files + EN spec) ───────────────────

type SynonymKey = SummaryMetricKey | 'oneTimeHeader'

// Exportiert (KAR Loop 2B): Der Fixture-Anonymizer muss diese Anker-Beschriftungen
// erhalten, sonst findet der Summary-Parser die Kennzahlen in der anonymisierten
// Mappe nicht mehr — nachgewiesen am HICE-Fall, wo der Angebotspreis nach der
// Anonymisierung null war. Reine Sichtbarkeitsaenderung, kein Verhalten.
export const SYNONYMS: Partial<Record<SynonymKey, string[]>> = {
  materialCosts: ['Materialkosten', 'Material costs', 'Material cost'],
  manufacturingCosts: ['Fertigungskosten', 'Manufacturing costs', 'Manufacturing cost'],
  totalProductionCosts: ['SUMME HERSTELLKOSTEN', 'Summe Herstellkosten', 'Total production costs', 'Herstellkosten'],
  packagingTransportIncluded: [
    'Enthaltene Verpackung und Transport',
    'Verpackung und Transport',
    'Packaging and transport',
    'Included packaging and transport',
  ],
  customsIncluded: ['Enthaltene Zölle', 'Included customs', 'Customs included'],
  devicesAndTools: ['Vorrichtungen und Folgewerkzeuge', 'Devices and tools', 'Devices and follow-up tools'],
  scrapMaterial: ['Ausschusskosten', 'Scrap costs', 'Scrap costs material'],
  totalCosts: ['GESAMTKOSTEN', 'Gesamtkosten', 'Total costs', 'TOTAL COSTS'],
  otherSurcharges: ['Sonstige Zuschläge', 'Other surcharges'],
  quotationBasePrice: ['ANGEBOTSBASISPREIS', 'Angebotsbasispreis', 'Quotation base price', 'QUOTATION BASE PRICE'],
  rawMaterialPriceShareMaterial: [
    'Rohstoff Preisanteil Material',
    'Raw material price share material',
    'Rohstoff Preisanteil',
    'Raw material price share',
  ],
  rawMaterialPriceShareEnergy: ['Rohstoff Preisanteil Energie', 'Raw material price share energy'],
  customsSupplierToBMW: ['Zölle Lieferant - BMW', 'Customs Supplier - BMW', 'Customs supplier BMW'], // allow-customer-string
  transportSupplierToBMW: ['Transportkosten Lieferant - BMW', 'Transport costs Supplier - BMW', 'Transport supplier BMW'], // allow-customer-string
  quotationPrice: ['ANGEBOTSPREIS', 'Angebotspreis', 'Quotation price', 'QUOTATION PRICE', 'Offer price'],
  oneTimeHeader: ['Einmalzahlungen', 'One-time payments', 'Lump sum payments'],
  oneTimeDevelopment: ['Entwicklungskosten', 'Development costs'],
  oneTimeTools: ['Sonderbetriebsmittel', 'Special operating resources', 'Special tooling'],
  totalOneTimePayment: ['GESAMTEINMALZAHLUNG', 'Gesamteinmalzahlung', 'Total one-time payment', 'TOTAL ONE-TIME PAYMENT'],
  // KAR-910 — label verbatim from fehlerreport-analyse.md §2 row #23 (same
  // DE/EN wording, "Cost Breakdown AW1").
  costBreakdownAw1: ['Cost Breakdown AW1'],
}

/** German display labels for the canonical metrics (V11 METRIC_LABELS, de). */
export const METRIC_LABELS_DE: Record<SummaryMetricKey, string> = {
  materialCosts: 'Materialkosten',
  manufacturingCosts: 'Fertigungskosten',
  totalProductionCosts: 'Summe Herstellkosten',
  packagingTransportIncluded: 'Enthaltene Verpackung und Transport',
  customsIncluded: 'Enthaltene Zölle',
  devicesAndTools: 'Vorrichtungen und Folgewerkzeuge',
  scrapMaterial: 'Ausschusskosten Material',
  scrapManufacturing: 'Ausschusskosten Fertigung',
  totalCosts: 'Gesamtkosten',
  otherSurcharges: 'Sonstige Zuschläge',
  quotationBasePrice: 'Angebotsbasispreis',
  rawMaterialPriceShareMaterial: 'Rohstoff-Preisanteil Material',
  rawMaterialPriceShareEnergy: 'Rohstoff-Preisanteil Energie',
  customsSupplierToBMW: 'Zölle Lieferant → BMW', // allow-customer-string
  transportSupplierToBMW: 'Transport Lieferant → BMW', // allow-customer-string
  quotationPrice: 'Angebotspreis',
  oneTimeDevelopment: 'Einmal: Entwicklungskosten',
  oneTimeTools: 'Einmal: Sonderbetriebsmittel',
  totalOneTimePayment: 'Gesamteinmalzahlung',
  costBreakdownAw1: 'Cost Breakdown AW1',
}

/** V11 normLabel: strip asterisks + numbered prefixes, collapse whitespace, lowercase. */
function normLabel(v: unknown): string {
  const s = cellToString(v)
  if (s === null) return ''
  return s
    .replace(/\*+/g, ' ')
    .replace(/^\s*\d+[.)]?\s*/, '')
    .replace(/\s+/g, ' ')
    .trim()
    .toLowerCase()
}

const NORM_SYN: Partial<Record<SynonymKey, string[]>> = Object.fromEntries(
  Object.entries(SYNONYMS).map(([k, syns]) => [k, syns.map((s) => normLabel(s))]),
)

// ── Template row priors (V11 §2.2/§2.3, verified against real files) ──────────

interface TemplateConfig {
  /** Cell holding the quotation currency code (AW 1). */
  awCell: string
  /** Expected 1-based label row per directly-labelled metric. */
  rows: Partial<Record<SummaryMetricKey, number>>
  /** Expected 1-based row of the 'Einmalzahlungen' anchor label. */
  oneTimeAnchor: number
}

// awCell per V11 b4_engine.js L139: V9 reads C25, legacy reads C26.
const TEMPLATE_CONFIG: Record<SummaryTemplateType, TemplateConfig> = {
  QAF_LEGACY_DE_SUMMARY: {
    awCell: 'C26',
    rows: {
      materialCosts: 11,
      manufacturingCosts: 12,
      totalProductionCosts: 13,
      packagingTransportIncluded: 14,
      customsIncluded: 15,
      devicesAndTools: 17,
      scrapMaterial: 18,
      totalCosts: 21,
      otherSurcharges: 23,
      quotationBasePrice: 24,
      rawMaterialPriceShareMaterial: 26,
      customsSupplierToBMW: 27,
      transportSupplierToBMW: 28,
      quotationPrice: 29,
    },
    oneTimeAnchor: 32,
  },
  QAF_V9_SUMMARY: {
    awCell: 'C25',
    rows: {
      materialCosts: 11,
      manufacturingCosts: 12,
      totalProductionCosts: 13,
      packagingTransportIncluded: 14,
      customsIncluded: 15,
      devicesAndTools: 17,
      scrapMaterial: 18,
      totalCosts: 21,
      otherSurcharges: 23,
      quotationBasePrice: 24,
      rawMaterialPriceShareMaterial: 26,
      rawMaterialPriceShareEnergy: 27,
      customsSupplierToBMW: 28,
      transportSupplierToBMW: 29,
      quotationPrice: 30,
    },
    oneTimeAnchor: 33,
  },
}

const LABEL_COL = 6 // column G
const HEADER_ROW = 9 // row 10 (0-based)
const AW_SCAN_FROM = 12 // column M
/** 0-based end column (inclusive) of the AW1-3 currency-header candidate
 * band locateAwColumn scans (column R). Exported (KAR-926 adversarial-review
 * F5 fix, 12.07.2026) so qaf-type-detector.ts's VARIANT_BAND_COL_FROM can be
 * DERIVED from this value (AW_SCAN_TO + 1) instead of a same-value magic
 * number kept in sync only by a comment — see that module's
 * VARIANT_BAND_COL_FROM doc comment for why the two must never overlap.
 * Purely additive: no behavior change, this constant's own value and every
 * caller inside this file are unchanged. */
export const AW_SCAN_TO = 17 // column R
const AW_FALLBACK_COL = 15 // column P
const ROW_WINDOW = 3

// ── Location helpers ──────────────────────────────────────────────────────────

function gridAt(grid: unknown[][], row0: number, col0: number): unknown {
  return grid[row0]?.[col0]
}

interface RowLocation {
  /** 1-based row, null on label collision. */
  row: number | null
  howLocated: MetricLocateMethod
  confidence: number
  /** Rohes Label der gewählten Zeile (Spalte G), null wenn dort nichts steht. */
  labelFile: string | null
  /** Siehe SummaryMetricValue.labelVerified. */
  labelVerified: boolean | null
}

/** Label der Zeile wörtlich, wie es in Spalte G steht. */
function rawLabelAt(grid: unknown[][], row: number | null): string | null {
  if (row === null) return null
  const s = cellToString(gridAt(grid, row - 1, LABEL_COL))
  return s === null || s.trim() === '' ? null : s
}

function locateRow(grid: unknown[][], synKey: SynonymKey, expectedRow: number): RowLocation {
  const syns = NORM_SYN[synKey] ?? []
  const hits: number[] = []
  for (let r = expectedRow - ROW_WINDOW; r <= expectedRow + ROW_WINDOW; r++) {
    if (r < 1) continue
    const label = normLabel(gridAt(grid, r - 1, LABEL_COL))
    if (label !== '' && syns.includes(label)) hits.push(r)
  }
  if (hits.length === 1) {
    return { row: hits[0], howLocated: 'labelMatch', confidence: 1, labelFile: rawLabelAt(grid, hits[0]), labelVerified: true }
  }
  if (hits.length === 0) {
    // Wert wird weiterhin an der erwarteten Zeile gelesen — aber offengelegt,
    // dass das Label sie nicht bestätigt (F-02).
    return {
      row: expectedRow,
      howLocated: 'fixedRow',
      confidence: 0.6,
      labelFile: rawLabelAt(grid, expectedRow),
      labelVerified: false,
    }
  }
  // Kollision: es wurde KEINE Zeile gelesen (row: null) — also gibt es auch
  // nichts zu verifizieren. `false` würde behaupten, ein Label habe die
  // Zeile widerlegt; tatsächlich war nur nicht entscheidbar, welche gemeint
  // ist. Gleiche Lesart wie in locateRowUnanchored.
  return { row: null, howLocated: 'labelCollision', confidence: 0, labelFile: null, labelVerified: null }
}

/**
 * KAR-910 — same column-G label match as locateRow above, but WITHOUT an
 * expected-row prior to center a ±ROW_WINDOW scan on: scans every row of the
 * grid instead. Used only for costBreakdownAw1, the one SUMMARY metric with
 * no Leitfaden-documented sheet position (see canonical-fields.ts
 * sum_cost_breakdown_aw1). Same ambiguity handling as locateRow (>1 hit is a
 * collision, not a guess); 0 hits returns row:null (absent — the metric may
 * simply not be present in this template/file, not necessarily a parse
 * failure, so this deliberately does NOT downgrade to a 'fixedRow' guess the
 * way locateRow's 0-hit branch does — there is no fixed row to fall back to
 * here).
 */
function locateRowUnanchored(grid: unknown[][], synKey: SynonymKey): RowLocation {
  const syns = NORM_SYN[synKey] ?? []
  const hits: number[] = []
  for (let r = 1; r <= grid.length; r++) {
    const label = normLabel(gridAt(grid, r - 1, LABEL_COL))
    if (label !== '' && syns.includes(label)) hits.push(r)
  }
  if (hits.length === 1) {
    return { row: hits[0], howLocated: 'labelMatch', confidence: 1, labelFile: rawLabelAt(grid, hits[0]), labelVerified: true }
  }
  // Kein Treffer und keine Ersatzzeile: nichts gelesen, also auch nichts zu
  // verifizieren — `null` statt `false`, sonst liest sich ein schlicht nicht
  // vorhandener Posten wie ein Label-Konflikt.
  if (hits.length === 0) return { row: null, howLocated: 'fixedRow', confidence: 0, labelFile: null, labelVerified: null }
  return { row: null, howLocated: 'labelCollision', confidence: 0, labelFile: null, labelVerified: null }
}

interface AwColumn {
  col: number
  currency: string | null
  confidence: number
}

function locateAwColumn(grid: unknown[][], cfg: TemplateConfig): AwColumn {
  const { row, col } = parseA1(cfg.awCell)
  const awCode = cellToString(gridAt(grid, row, col))
  if (awCode) {
    for (let c = AW_SCAN_FROM; c <= AW_SCAN_TO; c++) {
      const header = cellToString(gridAt(grid, HEADER_ROW, c))
      // Case-insensitive: C25/C26 and the row-10 headers are free-text cells.
      if (header !== null && header.trim().toLowerCase() === awCode.trim().toLowerCase()) {
        return { col: c, currency: normalizeCurrency(awCode) ?? awCode.trim(), confidence: 1 }
      }
    }
  }
  return {
    col: AW_FALLBACK_COL,
    currency: awCode ? (normalizeCurrency(awCode) ?? awCode.trim()) : null,
    confidence: 0.5,
  }
}

function readMetric(grid: unknown[][], loc: RowLocation, valueCol: number): SummaryMetricValue {
  if (loc.row === null) {
    return {
      value: null,
      cell: null,
      howLocated: loc.howLocated,
      confidence: loc.confidence,
      labelFile: loc.labelFile,
      labelVerified: loc.labelVerified,
    }
  }
  const cellAddr = a1(loc.row - 1, valueCol)
  return {
    value: parseLocaleNumber(gridAt(grid, loc.row - 1, valueCol)),
    cell: cellAddr,
    howLocated: loc.howLocated,
    confidence: loc.confidence,
    labelFile: loc.labelFile,
    labelVerified: loc.labelVerified,
  }
}

const ABSENT: SummaryMetricValue = {
  value: null,
  cell: null,
  howLocated: 'fixedRow',
  confidence: 0,
  labelFile: null,
  labelVerified: null,
}

// ── Parse ─────────────────────────────────────────────────────────────────────

function pickTemplate(sheetName: string): SummaryTemplateType {
  return /zusammenfassung/i.test(sheetName) ? 'QAF_LEGACY_DE_SUMMARY' : 'QAF_V9_SUMMARY'
}

/** Look up the raw formula text at a metric's already-resolved A1 cell
 * address in the parallel formula grid (KAR-900/P2.1) — null when there is
 * no formula grid, no cell address, or the cell has no formula. Passes the
 * SHARED_FORMULA_UNRESOLVED sentinel straight through (adversarial-review
 * fix) so the caller can build an `unresolved: true` FormulaProvenance
 * instead of silently treating an unresolvable shared-formula slave as "no
 * formula" (which risks a false formel_zu_konstante downstream — see
 * formula-engine.ts). */
function formulaAt(
  formulaGrid: (string | null | typeof SHARED_FORMULA_UNRESOLVED)[][] | undefined,
  cell: string | null,
): string | null | typeof SHARED_FORMULA_UNRESOLVED {
  if (!formulaGrid || cell === null) return null
  const { row, col } = parseA1(cell)
  return formulaGrid[row]?.[col] ?? null
}

/**
 * Extract the canonical summary money metrics from a summary-sheet grid.
 * Missing metrics come back with value null (never throws on sparse sheets).
 *
 * `formulaGrid` (KAR-900/P2.1, optional — from workbook-adapter.ts's
 * worksheetToFormulaGrid) attaches per-metric formula provenance for the
 * "berechnet" rows. Omitted entirely when absent — every pre-P2.1 caller
 * (and rehydrate.ts's summaryMetricsFromRows, which has no raw grid to read
 * formulas from at all) keeps working unchanged, `formulas` stays undefined.
 */
export function parseSummaryMetrics(
  grid: unknown[][],
  sheetName: string,
  formulaGrid?: (string | null | typeof SHARED_FORMULA_UNRESOLVED)[][],
): SummaryMetricsParse {
  const template = pickTemplate(sheetName)
  const cfg = TEMPLATE_CONFIG[template]
  const aw = locateAwColumn(grid, cfg)

  // Pre-fill: keys not covered by this template (e.g. energy share on legacy)
  // must still exist with a null value.
  const metrics = Object.fromEntries(SUMMARY_METRIC_KEYS.map((k) => [k, { ...ABSENT }])) as Record<
    SummaryMetricKey,
    SummaryMetricValue
  >

  for (const key of SUMMARY_METRIC_KEYS) {
    const expected = cfg.rows[key]
    if (expected === undefined) continue // scrapManufacturing + one-time block below
    metrics[key] = readMetric(grid, locateRow(grid, key, expected), aw.col)
  }

  // scrapManufacturing = row below scrapMaterial (label only marks the block start).
  const scrapMaterial = metrics.scrapMaterial
  if (scrapMaterial.value !== null && scrapMaterial.cell !== null) {
    const smRow0 = parseA1(scrapMaterial.cell).row
    metrics.scrapManufacturing = {
      value: parseLocaleNumber(gridAt(grid, smRow0 + 1, aw.col)),
      cell: a1(smRow0 + 1, aw.col),
      howLocated: 'aggregate',
      confidence: Math.min(0.9, scrapMaterial.confidence),
      // Die Zeile trägt im Template kein eigenes Label — nicht prüfbar, nicht mangelhaft.
      labelFile: null,
      labelVerified: null,
    }
  } else {
    metrics.scrapManufacturing = { ...ABSENT }
  }

  // One-time payments hang below the 'Einmalzahlungen' anchor label.
  const anchor = locateRow(grid, 'oneTimeHeader', cfg.oneTimeAnchor)
  const oneTimeOffsets: Array<[SummaryMetricKey, number]> = [
    ['oneTimeDevelopment', 1],
    ['oneTimeTools', 2],
    ['totalOneTimePayment', 3],
  ]
  for (const [key, offset] of oneTimeOffsets) {
    if (anchor.row === null) {
      metrics[key] = { value: null, cell: null, howLocated: 'labelCollision', confidence: 0, labelFile: null, labelVerified: null }
      continue
    }
    const r = anchor.row + offset
    const labelOk = (NORM_SYN[key] ?? []).includes(normLabel(gridAt(grid, r - 1, LABEL_COL)))
    metrics[key] = {
      value: parseLocaleNumber(gridAt(grid, r - 1, aw.col)),
      cell: a1(r - 1, aw.col),
      howLocated: labelOk ? 'labelMatch' : 'fixedRow',
      confidence: labelOk ? anchor.confidence : 0.4,
      labelFile: rawLabelAt(grid, r),
      labelVerified: labelOk,
    }
  }

  // costBreakdownAw1 (KAR-910) — no TEMPLATE_CONFIG.rows entry (see
  // SUMMARY_METRIC_KEYS comment), so the main loop above skipped it via its
  // `expected === undefined` guard. Located separately via the unanchored
  // full-column-G scan (locateRowUnanchored) instead.
  metrics.costBreakdownAw1 = readMetric(grid, locateRowUnanchored(grid, 'costBreakdownAw1'), aw.col)

  // Formula provenance (KAR-900/P2.1) — post-processing pass over the
  // already-located metric cells, only when a formula grid was supplied.
  // Seit Kriterium 2 werden beide Maps bei erhobenem Grid IMMER angelegt
  // (auch leer): {} heißt „erhoben, nichts gefunden" — undefined heißt „nie
  // erhoben". Der Unterschied trägt die value_state-Ableitung, die genau
  // hier stattfindet (die eine Stelle, die die Grid-Existenz kennt):
  // Formel → formula_and_cached, Wert ohne Formel → constant, unresolved →
  // kein Zustand (nicht bestimmbar), wertlose Zelle → kein Zustand.
  let formulas: Partial<Record<SummaryMetricKey, FormulaProvenance>> | undefined
  let valueStates: Partial<Record<SummaryMetricKey, ValueState>> | undefined
  if (formulaGrid) {
    formulas = {}
    valueStates = {}
    for (const key of SUMMARY_METRIC_KEYS) {
      const formulaText = formulaAt(formulaGrid, metrics[key].cell)
      if (formulaText === SHARED_FORMULA_UNRESOLVED) {
        formulas[key] = unresolvedFormulaProvenance()
      } else if (formulaText) {
        formulas[key] = buildFormulaProvenance(formulaText)
        valueStates[key] = 'formula_and_cached'
      } else if (metrics[key].value !== null) {
        valueStates[key] = 'constant'
      }
    }
  }

  return {
    template,
    currency: aw.currency,
    metrics,
    sheet: sheetName,
    ...(formulas ? { formulas } : {}),
    ...(valueStates ? { valueStates } : {}),
  }
}

// ── Diff ──────────────────────────────────────────────────────────────────────

/** Formeltext einer Provenienz — nie den leeren unresolved-Platzhalter. */
function reineFormel(p: FormulaProvenance | undefined): string | null {
  return p && !p.unresolved && p.raw !== '' ? p.raw : null
}

/**
 * Diff the summary metrics of two parsed files (NEU − ALT). Metrics absent on
 * both sides are skipped; one-sided metrics surface as neu/entfallen.
 *
 * `formulaEngineEnabled` (KAR-900/P2.1, default true — matches
 * FORMULA_ENGINE_CONFIG's default) gates the formula-aware post-processing
 * below, same flag/semantics as differ.ts's diffSteps.
 */
export function diffSummaryMetrics(
  alt: SummaryMetricsParse,
  neu: SummaryMetricsParse,
  formulaEngineEnabled = true,
): SummaryMetricDiff[] {
  const out: SummaryMetricDiff[] = []
  for (const key of SUMMARY_METRIC_KEYS) {
    const a = alt.metrics[key] ?? ABSENT
    const b = neu.metrics[key] ?? ABSENT
    if (a.value === null && b.value === null) continue

    const delta = computeNumericDelta(a.value, b.value)
    const diff: SummaryMetricDiff = {
      metricKey: key,
      altValue: a.value,
      neuValue: b.value,
      deltaAbsolute: delta.deltaAbsolute,
      deltaPercent: delta.deltaPercent,
      status: delta.status,
      currency: neu.currency ?? alt.currency,
      sourceAlt: a.value !== null ? a.cell : null,
      sourceNeu: b.value !== null ? b.cell : null,
      // Anders als sourceAlt/sourceNeu bewusst NICHT an einen Wert gekoppelt:
      // gerade die Zeile ohne Wert (Posten entfallen) muss zeigen, wie sie in
      // der Datei heißt — sonst fehlt die Beschriftung dort, wo die Aussage
      // "X ist weggefallen" gemacht wird.
      labelFileAlt: a.labelFile,
      labelFileNeu: b.labelFile,
      labelVerifiedAlt: a.labelVerified,
      labelVerifiedNeu: b.labelVerified,
      // Kriterium 2: Provenienz je Seite mitreichen, wo erhoben — der
      // Formeltext ohne unresolved-Platzhalter (dessen raw ist leer und darf
      // nie als „Formel" gelesen werden).
      ...(alt.valueStates?.[key] !== undefined
        ? {
            provenanceAlt: {
              sheet: alt.sheet ?? null,
              formula: reineFormel(alt.formulas?.[key]),
              valueState: alt.valueStates[key]!,
            },
          }
        : {}),
      ...(neu.valueStates?.[key] !== undefined
        ? {
            provenanceNeu: {
              sheet: neu.sheet ?? null,
              formula: reineFormel(neu.formulas?.[key]),
              valueState: neu.valueStates[key]!,
            },
          }
        : {}),
    }

    if (formulaEngineEnabled) {
      const cmp = compareFormulaPair({
        altFormula: alt.formulas?.[key],
        neuFormula: neu.formulas?.[key],
        altValue: a.value,
        neuValue: b.value,
      })
      switch (cmp.kind) {
        case 'formel_geaendert_wert_gleich':
          diff.status = 'formel_geaendert'
          diff.formulaFinding = { kind: cmp.kind, explanation: cmp.explanation }
          break
        case 'formel_zu_konstante':
          diff.status = 'formel_zu_konstante'
          diff.formulaFinding = { kind: cmp.kind, explanation: cmp.explanation }
          break
        case 'formel_geaendert_wert_geaendert':
          diff.formulaFinding = { kind: cmp.kind, explanation: cmp.explanation }
          break
        case 'unauffaellig':
          if (cmp.inputsChanged) diff.formulaInputsChanged = true
          break
        case 'no_formula_data':
        default:
          break
      }
    }

    out.push(diff)
  }
  return out
}
