// RAW MATERIAL RISKS sheet row parser + Rohstoffzuschlag-Validierung (KAR-902 /
// P2.3): standalone parser for the QAF "RAW MATERIAL RISKS" register sheet
// (Leitfaden 02-leitfaden-teil2.md [42]-[45]). Built 1:1 from the
// material-parser.ts / sbm-parser.ts pattern (KAR-897/P1.6, KAR-898/P1.7):
// dynamic import of the canonical registry, label-anchor + confidence
// matching, a controlled degradation path on an unusable header (never
// throws), sourceCells/normalized/rawText provenance from day one, and the
// coreFieldsFound tri-state + rmrRowsForReconciliation/FromPersistedMeta
// helper pair built in from the START (the #274/KAR-898 lesson from
// 4d158f6 — sbm-parser.ts's module header documents that this tri-state
// contract was RETROFITTED there after an adversarial-review finding; this
// module ships it from commit one instead of repeating that lesson).
//
// ── Scope discipline (backlog 05-backlog-phasenplan.md [P2.3] + explicit task
// override, see below) ──────────────────────────────────────────────────────
//   - Parses + persists the row shape only, plus ONE row-level Rohstoffzuschlag
//     plausibility check (see "Rohstoffzuschlag-Validierung" section below) —
//     the task instruction's "Rohstoffzuschlag-Plausibilität soweit die
//     Leitfaden-Regeln es mit geparsten Feldern hergeben".
//   - Cross-Sheet-Reconciliation MATERIAL <-> RAW MATERIAL RISKS (backlog
//     P2.3's own acceptance criterion: "Summen-Abgleich Bezugsgewicht" between
//     MATERIAL rows carrying a Rohstoffbezeichnung/RMR-Kennzeichnung and this
//     sheet's aggregated rows) is EXPLICITLY NOT built in this PR — the task
//     instruction overrides the backlog item's own scope here: "Verbindung zu
//     den MATERIAL-RMR-Split-Zeilen aus KAR-897 (gleiche Rohstoffbezeichnung
//     -> als Kontext-Feld erfassen, KEINE automatische Cross-Sheet-
//     Verknuepfung in diesem PR — dokumentieren)". rawMaterialDesignation is
//     therefore captured as a plain context field only (exactly like
//     material-parser.ts's own rawMaterialDesignation field is captured
//     without resolving it against this very sheet) — no join, no sum-match,
//     no PlausibilityIssue is produced from comparing the two sheets. This is
//     a deliberate, documented deviation from the backlog's acceptance
//     criterion "Cross-Reconciliation mit MATERIAL-Sheet-Rohstoffzeilen
//     funktioniert fuer die Beispieldaten aus Leitfaden S.45" — tracked as a
//     TODO for a future PR, not silently dropped.
//   - Schwellwert-Preisanpassungslogik ueber Zeit (BQ%/SW%-Mechanik, Leitfaden
//     [43]: "Preisanpassung erfolgt nur, wenn sich vereinbarte
//     Rohstoffnotierung seit letzter Preisanpassung jenseits des vereinbarten
//     Schwellenwertes veraendert hat") is explicitly OUT of scope per the
//     backlog item itself ("NICHT rein: die vollstaendige Schwellwert-
//     Preisanpassungslogik ueber Zeit ... als 'Spaeter' markiert") — this
//     requires a HISTORICAL baseline Rohstoffnotierung to compare the current
//     snapshot against, which a single-file parse structurally cannot supply
//     (bmwParticipationRate/threshold are captured as plain data fields, never
//     evaluated against a prior value). See "Rohstoffzuschlag-Validierung"
//     section below for the one check that IS built.
//   - Cross-file matching/diffing of RMR rows (ALT vs NEU) is explicitly OUT
//     of scope for this PR — same boundary material-parser.ts/sbm-parser.ts
//     document for their own row domains.
//
// ── Rohstoffzuschlag-Validierung (Leitfaden [42]-[45]) ──────────────────────
// The Leitfaden's own prose ([44]) only says RoZ0 "wird berechnet" without
// spelling out the formula textually (unlike e.g. MATERIAL's rule_calc_
// raw_material_surcharge in business-rules.ts, which quotes an explicit
// formula sentence from Abbildung 9). Abbildung 26 (S.45, [45]) DOES supply a
// fully worked 14-row example table (12 "Rohstoff Preisanteil Material" rows
// + 2 "Rohstoff Preisanteil Energie" rows) with Rohstoffnotierung Ro,
// Bezugsgewicht (or Energieverbrauch for the energy rows) and Rohstoffzuschlag
// RoZ0 all given — every single row is consistent with
//   RoZ0 = Ro x Bezugsgewicht (rounded to 2 decimals),
// including the Steel Scrap Coil EU row (Ro = -0.320, Bezugsgewicht = 1.00 kg,
// RoZ0 = -0.32 — a NEGATIVE quotation multiplied by a positive weight yields a
// negative surcharge, i.e. a credit/Gutschrift, per [45]'s own "Ausnahmen/
// Sonderfaelle" note: "Rohstoffnotierung Ro kann negativ sein ... Scrap/
// Reststoffe als Gutschrift-Logik"). validateRmrRawMaterialSurcharge below
// implements exactly this — derived from the worked example numbers, not from
// an explicit formula SENTENCE, so its evidence citation says so plainly
// (page + "Abbildung 26 worked-example derivation", not a quoted formula).
// Negative Ro/RoZ0 is NEVER flagged as an error by this check — it is
// multiplied through like any other signed number, which already produces the
// correct signed expected value; there is no separate "reject negative"
// branch to accidentally add.
//
// ── Gating (same anti-spam principle as business-rules.ts, KAR-901) ────────
// The check only evaluates a row when rawMaterialSurcharge (the Excel-shown
// RoZ0) is actually filled — most files may carry rows still awaiting BMW // allow-customer-string
// Facheinkauf's Vergabe-Detaillierung ([44]: "Zum Zeitpunkt der Vergabe ...
// zu befuellen"), so an empty RoZ0 is a normal intermediate state, not a
// finding.
//
// ── Client-bundle discipline (KAR-893 lesson, "Bundle-Lehre aus #272") ─────
// Exported through the qaf-differences barrel (index.ts), which several
// 'use client' components already import from — canonical-model.ts/
// canonical-fields.ts must stay OUT of the static import graph, exactly like
// material-parser.ts's loadMaterialRegistry(). loadRmrRegistry() is the same
// pattern: a dynamic import, cached after the first call. Only TYPE imports
// (erased at compile time) are static below.
//
// ── Known layout limitation (documented, not silently wrong) ───────────────
// Abbildung 26 shows the RMR sheet as TWO stacked blocks under one tab
// ("Rohstoff Preisanteil Energie" then "Rohstoff Preisanteil Material"),
// which may or may not share one contiguous header row in a real file. This
// parser — like material-parser.ts/sbm-parser.ts — locates exactly ONE header
// row (best score in the first HEADER_SCAN_MAX_ROWS rows) and reads a single
// contiguous data block until the first fully blank row. If a real file lays
// the two blocks out as two separate header/data blocks, only the block whose
// header the scan locates first/best is parsed; the other is silently not
// read (same single-header-block limitation the two reference parsers
// accept for their own sheets — not solved here, flagged for a future PR if
// a real file demonstrates the two-block layout).

import type { Worksheet } from 'exceljs'
import type { CanonicalField } from './canonical-fields.types'
import { worksheetToGrid } from './workbook-adapter'
import { isNotApplicableValue } from './normalizer'
import { matchesModuleSheetName } from './module-sheet-names'
import type { ComparisonSide } from './rule-engine'
import type { PlausibilityIssue, PlausibilitySeverity } from './plausibility'
import type { FacetDegradation } from './types'
import { MIN_SIGNAL_MAPPED_COLUMNS } from './types'
import { RMR_FIELD_KEY_TO_CANONICAL } from './canonical-fields'
import { byCanonicalId } from './canonical-model'
import { missingFieldReason, joinMissingReasons } from './bilingual-message'

/** EN label for an RMR field key, sourced from the canonical field registry
 * (KAR-906/P3.2). */
function rmrLabelEn(key: RmrFieldKey, fallbackDe: string): string {
  return byCanonicalId(RMR_FIELD_KEY_TO_CANONICAL[key])?.labelEn ?? fallbackDe
}

// ── Row shape ────────────────────────────────────────────────────────────

/**
 * The 12 RAW MATERIAL RISKS fields the Leitfaden documents completely
 * (S.43, canonical-fields.ts RMR_FIELDS) — camelCase mirror of the canonical
 * ids' `rmr_*` suffix, same relationship as material-parser.ts's
 * MaterialFieldKey to MATERIAL_FIELDS / sbm-parser.ts's SbmFieldKey to
 * SBM_FIELDS.
 */
export interface RmrRowValues {
  positionNumber: string
  rawMaterialDesignation: string
  referenceWeight: number | null
  rawMaterialKey: string
  rawMaterialQuotation: number | null
  rawMaterialSurcharge: number | null
  settlementModel: string
  indexDesignation: string
  indexQuotation: number | null
  bmwParticipationRate: number | null
  threshold: number | null
  remark: string
}

export type RmrFieldKey = keyof RmrRowValues

/**
 * Which of the RMR sheet's two documented blocks (Leitfaden [44]:
 * "Rohstoffpreisanteile werden in 2 Bloecken dargestellt: Rohstoffpreisanteil
 * Material und Rohstoffpreisanteil Energie") a row belongs to. Detected
 * primarily from the block's literal title text ("Rohstoff Preisanteil
 * Energie" / "Rohstoff Preisanteil Material", Abbildung 26, S.45), with a
 * documented low-confidence order-based fallback for the specific case where
 * exactly 2 blocks were found and neither title was distinguishable — see
 * detectBlockTypeFromTitle/applyBlockOrderFallback below. 'unbekannt' is the
 * conservative default (single block with no title text, 3+ blocks, or an
 * ambiguous title mentioning both words) — never guessed beyond what the
 * sheet content or its documented fixed layout actually supports.
 */
export type RmrBlockType = 'material' | 'energie' | 'unbekannt'

/**
 * A parsed RAW MATERIAL RISKS row. sourceCells/normalized/rawText are
 * present from day one (same "kein Nachruesten" instruction material-
 * parser.ts follows, and the explicit KAR-902 task instruction "die
 * #274-Lektion nicht wiederholen") — every row carries per-field cell
 * provenance for whichever columns were actually located in the header.
 */
export type RmrRow = RmrRowValues & {
  sourceCells: Partial<Record<RmrFieldKey, string>>
  normalized: Partial<Record<RmrFieldKey, string | number | null>>
  rawText: Partial<Record<RmrFieldKey, string>>
  blockType: RmrBlockType
}

const TEXT_FIELDS: Set<RmrFieldKey> = new Set([
  'positionNumber',
  'rawMaterialDesignation',
  'rawMaterialKey',
  'settlementModel',
  'indexDesignation',
  'remark',
])

/** Every RmrFieldKey — used to build the default-filled row shape. */
const ALL_FIELD_KEYS: readonly RmrFieldKey[] = [
  'positionNumber',
  'rawMaterialDesignation',
  'referenceWeight',
  'rawMaterialKey',
  'rawMaterialQuotation',
  'rawMaterialSurcharge',
  'settlementModel',
  'indexDesignation',
  'indexQuotation',
  'bmwParticipationRate',
  'threshold',
  'remark',
]

/**
 * The 3 header-level fields that MUST be located for an RMR header row to be
 * usable at all — Positionsnummer (row identity), Rohstoffbezeichnung (the
 * material identity the whole sheet aggregates by, Leitfaden [44]: "nach
 * Rohstoffbezeichnungen aggregiert = kumuliert") and Rohstoffnotierung Ro
 * (the "Notierung/Quote-artige Kernfeld" the task instruction names
 * explicitly — the fundamental input value the sheet exists to capture,
 * Leitfaden [44]: "Nach Eingabe der Rohstoffnotierung Ro wird der Rohstoff
 * Zuschlag RoZ0 je Rohstoff berechnet"). Mirrors material-parser.ts's
 * CORE_MATERIAL_FIELD_KEYS / sbm-parser.ts's CORE_SBM_FIELD_KEYS 3-field
 * pattern (row-identity + entity-name + the sheet's defining value field).
 *
 * Deliberately does NOT throw when missing (unlike qaf-parser.ts's
 * parseQAFTemplate): RAW MATERIAL RISKS is an OPTIONAL, additive sheet — a
 * malformed RMR sheet must not fail the whole file ingest. See
 * parseRmrWorksheet below: below this minimum, coreFieldsFound is false and
 * rows stays empty.
 */
export const CORE_RMR_FIELD_KEYS: readonly RmrFieldKey[] = [
  'positionNumber',
  'rawMaterialDesignation',
  'rawMaterialQuotation',
]

// KAR-958/P2 (gate-audit.md B7, coreFieldsFound-Resilienz) — the shared
// MIN_SIGNAL_MAPPED_COLUMNS floor from types.ts (PR #325 review fix #5, was
// a duplicated local literal here), applied per BLOCK here (RMR's
// multi-block layout, see "Multi-block parsing" below) — below it, a
// degraded block is still treated exactly as before this PR (flagged via
// possibleUnparsedBlockRow, not parsed). At/above it, the block's own
// already-mapped columns are extracted instead of discarded (see
// parseRmrWorksheet's degraded-block branch).

const HEADER_MATCH_MIN = 3
const HEADER_SCAN_MAX_ROWS = 20

// ── Sheet detection ─────────────────────────────────────────────────────────

/**
 * True when a worksheet name identifies the RAW MATERIAL RISKS register
 * sheet. The Leitfaden itself uses two spellings for this tab: the plural
 * 'Blatt "RAW MATERIAL RISKS"' (the sheet's own title, [42]/[44]/[45]/[148])
 * and the singular "RAW MATERIAL RISK" in cross-reference footnotes on the
 * MATERIAL sheet ([17]/[21]: "siehe Registerblatt RAW MATERIAL RISK") — this
 * matches on the singular substring "raw material risk" so both spellings
 * are recognized. material-parser.ts's isMaterialSheetName already excludes
 * any name containing "raw material" from being treated as the MATERIAL
 * sheet — this function is the other half of that split. Sheet-name alias
 * source centralized in module-sheet-names.ts (KAR-905/P3.1).
 */
export function isRmrSheetName(name: string): boolean {
  return matchesModuleSheetName(name, 'RMR')
}

/** First worksheet whose name matches isRmrSheetName, or null when the
 * workbook has none (the additive gate — files without an RMR sheet are
 * simply not parsed by this module at all). */
export function findRmrWorksheet(wb: { worksheets: Worksheet[] }): Worksheet | null {
  return wb.worksheets.find((w) => isRmrSheetName(w.name)) ?? null
}

// ── Canonical registry bridge (dynamic import — see module header) ─────────

interface ColumnMatch {
  key: RmrFieldKey
  /** 1.0 exact, 0.9 normalized-only — see material-parser.ts module header
   * for the two-tier rationale, reused verbatim here. */
  confidence: number
}

interface RmrRegistryCtx {
  /** RMR-only slice of the canonical registry — scoping is required, not
   * cosmetic: "Positionsnummer"/"Bemerkung" are shared verbatim with several
   * other modules — matching against the full registry would make every RMR
   * header ambiguous, same reasoning as material-parser.ts/sbm-parser.ts. */
  registry: readonly CanonicalField[]
  idToKey: Record<string, RmrFieldKey>
  findByAliasFn: (
    label: string,
    lang: 'de' | 'en' | undefined,
    registry: readonly CanonicalField[],
  ) => CanonicalField[]
}

let rmrRegistryPromise: Promise<RmrRegistryCtx> | null = null

async function loadRmrRegistry(): Promise<RmrRegistryCtx> {
  if (!rmrRegistryPromise) {
    rmrRegistryPromise = (async () => {
      const [{ byModule, findByAlias }, { RMR_FIELD_KEY_TO_CANONICAL }] = await Promise.all([
        import('./canonical-model'),
        import('./canonical-fields'),
      ])
      const registry = byModule('RMR')
      const idToKey = Object.fromEntries(
        Object.entries(RMR_FIELD_KEY_TO_CANONICAL).map(([key, canonicalId]) => [canonicalId, key as RmrFieldKey]),
      ) as Record<string, RmrFieldKey>

      return { registry, idToKey, findByAliasFn: findByAlias }
    })()
  }
  return rmrRegistryPromise
}

function matchHeaderColumnSync(headerCell: string, ctx: RmrRegistryCtx): ColumnMatch | null {
  if (headerCell === '') return null

  for (const field of ctx.registry) {
    if (headerCell === field.labelDe || headerCell === field.labelEn) {
      const key = ctx.idToKey[field.id]
      if (key) return { key, confidence: 1 }
    }
  }

  const hits = ctx.findByAliasFn(headerCell, undefined, ctx.registry)
  const distinctKeys = new Set(
    hits.map((f) => ctx.idToKey[f.id]).filter((k): k is RmrFieldKey => k !== undefined),
  )
  if (distinctKeys.size === 1) {
    const [key] = distinctKeys
    return { key, confidence: 0.9 }
  }

  return null
}

/** Match one already-whitespace-normalized header string against the RMR
 * canonical field registry. Exported for the same reason material-parser.ts
 * exports matchMaterialHeaderColumn: single-cell callers (tests, a future UI
 * preview) that don't want to manage the registry cache themselves. */
export async function matchRmrHeaderColumn(headerCell: string): Promise<ColumnMatch | null> {
  const ctx = await loadRmrRegistry()
  return matchHeaderColumnSync(headerCell, ctx)
}

function normalizeHeaderCell(v: unknown): string {
  return String(v ?? '')
    .replace(/[\r\n]+/g, ' ')
    .replace(/\s+/g, ' ')
    .trim()
}

function colLetter(col0: number): string {
  let n = col0 + 1
  let out = ''
  while (n > 0) {
    const rem = (n - 1) % 26
    out = String.fromCharCode(65 + rem) + out
    n = Math.floor((n - 1) / 26)
  }
  return out
}

function toNum(v: unknown): number | null {
  if (v === null || v === undefined || v === '') return null
  // `Number(new Date(...))` liefert den Millisekunden-Epoch, nicht NaN — eine
  // Zahlenspalte mit datums-/zeitartigem Zellformat käme sonst als
  // Milliardenwert in der Kalkulation an, ohne Fehler und ohne Warnung. Seit
  // der BIFF-Lesepfad `cellDates: true` setzt, liefern auch .xls-Dateien echte
  // Date-Objekte; 964 von 1076 Realdateien tragen Datumszellen, ein Großteil
  // davon in genau den Blättern, die dieser Parser liest.
  if (v instanceof Date) return null
  const n = Number(v)
  return isNaN(n) ? null : n
}

/** Number of cells in a row that match an RMR canonical header column — the
 * same scoring rule used to LOCATE a header row is reused (scanForHeaderRow
 * below) AND to GUARD data rows against being a mis-consumed header row from
 * a following block (parseRmrWorksheet's "garbage-row guard", see module
 * header "Known layout limitation" / KAR-902 follow-up fix). */
function scoreHeaderRow(row: readonly unknown[], ctx: RmrRegistryCtx): number {
  let score = 0
  for (const cell of row) {
    if (matchHeaderColumnSync(normalizeHeaderCell(cell), ctx)) score += 1
  }
  return score
}

/** Best-scoring row in grid[fromIdx, fromIdx+maxScan), or null when nothing
 * reaches HEADER_MATCH_MIN. Shared by findRmrHeaderRow (first block, fixed
 * window anchored at row 0) and parseRmrWorksheet's multi-block scan below
 * (subsequent blocks, window anchored at the previous block's end — Leitfaden
 * [44]: "Rohstoffpreisanteile werden in 2 Bloecken dargestellt"). */
function scanForHeaderRow(grid: unknown[][], ctx: RmrRegistryCtx, fromIdx: number, maxScan: number): number | null {
  let bestIdx: number | null = null
  let bestScore = 0
  const scanLimit = Math.min(grid.length, fromIdx + maxScan)
  for (let i = fromIdx; i < scanLimit; i++) {
    const score = scoreHeaderRow((grid[i] as unknown[]) ?? [], ctx)
    if (score > bestScore) {
      bestScore = score
      bestIdx = i
    }
  }
  return bestScore >= HEADER_MATCH_MIN ? bestIdx : null
}

/** Best matching row for the RMR header, scanning the first
 * HEADER_SCAN_MAX_ROWS rows exactly like material-parser.ts's
 * findMaterialHeaderRow — the FIRST block only (row 0 anchored). Subsequent
 * blocks are located internally by parseRmrWorksheet via scanForHeaderRow,
 * anchored at each preceding block's end instead of row 0. */
export async function findRmrHeaderRow(grid: unknown[][]): Promise<number | null> {
  if (!grid || grid.length === 0) return null
  const ctx = await loadRmrRegistry()
  return scanForHeaderRow(grid, ctx, 0, HEADER_SCAN_MAX_ROWS)
}

// ── Parse-level diagnostics ─────────────────────────────────────────────────

export interface RmrParseMeta {
  /** Arithmetic mean of per-column confidence over mapped columns — 1.0 for
   * an intact sheet, lower once normalized-only matches enter the mix. */
  parseConfidence: number
  /** Header cell text of every non-empty header-row cell that matched no RMR
   * canonical field. */
  unmappedHeaders: string[]
  /** Number of header columns mapped to an RmrFieldKey. */
  mappedFieldCount: number
  /** True once every block that was parsed had every CORE_RMR_FIELD_KEYS
   * located. False means at least one parsed block was missing a core field
   * — since KAR-958/P2, that block's own already-mapped columns are still
   * extracted (not unconditionally discarded) once MIN_SIGNAL_MAPPED_COLUMNS
   * is met; see `degradation` below and parseRmrWorksheet's degraded-block
   * branch. Also false (as before) when no block could be parsed at all. */
  coreFieldsFound: boolean
  /** KAR-958/P2 — set only when `coreFieldsFound` is false AND at least one
   * block was actually parsed (partial extraction, not the "nothing usable
   * at all" case). `undefined` on every fully-intact parse. */
  degradation?: FacetDegradation
  /**
   * KAR-902 follow-up (adversarial-review finding, confidence 85, "Zwei-
   * Block-Layout erzeugt stillen Datenverlust/Korruption"): 1-based sheet row
   * number of a header-scoring row (score >= HEADER_MATCH_MIN) that
   * parseRmrWorksheet located but did NOT parse — either because it was a
   * further block whose header was too degraded to trust (missing
   * CORE_RMR_FIELD_KEYS), or because it sits beyond every block-search
   * window this parse actually consumed. `null` when no such row exists
   * (the normal, fully-consumed case). Surfaced as a `pruefen`-severity
   * `rmr_possible_unparsed_block` PlausibilityIssue (see
   * rmrParseMetaToPlausibilityIssue below) — never silently dropped, per the
   * finding's core complaint.
   */
  possibleUnparsedBlockRow: number | null
  /**
   * KAR-958/P3 review fix (PR #325 finding #4): 0-based `[start, end)`
   * index ranges INTO THE RmrRow[] THIS META IS ATTACHED TO that came from a
   * degraded block (a block missing a CORE_RMR_FIELD_KEYS field but still
   * at/above MIN_SIGNAL_MAPPED_COLUMNS and contributing >=1 row — same
   * condition that sets `anyBlockDegraded` in parseRmrWorksheet). Empty when
   * every block that contributed rows was fully intact.
   *
   * Exists so rmrRowsForReconciliation (below) can filter OUT exactly the
   * degraded blocks' rows instead of the pre-fix "aggregate coreFieldsFound
   * is false -> discard the WHOLE result" blanket gate, which silently threw
   * away a still-fully-intact FIRST block's rows the moment a SECOND,
   * unrelated block in the same file degraded (confirmed data-loss finding
   * — a file comparison that used to correctly diff the intact block's rows
   * would otherwise silently show zero RMR rows, even though qaf_file.
   * g60_meta.rmr.rows — the raw persist, unfiltered — still had them).
   */
  degradedRowRanges: Array<{ start: number; end: number }>
  /**
   * KAR-958/P3 review fix (PR #325 finding #4) — true once at least ONE
   * block was fully intact (mirrors the `anyIntactBlockParsed` local in
   * parseRmrWorksheet), independent of whether ANOTHER block on the same
   * sheet degraded. Distinct from `coreFieldsFound` (which is the stricter,
   * whole-file `anyIntactBlockParsed && !anyBlockDegraded` aggregate) —
   * exists so rmrRowsForReconciliation can tell "confirmed, genuinely zero
   * rows survived filtering" (this true, e.g. an intact block whose header
   * was found but had no data rows below it — must reconcile to `[]`) apart
   * from "nothing trustworthy was ever parsed" (this false — must reconcile
   * to `null`), even after the per-block degraded-row filter runs.
   */
  anyIntactBlockParsed: boolean
}

export type RmrParseResult = RmrRow[] & RmrParseMeta

function withParseMeta(rows: RmrRow[], meta: RmrParseMeta): RmrParseResult {
  return Object.assign(rows, meta) as RmrParseResult
}

const EMPTY_PARSE_META: RmrParseMeta = {
  parseConfidence: 0,
  unmappedHeaders: [],
  mappedFieldCount: 0,
  coreFieldsFound: false,
  possibleUnparsedBlockRow: null,
  degradedRowRanges: [],
  anyIntactBlockParsed: false,
}

// ── Multi-block parsing (KAR-902 follow-up fix) ─────────────────────────────
//
// Leitfaden [44]: "Rohstoffpreisanteile werden in 2 Bloecken dargestellt:
// Rohstoffpreisanteil Material und Rohstoffpreisanteil Energie." Abbildung 26
// (S.45) shows both blocks on the SAME "RAW MATERIAL RISKS" tab, each with
// its own header row + data rows. The original single-block implementation
// of this module (before this fix) located exactly one header row and read
// one contiguous data block to the first blank row — silently losing the
// SECOND block whenever a blank-row separator was present (no runtime
// signal, parseConfidence stayed green), or misreading the second block's
// header row as a garbage data row whenever it was NOT present (adversarial-
// review finding, confidence 85). parseRmrWorksheet below fixes both:
//   1. after a block ends, it keeps scanning for a FURTHER header row
//      (scanForHeaderRow, same scoring rule as the initial header search);
//   2. a data row that itself scores like a header is NEVER pushed as data
//      (the "garbage-row guard") — it becomes the next block's header
//      directly, with no re-scan needed;
//   3. any header-scoring row that is STILL not consumed once the scan
//      genuinely stops (a further block too degraded to trust, or one
//      sitting beyond the normal per-block scan window) is flagged via
//      RmrParseMeta.possibleUnparsedBlockRow / the `rmr_possible_unparsed_
//      block` PlausibilityIssue — never silently dropped.
// The RoZ0 = Ro x Menge validation (validateRmrRawMaterialSurcharge below)
// runs UNCHANGED for rows from either block — it only reads
// rawMaterialQuotation/referenceWeight/rawMaterialSurcharge, and the
// Energie block's differently-unit-suffixed header labels ("Energieverbrauch
// pro h [kWh]", "Rohstoffnotierung Ro [AW/kWh]") are mapped onto the SAME
// two RmrFieldKeys via aliases added to canonical-fields.ts's
// rmr_reference_weight/rmr_raw_material_quotation entries — no separate
// Energie-specific validation path was needed.

const MAX_RMR_BLOCKS = 6 // safety cap only — the Leitfaden documents exactly 2 blocks

interface BlockColumnMap {
  colMap: Map<number, RmrFieldKey>
  colConfidence: Map<number, number>
  unmappedHeaders: string[]
  coreFieldsFound: boolean
}

function buildBlockColumnMap(headerRow: readonly string[], ctx: RmrRegistryCtx): BlockColumnMap {
  const colMap = new Map<number, RmrFieldKey>()
  const colConfidence = new Map<number, number>()
  const unmappedHeaders: string[] = []
  const usedKeys = new Set<RmrFieldKey>()

  for (let i = 0; i < headerRow.length; i++) {
    const cell = headerRow[i]
    if (cell === '') continue
    const match = matchHeaderColumnSync(cell, ctx)
    if (!match || usedKeys.has(match.key)) {
      // No documented RMR label duplicate (unlike MATERIAL's Mengeneinheit
      // collision) — a second column claiming an already-used key is treated
      // as unmapped rather than silently overwriting the first column.
      unmappedHeaders.push(cell)
      continue
    }
    colMap.set(i, match.key)
    colConfidence.set(i, match.confidence)
    usedKeys.add(match.key)
  }

  const mappedKeys = new Set(colMap.values())
  const coreFieldsFound = CORE_RMR_FIELD_KEYS.every((k) => mappedKeys.has(k))
  return { colMap, colConfidence, unmappedHeaders, coreFieldsFound }
}

/** Extract one data row given an already-built column map. Returns null when
 * the row-push guard rejects it. Pure.
 *
 * Two independent guards (KAR-958/P3 review fix, PR #325 finding #3):
 *   1. `!r.rawMaterialDesignation` — no genuine RMR position (e.g. a footer/
 *      subtotal row; mirrors material-parser.ts's pattern).
 *   2. `![...colMap.values()].includes('rawMaterialQuotation')` — the block's
 *      HEADER never mapped a Rohstoffnotierung-Ro column at all. Before this fix, the
 *      MIN_SIGNAL_MAPPED_COLUMNS=2 floor was satisfiable with just
 *      positionNumber+rawMaterialDesignation mapped (2 columns), and guard 1
 *      alone let every row through with a silently-null rawMaterialQuotation
 *      — indistinguishable downstream from a genuine RMR row whose quotation
 *      cell is legitimately blank (Leitfaden [44]: quotation is filled in
 *      "zum Zeitpunkt der Vergabe", so a null VALUE on an otherwise-intact
 *      header is expected and already handled by validateRmrRawMaterialSurcharge's
 *      own missingFieldReason path). Guard 2 checks the COLUMN MAP, not the
 *      per-row value, so it only rejects the "header never had this column"
 *      case — a row's own legitimately-blank quotation cell in a block where
 *      the column IS mapped still passes through untouched. */
function extractRmrRow(
  grid: unknown[][],
  rowIdx: number,
  colMap: Map<number, RmrFieldKey>,
  sheetName: string,
  blockType: RmrBlockType,
): RmrRow | null {
  const row = (grid[rowIdx] as unknown[]) ?? []
  const r: Partial<RmrRowValues> = {}
  for (const key of ALL_FIELD_KEYS) {
    ;(r as Record<string, unknown>)[key] = TEXT_FIELDS.has(key) ? '' : null
  }

  const sourceCells: Partial<Record<RmrFieldKey, string>> = {}
  const normalized: Partial<Record<RmrFieldKey, string | number | null>> = {}
  const rawText: Partial<Record<RmrFieldKey, string>> = {}
  const sheetRowNumber = rowIdx + 1 // worksheetToGrid is 0-based, ExcelJS rows are 1-based

  for (const [colIdx, key] of colMap.entries()) {
    const val = row[colIdx]
    const normVal: string | number | null = TEXT_FIELDS.has(key) ? String(val ?? '').trim() : toNum(val)
    ;(r as Record<string, unknown>)[key] = normVal
    sourceCells[key] = `${sheetName}!${colLetter(colIdx)}${sheetRowNumber}`
    normalized[key] = normVal
    if (!TEXT_FIELDS.has(key) && normVal === null && val !== null && val !== undefined && String(val).trim() !== '') {
      rawText[key] = String(val)
    }
  }

  if (!r.rawMaterialDesignation) return null
  // colMap: Map<COLUMN INDEX, RmrFieldKey> — `.has()` checks the numeric
  // keys, NOT the field-key values, so the mapped-column check has to scan
  // `.values()` instead (colMap.has('rawMaterialQuotation') would always be
  // false, silently rejecting EVERY row regardless of the header).
  if (![...colMap.values()].includes('rawMaterialQuotation')) return null
  return { ...(r as RmrRowValues), sourceCells, normalized, rawText, blockType }
}

/**
 * Detect a block's type from the literal section-title text the Leitfaden
 * documents for this sheet (Abbildung 26, S.45: 'Block "Rohstoff
 * Preisanteil Energie"' / 'Block "Rohstoff Preisanteil Material"'). Scans
 * every cell in grid[gapStart, headerIdx) — the rows between the previous
 * block's end (or the sheet start, for the first block) and this block's own
 * header row — for the case-insensitive substrings "energie"/"material".
 * Returns 'unbekannt' when neither, both (ambiguous title), or nothing in
 * the gap is distinguishable — the order-based fallback below is applied
 * separately, never guessed here from a single block in isolation.
 */
function detectBlockTypeFromTitle(grid: unknown[][], gapStart: number, headerIdx: number): RmrBlockType {
  let sawEnergie = false
  let sawMaterial = false
  for (let i = gapStart; i < headerIdx; i++) {
    const row = (grid[i] as unknown[]) ?? []
    for (const cell of row) {
      const text = String(cell ?? '').toLowerCase()
      if (text.includes('energie')) sawEnergie = true
      if (text.includes('material')) sawMaterial = true
    }
  }
  if (sawEnergie && !sawMaterial) return 'energie'
  if (sawMaterial && !sawEnergie) return 'material'
  return 'unbekannt'
}

/**
 * Conservative order-based fallback (task instruction: "wenn nicht
 * zuverlaessig unterscheidbar: Reihenfolge-Heuristik + 'unbekannt'
 * konservativ") — applied ONLY when exactly 2 blocks were found on the sheet
 * AND neither block's title text was distinguishable (both 'unbekannt' from
 * detectBlockTypeFromTitle). The Leitfaden documents a fixed, always-2-block
 * layout with the Energie block listed first (Abbildung 26, S.45 lists
 * "Rohstoff Preisanteil Energie" before "Rohstoff Preisanteil Material") —
 * this is therefore a deliberately low-confidence, LAST-RESORT default, never
 * applied to a single block (the overwhelmingly common real case — Leitfaden
 * [44]: the sheet is only filled in "zum Zeitpunkt der Vergabe", and most
 * quotations carry no Rohstoff-Preisanteil-Energie at all) or to 3+ blocks
 * (beyond the documented shape, no fallback ordering is defined for that
 * case — stays 'unbekannt').
 */
function applyBlockOrderFallback(blockTypes: readonly RmrBlockType[]): RmrBlockType[] {
  if (blockTypes.length !== 2) return [...blockTypes]
  if (blockTypes[0] !== 'unbekannt' || blockTypes[1] !== 'unbekannt') return [...blockTypes]
  return ['energie', 'material']
}

/**
 * Parse an already-located RAW MATERIAL RISKS worksheet — one OR MORE
 * documented blocks (see "Multi-block parsing" above). Pure aside from the
 * lazy registry import. Never throws on a degraded/unusable header (see
 * CORE_RMR_FIELD_KEYS doc comment) — returns an empty, flagged result
 * instead, so a malformed RMR sheet cannot fail the whole file ingest.
 */
export async function parseRmrWorksheet(ws: Worksheet): Promise<RmrParseResult> {
  const grid = worksheetToGrid(ws)
  if (grid.length < 2) return withParseMeta([], EMPTY_PARSE_META)

  const ctx = await loadRmrRegistry()
  const sheetName = ws.name

  const allRows: RmrRow[] = []
  const blockRanges: Array<{ start: number; end: number; blockType: RmrBlockType }> = []
  const unmappedHeaders: string[] = []
  const allConfidences: number[] = []
  let mappedFieldCountMax = 0
  let anyBlockParsed = false
  /** At least one block reached AND fully intact (coreFieldsFound at the
   * block level) — the "is there something fully trustworthy here" signal.
   * Distinct from anyBlockParsed (which also becomes true for a
   * degraded-but-attempted block, KAR-958/P2). */
  let anyIntactBlockParsed = false
  /** A degraded block actually contributed >=1 row to allRows (its own
   * row-identity field survived, only a DIFFERENT core field is missing) —
   * unreliable/partial data is now mixed into the aggregate, so the overall
   * result can no longer be reported as fully trustworthy even if an
   * earlier block was intact. See the block loop below. */
  let anyBlockDegraded = false
  /** KAR-958/P3 review fix (PR #325 finding #4) — [start, end) ranges into
   * allRows for every degraded block that contributed rows; threaded through
   * to RmrParseMeta.degradedRowRanges so rmrRowsForReconciliation can filter
   * per block instead of blanket-nulling the whole result. */
  const degradedRowRanges: Array<{ start: number; end: number }> = []
  const missingCoreKeysUnion = new Set<RmrFieldKey>()
  let searchFrom = 0
  let pendingHeaderIdx: number | null = null
  let possibleUnparsedBlockRow: number | null = null

  for (let blockOrdinal = 0; blockOrdinal < MAX_RMR_BLOCKS; blockOrdinal++) {
    const gapStart = searchFrom
    const headerIdx: number | null = pendingHeaderIdx ?? scanForHeaderRow(grid, ctx, searchFrom, HEADER_SCAN_MAX_ROWS)
    pendingHeaderIdx = null
    if (headerIdx === null) break

    const headerRow = (grid[headerIdx] as unknown[]).map(normalizeHeaderCell)
    const block = buildBlockColumnMap(headerRow, ctx)
    unmappedHeaders.push(...block.unmappedHeaders)
    mappedFieldCountMax = Math.max(mappedFieldCountMax, block.colMap.size)

    if (!block.coreFieldsFound && block.colMap.size < MIN_SIGNAL_MAPPED_COLUMNS) {
      // Below the minimum-signal floor — treated exactly as before this PR:
      // a further header-scoring row exists, but this block is too degraded
      // to trust at all (module header point 3, finding's core complaint:
      // never silently drop it) — flag it, do not parse it, stop scanning
      // for MORE blocks beyond it (a degraded block's own data extent is
      // unknown, so there is nothing safe to skip past).
      possibleUnparsedBlockRow = headerIdx + 1
      break
    }

    // KAR-958/P2 (gate-audit.md B7): at/above the minimum-signal floor, a
    // block missing a core field is no longer discarded outright — its own
    // already-mapped columns are extracted below via the SAME row-reading
    // loop an intact block uses (blank-row/header-row terminated, so a
    // partial column map does not change where this block's data ends).
    // Scanning for FURTHER blocks still stops right after it (unchanged
    // safety property: a degraded block's boundary is only trustworthy once
    // actually read, never guessed by skipping past it blind). Flag this
    // block's own header row as `possibleUnparsedBlockRow` up front, exactly
    // as the pre-KAR-958 immediate-break path did — independent of whether
    // extraction below actually yields a row (extractRmrRow's own
    // rawMaterialDesignation guard may still reject every row, e.g. when
    // THAT is the missing core field; the header location itself is still
    // worth flagging for manual review either way).
    const blockIsDegraded = !block.coreFieldsFound
    if (blockIsDegraded) {
      possibleUnparsedBlockRow = headerIdx + 1
      const blockMappedKeys = new Set(block.colMap.values())
      for (const k of CORE_RMR_FIELD_KEYS) {
        if (!blockMappedKeys.has(k)) missingCoreKeysUnion.add(k)
      }
    }

    const blockType = detectBlockTypeFromTitle(grid, gapStart, headerIdx)
    const blockStart = allRows.length

    let i: number = headerIdx + 1
    let stoppedOnHeaderRow = false
    for (; i < grid.length; i++) {
      const row = (grid[i] as unknown[]) ?? []
      if (row.every((c) => c === '' || c === null || c === undefined)) {
        i++
        break
      }
      // Garbage-row guard (the finding's core fix): a row whose cells score
      // like a header is NEVER pushed as data — it is the NEXT block's
      // header appearing directly after this block's last data row, with no
      // blank-row separator.
      if (scoreHeaderRow(row, ctx) >= HEADER_MATCH_MIN) {
        stoppedOnHeaderRow = true
        break
      }
      const parsed = extractRmrRow(grid, i, block.colMap, sheetName, blockType)
      if (parsed) allRows.push(parsed)
    }

    // Top-level coreFieldsFound only downgrades when a degraded block
    // actually contributed rows to allRows (unreliable/partial data now
    // mixed into the aggregate) — a degraded block whose OWN row-identity
    // field was among the missing core fields contributes NOTHING (the
    // extractRmrRow guard rejects every row), so it taints nothing beyond
    // the possibleUnparsedBlockRow flag already set above (mirrors the
    // pre-KAR-958 behavior for that specific, still-common case: a fully
    // intact first block's rows remain fully trusted).
    if (blockIsDegraded) {
      if (allRows.length > blockStart) {
        anyBlockDegraded = true
        degradedRowRanges.push({ start: blockStart, end: allRows.length })
      }
    } else {
      anyIntactBlockParsed = true
    }

    blockRanges.push({ start: blockStart, end: allRows.length, blockType })
    allConfidences.push(...block.colConfidence.values())
    anyBlockParsed = true

    if (blockIsDegraded) {
      // Unchanged safety property (see comment above): scanning for a
      // FURTHER block after this one still stops right here.
      break
    }

    if (stoppedOnHeaderRow) {
      pendingHeaderIdx = i
      searchFrom = i
    } else {
      searchFrom = i
    }

    if (i >= grid.length) break
  }

  // Sicherheitsnetz (module header point 3): applies regardless of whether
  // any block was successfully parsed — a file whose FIRST block is already
  // too degraded to trust, or whose only real block sits beyond the normal
  // per-block scan window, must not silently report "nothing here" when a
  // header-scoring row genuinely still exists further down. Skipped when
  // already set inside the loop (the degraded-block break above).
  if (possibleUnparsedBlockRow === null) {
    if (pendingHeaderIdx !== null) {
      possibleUnparsedBlockRow = pendingHeaderIdx + 1
    } else {
      const missed = scanForHeaderRow(grid, ctx, searchFrom, Math.max(0, grid.length - searchFrom))
      if (missed !== null) possibleUnparsedBlockRow = missed + 1
    }
  }

  if (!anyBlockParsed) {
    return withParseMeta([], {
      parseConfidence: 0,
      unmappedHeaders,
      mappedFieldCount: 0,
      coreFieldsFound: false,
      possibleUnparsedBlockRow,
      degradedRowRanges: [],
      anyIntactBlockParsed: false,
    })
  }

  // Order-heuristic block-type fallback (see applyBlockOrderFallback doc
  // comment) — only ever adjusts the specific "exactly 2 blocks, both
  // unbekannt from title text" case, patched back onto the already-pushed
  // rows via their recorded index ranges.
  const resolvedTypes = applyBlockOrderFallback(blockRanges.map((b) => b.blockType))
  resolvedTypes.forEach((blockType, idx) => {
    if (blockType === blockRanges[idx].blockType) return
    for (let r = blockRanges[idx].start; r < blockRanges[idx].end; r++) {
      allRows[r] = { ...allRows[r], blockType }
    }
  })

  const parseConfidence =
    allConfidences.length > 0 ? allConfidences.reduce((a, b) => a + b, 0) / allConfidences.length : 0

  // KAR-958/P2 (gate-audit.md B7): trustworthy overall only when at least
  // one block was FULLY intact AND no degraded block mixed partial/
  // unreliable rows into the aggregate — see anyIntactBlockParsed/
  // anyBlockDegraded doc comments above and the RmrParseMeta.coreFieldsFound
  // doc comment.
  const coreFieldsFound = anyIntactBlockParsed && !anyBlockDegraded
  const degradation: FacetDegradation | undefined = coreFieldsFound
    ? undefined
    : {
        facet: 'rmr',
        reason: 'PARSE_FAILED',
        sheet: sheetName,
        message: `RAW MATERIAL RISKS-Kernfelder fehlen in mindestens einem Block: ${[...missingCoreKeysUnion].join(', ')}.`,
      }

  return withParseMeta(allRows, {
    parseConfidence,
    unmappedHeaders,
    mappedFieldCount: mappedFieldCountMax,
    coreFieldsFound,
    possibleUnparsedBlockRow,
    degradedRowRanges,
    anyIntactBlockParsed,
    ...(degradation ? { degradation } : {}),
  })
}

/**
 * Locate + parse the RAW MATERIAL RISKS sheet from an already-loaded
 * workbook. Returns null when the workbook has none at all (the additive
 * gate — distinct from "sheet present but header unusable", which returns an
 * empty RmrParseResult with coreFieldsFound:false instead).
 */
export async function parseRmrSheet(wb: { worksheets: Worksheet[] }): Promise<RmrParseResult | null> {
  const ws = findRmrWorksheet(wb)
  if (!ws) return null
  return parseRmrWorksheet(ws)
}

// ── Parse-meta -> PlausibilityIssue bridge (KAR-902 follow-up fix) ─────────
//
// Reuses the qaf_plausibility_issue path (no schema change), same pattern as
// plausibility.ts's manufacturingParseDegradationToPlausibilityIssue — takes
// the meta as a plain object rather than growing compare.ts's import surface
// for a re-exported type. Threaded through actions.ts (ingest -> g60_meta.rmr.
// parseMeta -> KAR-899-style rehydration) and compare.ts (QafFileParsed.
// rmrParseMeta), same three-hop wiring manufacturingParseMeta already uses.

/** Pure. Returns null when nothing was left unparsed — a clean multi-block
 * parse (or a file with no RMR sheet at all) produces no issue. */
export function rmrParseMetaToPlausibilityIssue(
  meta: Pick<RmrParseMeta, 'possibleUnparsedBlockRow'>,
  side: ComparisonSide,
): PlausibilityIssue | null {
  if (meta.possibleUnparsedBlockRow === null) return null
  return {
    type: 'rmr_possible_unparsed_block',
    severity: 'pruefen' as PlausibilitySeverity,
    step: side,
    explanation: `RAW MATERIAL RISKS (${side}): moeglicher weiterer, nicht eingelesener Block ab Zeile ${meta.possibleUnparsedBlockRow} erkannt (Zeile mit ausreichend Spalten-Treffern gegen die RMR-Feldliste, aber ausserhalb des tatsaechlich geparsten Bereichs bzw. mit fehlenden Kernfeldern) — bitte manuell pruefen, ob dort echte Rohstoffdaten stehen.`,
    explanationEn: `RAW MATERIAL RISKS (${side}): possible additional, unparsed block detected starting at row ${meta.possibleUnparsedBlockRow} (a row with enough column matches against the RMR field list, but outside the actually parsed range or with missing core fields) — please manually review whether real raw-material data is present there.`,
  }
}

/**
 * Derive the tri-state `RmrRow[] | null` value callers (actions.ts's
 * ingestQafUpload, and via it QafFileParsed.rmrRows / a future
 * ReconciliationInput.rmrRows) must persist/thread from a `parseRmrSheet`
 * result — the single source of truth for that derivation, mirroring
 * material-parser.ts's materialRowsForReconciliation / sbm-parser.ts's
 * sbmRowsForReconciliation (the established pattern this module builds in
 * from day one instead of retrofitting, per the KAR-902 task instruction
 * "die #274-Lektion nicht wiederholen" — see sbm-parser.ts's own doc comment
 * on this function for the full false-positive scenario a naive `parsed ?
 * [...parsed] : null` would create).
 *
 * KAR-958/P3 review fix (PR #325 finding #4): used to blanket-null the WHOLE
 * result whenever `!parsed.coreFieldsFound` — but `coreFieldsFound` is a
 * FILE-level aggregate (`anyIntactBlockParsed && !anyBlockDegraded`) that
 * goes false the moment ANY block degrades, even when a DIFFERENT block on
 * the same sheet was fully intact. That discarded a still-trustworthy
 * block's rows from reconciliation/comparison entirely, even though the raw
 * persist (qaf_file.g60_meta.rmr.rows, unfiltered) kept them — a silent
 * data-loss regression confirmed by review. Filters PER BLOCK instead, using
 * `degradedRowRanges` (parseRmrWorksheet's own record of which row ranges
 * came from a degraded block): an intact block's rows survive into
 * reconciliation, a degraded block's rows are excluded from it — exactly the
 * task instruction's "intakte Block-Zeilen bleiben drin, degradierte raus"
 * — while remaining visible (with their FacetDegradation reason) in the raw
 * persist untouched. Returns null only when literally nothing survives the
 * filter (no block parsed at all, or every parsed block that contributed
 * rows was degraded) — same "nothing to reconcile" contract as before.
 */
export function rmrRowsForReconciliation(parsed: RmrParseResult | null): RmrRow[] | null {
  if (parsed === null) return null
  const degraded = parsed.degradedRowRanges
  if (degraded === undefined) {
    // Backward-compat fallback: a g60_meta.rmr.parseMeta JSONB persisted
    // BEFORE this fix (KAR-958/P3) has no `degradedRowRanges` key at all —
    // the property is genuinely `undefined` at runtime here (not `[]`, which
    // every FRESH parse now sets explicitly, including the "nothing parsed"
    // early-return branch above). There is no way to recover which specific
    // rows came from a degraded block from that older shape, so this falls
    // back to the EXACT pre-fix blanket gate (byte-identical to the
    // pre-KAR-958/P3 implementation) rather than guessing "trust
    // everything" — a rehydrated legacy file with `coreFieldsFound: false`
    // keeps reconciling to `null` until it is next re-ingested/replaced (at
    // which point the fresh parse populates `degradedRowRanges` and the
    // per-block filter below takes over).
    if (!parsed.coreFieldsFound) return null
    return [...parsed]
  }
  const rows = degraded.length === 0 ? [...parsed] : parsed.filter((_, idx) => !degraded.some((r) => idx >= r.start && idx < r.end))
  // `anyIntactBlockParsed` (not `coreFieldsFound`, and not just "rows.length
  // > 0") decides null vs. `[]`: a fully intact block whose header was found
  // but had zero data rows below it must still reconcile to a CONFIRMED `[]`
  // (mirrors the "even when legitimately empty" contract every sibling
  // module's *RowsForReconciliation already documents) — `rows.length > 0`
  // alone would wrongly collapse that case to `null` just because the
  // filtered array happens to be empty.
  if (rows.length > 0) return rows
  return parsed.anyIntactBlockParsed ? rows : null
}

/**
 * The shape actions.ts's ingestQafUpload persists on `qaf_file.g60_meta.rmr`
 * (see that file's `rmrMeta` local) — `null` when no RMR sheet was found at
 * all, otherwise the parsed rows plus the same RmrParseMeta a live
 * RmrParseResult carries.
 */
export interface PersistedRmrMeta {
  rows: RmrRow[]
  parseMeta: RmrParseMeta
}

/**
 * Persisted-JSONB counterpart to rmrRowsForReconciliation above (KAR-899
 * rehydration path, threaded through from the start per task instruction) —
 * same rationale and tri-state contract as material-parser.ts's
 * materialRowsFromPersistedMeta / sbm-parser.ts's sbmRowsFromPersistedMeta:
 * `undefined` when `qaf_file.g60_meta.rmr` carries no key at all (pre-
 * KAR-902/P2.3 file, or a G60 file — no RMR parse was ever attempted),
 * `null`/rows otherwise, delegated to rmrRowsForReconciliation so the
 * live-parse and rehydrated-JSONB entry points cannot drift.
 */
export function rmrRowsFromPersistedMeta(meta: PersistedRmrMeta | null | undefined): RmrRow[] | null | undefined {
  if (meta === undefined) return undefined
  if (meta === null) return null
  return rmrRowsForReconciliation(withParseMeta([...meta.rows], meta.parseMeta))
}

// ── Rohstoffzuschlag-Validierung (RoZ0 = Ro x Bezugsgewicht) ────────────────
//
// See module header "Rohstoffzuschlag-Validierung" for the evidence
// derivation (Leitfaden [45] Abbildung 26 worked examples, S.45). Modeled
// after business-rules.ts's per-row check shape (BusinessRuleResult /
// judge()/notPruefbar()) but kept SELF-CONTAINED in this module rather than
// added to business-rules.ts — that module is KAR-901/P2.2's own scope
// (MANUFACTURING/MATERIAL/SBM formulas only, per its module header inventory)
// and this PR does not touch it. Issue namespace is `rmr_*`, distinct from
// business-rules.ts's `rule_calc_*` and reconciliation.ts's `recon_*`.

export type RmrValidationCheckId = 'rmr_raw_material_surcharge'
export type RmrValidationStatus = 'bestanden' | 'abweichung' | 'nicht_pruefbar'

export interface RmrValidationResult {
  checkId: RmrValidationCheckId
  side: ComparisonSide
  /** Index in the rmrRows array this result belongs to. -1 for a file-level
   * result (no RMR sheet at all — there is no row to index into). */
  rowIndex: number
  /** Rohstoffbezeichnung of the row (empty string for a file-level result). */
  rawMaterialDesignation: string
  status: RmrValidationStatus
  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 / business-rules.ts's
 * BUSINESS_RULES_CONFIG (0.5% relativ + 1 AW absolut, KAR-896-Muster), kept as
 * its own local constant (not wired into engine-config.ts's central
 * DEFAULT_ENGINE_CONFIG in this PR — a single, narrowly-scoped check does not
 * need its own governed config section; revisit if a future PR adds more RMR
 * checks that warrant one).
 */
export interface RmrValidationConfig {
  /** Fraction, e.g. 0.005 = 0.5%. */
  relativeTolerance: number
  /** Absolute floor in AW, applied via max(relative, absolute). */
  absoluteToleranceMinor: number
}

export const RMR_VALIDATION_CONFIG: RmrValidationConfig = {
  relativeTolerance: 0.005,
  absoluteToleranceMinor: 1,
}

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

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

function judge(
  side: ComparisonSide,
  rowIndex: number,
  rawMaterialDesignation: string,
  expected: number,
  actual: number,
  cfg: RmrValidationConfig,
): RmrValidationResult {
  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 label = rawMaterialDesignation || '(ohne Rohstoffbezeichnung)'
  const messageDe = `Rohstoffzuschlag RoZ0 (${side}, ${label}): Excel weist ${fmt(actual)} aus, die unabhaengige Nachrechnung (Rohstoffnotierung Ro x Bezugsgewicht) ergibt ${fmt(expected)} (Delta ${fmt(deltaAbsolute)}) — Abweichung ausserhalb der Toleranz (${(cfg.relativeTolerance * 100).toFixed(1)} % / min. ${cfg.absoluteToleranceMinor}).`
  const messageEn = `Raw material surcharge RoZ0 (${side}, ${label}): Excel states ${fmt(actual)}, the independent recomputation (quotation x reference weight) yields ${fmt(expected)} (delta ${fmt(deltaAbsolute)}) — deviation exceeds tolerance (${(cfg.relativeTolerance * 100).toFixed(1)}% / min. ${cfg.absoluteToleranceMinor}).`
  return {
    checkId: 'rmr_raw_material_surcharge',
    side,
    rowIndex,
    rawMaterialDesignation,
    status: ok ? 'bestanden' : 'abweichung',
    expected,
    actual,
    deltaAbsolute,
    deltaPercent,
    ...(ok ? {} : { messageDe, messageEn }),
  }
}

function notPruefbar(
  side: ComparisonSide,
  rowIndex: number,
  rawMaterialDesignation: string,
  reason: string,
  reasonEn: string,
): RmrValidationResult {
  return {
    checkId: 'rmr_raw_material_surcharge',
    side,
    rowIndex,
    rawMaterialDesignation,
    status: 'nicht_pruefbar',
    expected: null,
    actual: null,
    deltaAbsolute: null,
    deltaPercent: null,
    reason,
    reasonEn,
  }
}

/**
 * Row-level check: RoZ0 (rawMaterialSurcharge) == Ro (rawMaterialQuotation) x
 * Bezugsgewicht (referenceWeight), Leitfaden [45] Abbildung 26 worked-example
 * derivation (see module header). Gated (module header "Gating"): returns
 * null — no result at all, not even nicht_pruefbar — when rawMaterialSurcharge
 * itself is empty (nothing to check yet, a normal pre-Vergabe intermediate
 * state per [44]). A negative rawMaterialQuotation (Steel Scrap Coil-style
 * credit, [45] "Ausnahmen/Sonderfaelle") is NEVER treated as an error here —
 * it flows through the multiplication like any other signed number.
 */
export function validateRmrRawMaterialSurcharge(
  row: RmrRow,
  rowIndex: number,
  side: ComparisonSide,
  cfg: RmrValidationConfig = RMR_VALIDATION_CONFIG,
): RmrValidationResult | null {
  const actual = row.rawMaterialSurcharge
  if (actual === null) return null // Gating: nothing to check on this row yet.

  const missing: { de: string; en: string }[] = []
  if (row.rawMaterialQuotation === null) {
    const naMarked = row.rawText?.rawMaterialQuotation !== undefined && isNotApplicableValue(row.rawText.rawMaterialQuotation)
    missing.push(missingFieldReason('Rohstoffnotierung Ro [AW/kg]', rmrLabelEn('rawMaterialQuotation', 'Rohstoffnotierung Ro [AW/kg]'), naMarked))
  }
  if (row.referenceWeight === null) {
    const naMarked = row.rawText?.referenceWeight !== undefined && isNotApplicableValue(row.rawText.referenceWeight)
    missing.push(missingFieldReason('Bezugsgewicht [kg]', rmrLabelEn('referenceWeight', 'Bezugsgewicht [kg]'), naMarked))
  }
  if (missing.length > 0) {
    const joined = joinMissingReasons(missing)
    return notPruefbar(side, rowIndex, row.rawMaterialDesignation, joined.de, joined.en)
  }

  const expected = row.rawMaterialQuotation! * row.referenceWeight!
  return judge(side, rowIndex, row.rawMaterialDesignation, expected, actual, cfg)
}

export interface RmrValidationInput {
  side: ComparisonSide
  /** Tri-state, identical contract to reconciliation.ts's
   * ReconciliationInput.materialRows / business-rules.ts's
   * BusinessRuleInput.materialRows: undefined = no RMR parse attempted (the
   * check is omitted entirely, not even as nicht_pruefbar); null = a parse
   * was attempted but no usable RMR sheet was found (one file-level
   * nicht_pruefbar); RmrRow[] = parsed rows (can legitimately be empty). */
  rmrRows?: RmrRow[] | null
}

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

/** Orchestrator over one side's rmrRows tri-state — mirrors business-rules.ts's
 * evaluateBusinessRules materialRows/sbmRows handling exactly. */
export function evaluateRmrValidation(
  input: RmrValidationInput,
  config: RmrValidationConfig = RMR_VALIDATION_CONFIG,
): RmrValidationResult[] {
  const { side, rmrRows } = input
  if (rmrRows === undefined) return [] // no parse attempted -> no results at all
  if (rmrRows === null) return [rmrFileLevelNotPruefbar(side)]

  const results: RmrValidationResult[] = []
  rmrRows.forEach((row, idx) => {
    const r = validateRmrRawMaterialSurcharge(row, idx, side, config)
    if (r) results.push(r)
  })
  return results
}

// ── Persistence bridge ───────────────────────────────────────────────────────
//
// Reuses the qaf_plausibility_issue path (no schema change), namespaced
// rmr_raw_material_surcharge for a breached tolerance,
// rmr_raw_material_surcharge_nicht_pruefbar for an incomplete basis — same
// pattern as reconciliation.ts's recon_<checkId>/business-rules.ts's
// rule_calc_<checkId>. 'bestanden' never produces an issue. Severity 'pruefen'
// (not 'kritisch'): RoZ0 is not currently summed against any Summary total by
// reconciliation.ts (no RMR facet exists there in this PR, see module header
// "Cross-Sheet-Reconciliation" scope note) — same one-hop-removed reasoning
// business-rules.ts documents for its own non-cascade-linked checks.
export function rmrValidationResultToPlausibilityIssue(r: RmrValidationResult): PlausibilityIssue | null {
  if (r.status === 'bestanden') return null

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

  return {
    type: r.checkId,
    severity: 'pruefen' as PlausibilitySeverity,
    step: r.side,
    explanation: r.messageDe ?? '',
    explanationEn: r.messageEn,
  }
}

export function checkRmrValidation(
  input: RmrValidationInput,
  config: RmrValidationConfig = RMR_VALIDATION_CONFIG,
): PlausibilityIssue[] {
  return evaluateRmrValidation(input, config)
    .map(rmrValidationResultToPlausibilityIssue)
    .filter((x): x is PlausibilityIssue => x !== null)
}
