// QAF Fehlerreport-Regel-Engine (R1-R6), KAR-889 / P0.4.
//
// Deterministic, pure validation layer derived empirically from the BMW // allow-customer-string
// Fehlerreport-Analyse (brain: 01-Projekte/supplierpulse-qaf-adaptive-engine/
// 03-fehlerreport-analyse.md §4.2). Distinct from plausibility.ts (cross-file
// sanity checks like negative costs, currency change, part-number mismatch):
// this module classifies field-level "provided correctly / not" state PER
// FILE using a field-state model, then applies R1-R4/R6 to derive
// violations. Pure, no LLM, no I/O.
//
// The source Fehlerreport judges a supplier's QAF submission via a colored
// ✓/x column that Kadi-v2 never sees (that report is BMW-side metadata, not // allow-customer-string
// part of the QAF file itself — spec §4.1: "Farb-Binärität statt
// 4-Klassen-Modell", only 2 of 4 legend colors are ever used in practice).
// Kadi-v2 has no such external judgment to read; the closest deterministic
// analog it CAN derive from its own parsed QAF data is whether a field was
// filled in at all and, if numeric, whether the cell content parsed as a
// number or an explicit "n.a." marker (KAR-886/891 provenance: sourceCells/
// normalized/rawText). That derived state is what R1-R4/R6 operate on below.
//
// R5 ("kein 1:1-Diff bei unterschiedlichen Quelldateien") is a program-level
// test-fixture-handling constraint for comparing two *Fehlerreports* against
// each other — not an engine rule over parsed QAF data — and is intentionally
// NOT implemented here (matches backlog P0.4 scope: "wird dort dokumentiert,
// nicht als Engine-Rule kodiert").
//
// [unklar] flagged item from the source analysis (§6.1): whether R3
// ("Nicht-Pflichtfeld inkorrekt") should ever become blocking is an open
// BMW-fachliche Frage. R3 is therefore hard-wired to `warn` regardless of // allow-customer-string
// `ruleEnforcement` — only R2 (Pflichtfeld-Blockade) respects the
// enforcement switch. Do not change this without Fachfreigabe.

import type { QAFRow, QAFFieldKey } from '@/lib/qaf-parser'
import { isBlank, isNotApplicableValue } from './normalizer'
import type { QafSummary, QafSummaryKey, FieldState } from './types'
import type { PlausibilityIssue, PlausibilitySeverity } from './plausibility'
import {
  QAF_FIELD_KEY_TO_CANONICAL,
  MATERIAL_FIELD_KEY_TO_CANONICAL,
  SBM_FIELD_KEY_TO_CANONICAL,
  RMR_FIELD_KEY_TO_CANONICAL,
  LOG_FIELD_KEY_TO_CANONICAL,
} from './canonical-fields'
import { byCanonicalId } from './canonical-model'
import type { MaterialRow, MaterialFieldKey } from './material-parser'
import type { SbmRow, SbmFieldKey } from './sbm-parser'
import type { RmrRow, RmrFieldKey } from './rmr-parser'
import type { LogisticsRow, LogisticsFieldKey } from './logistics-parser'

export type RuleId = 'R1' | 'R2' | 'R3' | 'R4' | 'R6'
export type RuleEnforcement = 'warn' | 'block'
export type ComparisonSide = 'ALT' | 'NEU'

export interface RuleEngineConfig {
  ruleEnforcement: RuleEnforcement
}

/**
 * Central rule-config constant — one section of DEFAULT_ENGINE_CONFIG since
 * P1.5/KAR-896 (engine-config.ts assembles it verbatim, does not redefine
 * it). Default 'warn': violations stay visible as issues, nothing is
 * blocked.
 *
 * FACHENTSCHEID (Kais, KAR-896 comment 10.07.2026, DAUERHAFT — nicht nur
 * "weich starten" wie ursprünglich bei KAR-889 formuliert): R2
 * (Pflichtfeld-Blockade) bleibt DAUERHAFT eine Warnung im produktiven
 * Default. 'block' bleibt als gültiger RuleEnforcement-Wert im Typ und in
 * validateEngineConfig bestehen (Options-Wert, z.B. für gezielte
 * Testfälle/spätere Fachfreigabe), wird aber NIE der Default. Diese
 * Entscheidung darf nicht ohne neue explizite Fachfreigabe rückgängig
 * gemacht werden (siehe auch R3-Hinweis im Modul-Header oben: R3 ist ohnehin
 * fest auf 'warn' verdrahtet, unabhängig von diesem Switch).
 */
export const RULE_ENGINE_CONFIG: RuleEngineConfig = {
  ruleEnforcement: 'warn',
}

export interface RuleViolation {
  ruleId: RuleId
  severity: PlausibilitySeverity
  /** Human-readable field label (DE), for display/persistence — NOT a lookup key. */
  field?: string
  step?: string
  side?: ComparisonSide
  /**
   * KAR-909/P3.4: extended from the original 'summary' | 'fertigungskosten'
   * pair to also cover the 4 detail-sheet channels (material/sbm/rmr/
   * logistics) — see evaluateDetailRowRules below. ruleViolationToPlausibilityIssue
   * only prefixes the persisted issue_type for these 4 NEW sections (keeps
   * every existing 'summary'/'fertigungskosten' issue_type byte-identical,
   * no persistence/UI-filter break for the pre-KAR-909 namespace).
   */
  section: 'summary' | 'fertigungskosten' | 'material' | 'sbm' | 'rmr' | 'logistics'
  messageDe: string
  messageEn: string
  /** Effective consequence after applying config — 'block' only ever occurs for R2 in 'block' mode. */
  enforcement: RuleEnforcement
  /** True when this violation marks the affected section blocked (R2 + block mode only). */
  comparisonBlocked: boolean
  /**
   * Index of the affected row within `side`'s `.steps` array (matches
   * StepMatch.altIndex/neuIndex semantics) — set for step-level violations
   * only. Together with `fieldKey` this is what compare.ts uses to gate the
   * exact FieldDiff, since `field` above is a display label, not a lookup
   * key (KAR-889 reviewer finding).
   */
  rowIndex?: number
  /** Raw QAFRowValues key of the affected field — set for step-level violations only. */
  fieldKey?: QAFFieldKey
}

export interface RuleEngineFileInput {
  summary: QafSummary
  steps: QAFRow[]
  /**
   * MATERIAL/SBM/RMR/LOGISTICS rows (KAR-909/P3.4) — same tri-state contract
   * QafFileParsed (compare.ts) already uses for these exact arrays:
   * `undefined` (default, omitted) means no parse was attempted for this
   * side, `null` means a parse was attempted but the sheet has none (or was
   * too degraded), an array means rows to evaluate. Both `undefined` and
   * `null` are treated identically here (nothing to iterate) — unlike
   * reconciliation.ts, R1-R3 field-state evaluation has no third behavior
   * to distinguish between "not attempted" and "attempted, empty". Every
   * pre-KAR-909 caller (rehydrate.ts does not reconstruct these arrays,
   * existing rule-engine.test.ts fixtures never set them) stays unaffected
   * — omitting a channel here is exactly as inert as it always was.
   */
  materialRows?: MaterialRow[] | null
  sbmRows?: SbmRow[] | null
  rmrRows?: RmrRow[] | null
  logisticsRows?: LogisticsRow[] | null
}

export interface RuleEngineInput {
  alt: RuleEngineFileInput
  neu: RuleEngineFileInput
}

// ── R1 — field-state classification ──────────────────────────────────────────
//
// Foundational rule (fehlerreport-analyse §4.2 R1): decide "korrekt" vs.
// "inkorrekt" WITHOUT relying on any color/legend concept (Kadi-v2 has none).
// Implemented as a classifier, not a standalone emitted violation — R2/R3 are
// its consequences once "notwendig für Vergleich" (mandatory) is applied.

/**
 * Classify one field's fill state. Works uniformly for text fields (pass the
 * string `value`) and numeric fields (pass the parsed `value` — number or
 * null — plus the raw cell text in `rawText` when the cell had non-blank,
 * non-numeric content, per the KAR-891 rawText provenance).
 */
export function classifyFieldState(input: {
  value: string | number | null
  rawText?: string
  mandatory: boolean
}): FieldState {
  const { value, rawText, mandatory } = input

  if (typeof value === 'number') return 'provided_valid'

  if (typeof value === 'string' && !isBlank(value)) {
    return isNotApplicableValue(value) ? 'not_applicable' : 'provided_valid'
  }

  // Field is blank in its typed shape — check the raw candidate text kept for
  // numeric fields whose cell was non-blank but didn't parse (KAR-891).
  if (rawText !== undefined && !isBlank(rawText)) {
    return isNotApplicableValue(rawText) ? 'not_applicable' : 'provided_invalid'
  }

  return mandatory ? 'empty_comparison_critical' : 'empty_allowed'
}

// ── R2/R3 — mandatory-for-comparison classification (interim minimal list) ──
//
// P0.4 interim minimal hard-coded list (backlog: "kann aber mit einer
// Minimal-Liste hartkodierter Pflichtfelder starten und später auf P1.1
// [kanonisches Feldmodell] migrieren"). Deliberately narrow: these are the
// fields the fehlerreport-analyse documents as "notwendig=Ja" AND that are
// always populated across the existing engine's test fixtures, to keep this
// additive change from generating spurious noise on data that predates the
// mandatory-field concept (e.g. `supplier`/`supplierNo` are NOT included —
// they are frequently left unset in existing fixtures/rehydrated rows and
// were not consistently "Ja" between the DE/EN fehlerreport anyway, see
// analyse §3.1 point 4 "Notwendig-Flag-Mismatch", [unklar]).
const MANDATORY_STEP_TEXT_FIELDS: ReadonlyArray<QAFFieldKey> = ['positionsnummer', 'prozessbezeichnung', 'angebotswaehrung']
const MANDATORY_STEP_NUMERIC_FIELD: QAFFieldKey = 'fkAW'
const MANDATORY_SUMMARY_FIELDS: ReadonlyArray<QafSummaryKey> = ['partNumber']

const NUMERIC_STEP_FIELDS: ReadonlyArray<QAFFieldKey> = [
  'zykluszeit',
  'teileProZyklus',
  'anzahlMA',
  'lohnkosten',
  'lohnzuschlagssaetze',
  'mss',
  'ruestkosten',
  'fek',
  'rfgk',
  'fk',
  'wechselkurs',
  'anzahlProAngebotsteil',
  'fkAW',
  'ausschuss',
  'ausschusskosten',
]

// Exportiert (Loop 3 Schritt 4): der Fertigungs-Produzent des Differenzkatalogs
// beschreibt seine Sätze mit denselben Feld-Labels. Reine Sichtbarkeitsänderung.
export const STEP_FIELD_LABELS: Partial<Record<QAFFieldKey, string>> = {
  positionsnummer: 'Positionsnummer Fertigungsschritt',
  teilebenennung: 'Teilebenennung',
  prozessbezeichnung: 'Prozessbezeichnung',
  bezeichnungAnlage: 'Bezeichnung Anlage/Maschine/Typ',
  standort: 'Standort',
  beschaffungswaehrung: 'Beschaffungswährung BW',
  zykluszeit: 'Zykluszeit [s]',
  teileProZyklus: 'Teile pro Zyklus',
  anzahlMA: 'Anzahl direkte Mitarbeiter',
  lohnkosten: 'Kalkulatorisch angesetzte direkte Lohnkosten [BW/h]',
  lohnzuschlagssaetze: 'Kalkulatorisch angesetzte Lohn-zuschlagssätze SGK [%]',
  mss: 'Maschinenstundensatz MSS [BW/h]',
  ruestkosten: 'Rüstkosten pro Stück [BW]',
  fek: 'Fertigungseinzelkosten FEK [BW]',
  rfgk: 'Restfertigungsgemeinkosten (RFGK) Stundensatz [BW/h]',
  fk: 'Fertigungskosten FK [BW]',
  angebotswaehrung: 'Angebotswährung AW',
  wechselkurs: 'Wechselkurs [AW/BW]',
  anzahlProAngebotsteil: 'Anzahl pro Angebotsteil',
  fkAW: 'Fertigungskosten FK [AW]',
  ausschuss: 'Ausschuss pro Prozessschritt [%]',
  ausschusskosten: 'Ausschusskosten Fertigung [AW]',
}

const SUMMARY_FIELD_LABELS: Partial<Record<QafSummaryKey, string>> = {
  partNumber: 'BMW Sachnummer', // allow-customer-string
  quotationDate: 'Angebotsdatum',
  supplier: 'Anbieter/Lieferant',
  partName: 'Teilebenennung',
  variant: 'Variante',
  project: 'Projekt',
  requestVersion: 'Anfragenummer/Version',
  changeIndex: 'Änderungsindex (AI)',
  supplierNo: 'BMW Lieferanten-Nr.', // allow-customer-string
  // KAR-910: 4 SUMMARY premise fields promoted from registry-only to parsed
  // (types.ts QafSummary, summary-parser.ts FIELD_DEFS) — adding them here
  // is what makes evaluateSummaryRules' dynamic Object.keys(SUMMARY_FIELD_
  // LABELS) loop below evaluate them for R3 (all 4 stay out of
  // MANDATORY_SUMMARY_FIELDS, so only the R3/hinweis path fires, never R2).
  peakVolumeYear: 'Peakvolumen Jahr [Teile / Jahr]',
  productionStartSop: 'Produktionsstart (SOP) [mm.jjjj]',
  deliverySite: 'Auslieferungsstandort (Land)',
  shiftsPerWeek: 'Schichten / Woche',
  // QVS-P4 (KAR-973): same "now parsed, add here for symmetric R3 hinweis
  // treatment" precedent as the 4 KAR-910 fields directly above — both stay
  // out of MANDATORY_SUMMARY_FIELDS (optional in the canonical registry).
  plannedCapacity: 'Plankapazität [Teile / Jahr]',
  lotSize: 'Fertigungslosgröße [Teile]',
}

/**
 * QafSummaryKey -> canonical-fields.ts id (KAR-906/P3.2 follow-up, #284
 * adversarial-review finding, confidence 88). canonical-fields.ts has no
 * ready-made `Record<QafSummaryKey, string>` map the way QAF_FIELD_KEY_TO_
 * CANONICAL exists for QAFFieldKey — the 9 SUMMARY identity fields
 * (`SUMMARY_IDENTITY_FIELDS` in that file) are declared as a plain array,
 * not indexed by this module's key names. Built here, once, so
 * evaluateSummaryRules can resolve labelEn the same way evaluateStepRules
 * already does via QAF_FIELD_KEY_TO_CANONICAL. Only `partNumber` is
 * actually mandatory today (MANDATORY_SUMMARY_FIELDS below), so only that
 * entry is live on the R2 path right now — the other 8 are filled in for
 * completeness/forward-compat with a future R3-for-SUMMARY extension
 * (module header "deferred to P1.1's canonical field model").
 */
const SUMMARY_FIELD_KEY_TO_CANONICAL: Record<QafSummaryKey, string> = {
  partNumber: 'sum_part_number',
  quotationDate: 'sum_quotation_date',
  supplier: 'sum_supplier',
  partName: 'sum_part_name',
  variant: 'sum_variant',
  project: 'sum_project',
  requestVersion: 'sum_request_version',
  changeIndex: 'sum_change_index',
  supplierNo: 'sum_supplier_number',
  // KAR-910
  peakVolumeYear: 'sum_peak_volume_year',
  productionStartSop: 'sum_production_start_sop',
  deliverySite: 'sum_delivery_site',
  shiftsPerWeek: 'sum_shifts_per_week',
  // QVS-P4
  plannedCapacity: 'sum_planned_capacity',
  lotSize: 'sum_lot_size',
}

/**
 * EN label for a MANUFACTURING (QAFFieldKey) or SUMMARY (QafSummaryKey)
 * field, sourced from the canonical field registry (KAR-906/P3.2 #284
 * adversarial-review fix, confidence 88 — R2/R3 previously interpolated the
 * SAME German fieldLabel into both messageDe and messageEn, so an EN-locale
 * reader saw sentences like `Required field "BMW Sachnummer" is not filled // allow-customer-string
 * in correctly...`). Falls back to the DE label on a registry miss (should
 * not happen for the fields this module evaluates — both maps above are
 * exhaustive over their key sets — documented fallback, not a crash).
 */
function canonicalFieldLabelEn(canonicalId: string, fallbackDe: string): string {
  return byCanonicalId(canonicalId)?.labelEn ?? fallbackDe
}

function stepFieldLabelEn(field: QAFFieldKey, fallbackDe: string): string {
  return canonicalFieldLabelEn(QAF_FIELD_KEY_TO_CANONICAL[field], fallbackDe)
}

function summaryFieldLabelEn(key: QafSummaryKey, fallbackDe: string): string {
  return canonicalFieldLabelEn(SUMMARY_FIELD_KEY_TO_CANONICAL[key], fallbackDe)
}

function stepLabelOf(row: QAFRow): string {
  const pos = String(row.positionsnummer ?? '').trim()
  const name = String(row.prozessbezeichnung ?? '').trim()
  return [pos, name].filter(Boolean).join(' ') || '(unbenannt)'
}

function buildViolation(params: {
  state: FieldState
  mandatory: boolean
  section: 'summary' | 'fertigungskosten' | 'material' | 'sbm' | 'rmr' | 'logistics'
  side: ComparisonSide
  fieldLabel: string
  /** EN counterpart of fieldLabel (KAR-906/P3.2 #284 fix), sourced from the
   * canonical registry by the caller — falls back to fieldLabel (DE) when
   * the caller doesn't have one, never crashes on a missing translation. */
  fieldLabelEn?: string
  step?: string
  rawText?: string
  config: RuleEngineConfig
  rowIndex?: number
  fieldKey?: QAFFieldKey
}): RuleViolation | null {
  const { state, mandatory, section, side, fieldLabel, step, rawText, config, rowIndex, fieldKey } = params
  const fieldLabelEn = params.fieldLabelEn ?? fieldLabel

  if (state === 'provided_valid' || state === 'not_applicable' || state === 'empty_allowed') return null

  const reasonDe =
    state === 'empty_comparison_critical'
      ? ' Feld ist leer.'
      : rawText
        ? ` Zellinhalt „${rawText}" konnte nicht als Zahl interpretiert werden.`
        : ' Zellinhalt konnte nicht interpretiert werden.'
  const reasonEn =
    state === 'empty_comparison_critical'
      ? ' Field is empty.'
      : rawText
        ? ` Cell content "${rawText}" could not be interpreted as a number.`
        : ' Cell content could not be interpreted.'

  if (mandatory) {
    const blocked = config.ruleEnforcement === 'block'
    return {
      ruleId: 'R2',
      severity: 'kritisch',
      field: fieldLabel,
      step,
      side,
      section,
      messageDe: `Pflichtfeld „${fieldLabel}" ist nicht korrekt befüllt — Vergleich/Detaillierung für dieses Feld nicht möglich.${reasonDe}`,
      messageEn: `Required field "${fieldLabelEn}" is not filled in correctly — comparison/detailing for this field is not possible.${reasonEn}`,
      enforcement: blocked ? 'block' : 'warn',
      comparisonBlocked: blocked,
      rowIndex,
      fieldKey,
    }
  }

  // R3 — always warn, never blocks (open Fachfrage 6.1, see module header).
  return {
    ruleId: 'R3',
    severity: 'hinweis',
    field: fieldLabel,
    step,
    side,
    section,
    messageDe: `Feld „${fieldLabel}" ist nicht korrekt befüllt, aber nicht vergleichsrelevant.${reasonDe}`,
    messageEn: `Field "${fieldLabelEn}" is not filled in correctly but not required for comparison.${reasonEn}`,
    enforcement: 'warn',
    comparisonBlocked: false,
    rowIndex,
    fieldKey,
  }
}

/**
 * R3 for optional SUMMARY fields (KAR-908 fix).
 *
 * evaluateSummaryRules used to `continue` past every non-mandatory field
 * (see the removed comment below), so R3 never fired for SUMMARY at all —
 * the documented gap (fehlerreport-analyse.md §5 Fall #6, "Anfragenummer/
 * Version": DE=korrekt/gefüllt, EN=inkorrekt). A naive fix (just deleting
 * the `continue`) is not enough on its own: QafSummary.SummaryField
 * (types.ts) has no rawText-equivalent for TEXT fields the way numeric STEP
 * fields have had since KAR-891 — so a filled-but-content-wrong TEXT value
 * can never reach `provided_invalid` via classifyFieldState; the only
 * distinguishable "something is off" state a SUMMARY TEXT field can hit is
 * `empty_allowed`. Per KAR-908's own noise-control mandate, generically
 * flagging every optional-and-empty field as R3 would be far too noisy —
 * the large majority of a QAF's optional SUMMARY fields are legitimately
 * never filled in on EITHER side of a comparison.
 *
 * The heuristic below resolves both problems together: the EMPTY side of an
 * optional field is only flagged when its ALT/NEU counterpart is itself
 * `provided_valid` (i.e. genuinely non-blank data exists for this exact
 * field on the other side of the SAME comparison). A field empty on BOTH
 * sides stays completely silent — "leer bleibt still" for the common case.
 * A field filled on one side and empty on the other is exactly the
 * correctness signal the real BMW Fehlerreport's paired DE/EN judgment // allow-customer-string
 * captured for case #6 (BMW compares two independent quote submissions, // allow-customer-string
 * see fehlerreport-analyse.md §1 "zwei verschiedene Dateien" — the same
 * ALT/NEU shape this engine already compares) — the closest deterministic
 * analog Kadi-v2 can derive without inventing a TEXT-field validation
 * format BMW never specified (see classifyFieldState's own doc comment). // allow-customer-string
 * `provided_invalid` is always flagged independent of the counterpart,
 * exactly like R3 already works for STEP fields — currently unreachable
 * for these 9 TEXT identity fields, but kept generic/forward-compatible for
 * a future SUMMARY field that DOES carry rawText, rather than hard-coding
 * "TEXT fields never reach provided_invalid" into this function.
 *
 * `state`/`counterpartState` reuse buildViolation's reason text by
 * deliberately passing `'empty_comparison_critical'` (not the true
 * `'empty_allowed'` classifyFieldState returned) when the asymmetry
 * condition holds — `mandatory: false` is what keeps the resulting
 * violation on the R3/hinweis path, not R2/kritisch; only the message's
 * "Feld ist leer." reason text is borrowed.
 */
function summaryOptionalViolation(params: {
  state: FieldState
  counterpartState: FieldState
  side: ComparisonSide
  fieldLabel: string
  fieldLabelEn: string
  config: RuleEngineConfig
}): RuleViolation | null {
  const { state, counterpartState, side, fieldLabel, fieldLabelEn, config } = params
  if (state === 'provided_invalid') {
    return buildViolation({ state, mandatory: false, section: 'summary', side, fieldLabel, fieldLabelEn, config })
  }
  if (state === 'empty_allowed' && counterpartState === 'provided_valid') {
    return buildViolation({
      state: 'empty_comparison_critical',
      mandatory: false,
      section: 'summary',
      side,
      fieldLabel,
      fieldLabelEn,
      config,
    })
  }
  return null
}

function evaluateSummaryRules(alt: QafSummary, neu: QafSummary, config: RuleEngineConfig): RuleViolation[] {
  const out: RuleViolation[] = []
  const keys = Object.keys(SUMMARY_FIELD_LABELS) as QafSummaryKey[]
  for (const key of keys) {
    const mandatory = (MANDATORY_SUMMARY_FIELDS as readonly string[]).includes(key)
    const fieldLabel = SUMMARY_FIELD_LABELS[key] ?? String(key)
    const fieldLabelEn = summaryFieldLabelEn(key, fieldLabel)
    const altState = classifyFieldState({ value: alt[key].value, mandatory })
    const neuState = classifyFieldState({ value: neu[key].value, mandatory })

    if (mandatory) {
      const va = buildViolation({ state: altState, mandatory, section: 'summary', side: 'ALT', fieldLabel, fieldLabelEn, config })
      if (va) out.push(va)
      const vn = buildViolation({ state: neuState, mandatory, section: 'summary', side: 'NEU', fieldLabel, fieldLabelEn, config })
      if (vn) out.push(vn)
      continue
    }

    // Optional field — R3 via the cross-side heuristic (KAR-908), see
    // summaryOptionalViolation's doc comment above.
    const va = summaryOptionalViolation({ state: altState, counterpartState: neuState, side: 'ALT', fieldLabel, fieldLabelEn, config })
    if (va) out.push(va)
    const vn = summaryOptionalViolation({ state: neuState, counterpartState: altState, side: 'NEU', fieldLabel, fieldLabelEn, config })
    if (vn) out.push(vn)
  }
  return out
}

function evaluateStepRules(steps: QAFRow[], side: ComparisonSide, config: RuleEngineConfig): RuleViolation[] {
  const out: RuleViolation[] = []
  steps.forEach((row, rowIndex) => {
    const step = stepLabelOf(row)

    for (const field of MANDATORY_STEP_TEXT_FIELDS) {
      const raw = row[field]
      const value = typeof raw === 'string' ? raw : String(raw ?? '')
      const state = classifyFieldState({ value, mandatory: true })
      const fieldLabel = STEP_FIELD_LABELS[field] ?? String(field)
      const v = buildViolation({
        state,
        mandatory: true,
        section: 'fertigungskosten',
        side,
        fieldLabel,
        fieldLabelEn: stepFieldLabelEn(field, fieldLabel),
        step,
        config,
        rowIndex,
        fieldKey: field,
      })
      if (v) out.push(v)
    }

    for (const field of NUMERIC_STEP_FIELDS) {
      const mandatory = field === MANDATORY_STEP_NUMERIC_FIELD
      const value = row[field] as number | null
      const rawText = row.rawText?.[field]
      const state = classifyFieldState({ value, rawText, mandatory })
      const fieldLabel = STEP_FIELD_LABELS[field] ?? String(field)
      const v = buildViolation({
        state,
        mandatory,
        section: 'fertigungskosten',
        side,
        fieldLabel,
        fieldLabelEn: stepFieldLabelEn(field, fieldLabel),
        step,
        rawText,
        config,
        rowIndex,
        fieldKey: field,
      })
      if (v) out.push(v)
    }
  })
  return out
}

// ── R4 — structural section difference (not an error) ───────────────────────
//
// Scoped to what Kadi-v2 currently parses: whether the whole Fertigungskosten
// section is present at all in one file but not the other (fehlerreport
// analog: the EN-only "LC" section, §4.2 R4). A future MATERIAL/SBM/LC parser
// (P1.6/P1.7) would extend this the same way once those sections exist.
function evaluateStructuralRules(altSteps: QAFRow[], neuSteps: QAFRow[]): RuleViolation[] {
  if (altSteps.length > 0 === neuSteps.length > 0) return []
  const missingSide: ComparisonSide = altSteps.length === 0 ? 'ALT' : 'NEU'
  const presentSide: ComparisonSide = missingSide === 'ALT' ? 'NEU' : 'ALT'
  return [
    {
      ruleId: 'R4',
      severity: 'hinweis',
      side: missingSide,
      section: 'fertigungskosten',
      messageDe: `Abschnitt „Fertigungskosten" existiert nur in ${presentSide}, nicht in ${missingSide} — kein automatischer Fehlerbefund, sondern struktureller Unterschied.`,
      messageEn: `Section "Fertigungskosten" exists only in ${presentSide}, not in ${missingSide} — not an automatic error, but a structural difference.`,
      enforcement: 'warn',
      comparisonBlocked: false,
    },
  ]
}

// ── R6 — label robustness against typos/duplicates ───────────────────────────
//
// fehlerreport-analyse §4.2 R6 + §3.3: documented BMW-source typos // allow-customer-string
// ("Teilebennung", "Produdktionsstart", "Verrechungsform" — 1:1 aus Quelle
// übernommen) and the duplicate-label case ("Mengeneinheit" 2x in MATERIAL).
// Kadi-v2's own Fertigungskosten header dictionary (lib/qaf-parser.ts
// HEADER_TO_KEY) is already exact-matched against a fixed, typo-free set and
// doesn't need this today; these become load-bearing once a fuzzy header
// matcher (P1.2) or a MATERIAL-sheet parser with genuine duplicate labels
// (P1.6) lands. Exported now so that work can reuse them instead of
// re-deriving the same corrections.
const KNOWN_LABEL_TYPOS: Record<string, string> = {
  teilebennung: 'teilebenennung',
  produdktionsstart: 'produktionsstart',
  verrechungsform: 'verrechnungsform',
}

/** Canonicalize a free-text QAF field label: trim, lowercase, fix known typos. */
export function canonicalizeFieldLabel(label: string): string {
  const key = label.trim().toLowerCase()
  return KNOWN_LABEL_TYPOS[key] ?? key
}

/**
 * Disambiguate duplicate labels within one section by (section, position),
 * since label-string matching alone is ambiguous when the same label
 * (typo-corrected) appears more than once (e.g. "Mengeneinheit" 2x in
 * MATERIAL). First occurrence keeps the bare key; later ones get `#n`.
 */
export function disambiguateLabels(section: string, labels: readonly string[]): string[] {
  const seen = new Map<string, number>()
  return labels.map((label) => {
    const canonical = canonicalizeFieldLabel(label)
    const count = (seen.get(canonical) ?? 0) + 1
    seen.set(canonical, count)
    return count === 1 ? `${section}:${canonical}` : `${section}:${canonical}#${count}`
  })
}

// ── MATERIAL/SBM/RMR/LOGISTICS — detail-row R2/R3 (KAR-909/P3.4) ────────────
//
// Architecture-gap fix ("Zusatzfund", cross-language-fixtures.test.ts):
// material-parser.ts/sbm-parser.ts/rmr-parser.ts/logistics-parser.ts (P1.6/
// P1.7/P2.3/P2.4) have existed for a while and already produce the exact
// same sourceCells/normalized/rawText row shape QAFRow has carried since
// KAR-886/891 — classifyFieldState already consumes that shape generically
// (proven by the 6 "green" MATERIAL/SBM cases in cross-language-fixtures.
// test.ts). But RuleEngineInput never had a channel for these rows, so R2/R3
// never RAN for them — classifyFieldState calls in tests only ever
// demonstrated the classifier in isolation, never through evaluateRuleEngine.
//
// Registry-derivation choice (task instruction: no new hardcoded mandatory-
// field lists): mandatory/optional per field comes from canonical-fields.ts
// via byCanonicalId(...).requirement — the SAME registry MATERIAL_FIELD_KEY_
// TO_CANONICAL/SBM_FIELD_KEY_TO_CANONICAL/RMR_FIELD_KEY_TO_CANONICAL/LOG_
// FIELD_KEY_TO_CANONICAL backward-mapping tables already establish for
// labelDe/labelEn lookups elsewhere in this codebase (reconciliation.ts,
// business-rules.ts). 'mandatory' -> R2; 'conditional' and 'optional' both
// fall to the R3 path (the registry does not yet encode WHEN a 'conditional'
// field's precondition holds — see canonical-fields.ts module header,
// "pending P1.6" staleness note for MATERIAL specifically — treating
// 'conditional' as always-required would be a guess this module refuses to
// make, exactly the same discipline rule-engine.ts's R3 module-header
// comment already applies to R3's own Fachfrage 6.1).
//
// Sync vs. async import (task instruction: "spiegelt die bestehende
// Architektur"): rule-engine.ts already imports canonical-model.ts (and
// therefore the full canonical-fields.ts CANONICAL_FIELDS constant it
// re-exports) STATICALLY at the top of this file (`byCanonicalId`, KAR-906/
// P3.2, predates this PR) — unlike qaf-parser.ts's loadManufacturingRegistry
// / material-parser.ts's loadMaterialRegistry, which dynamic-import the
// registry specifically to keep it out of 'use client' bundles that import
// those PARSER modules directly (material-parser.ts module header, "Client-
// bundle discipline"). rule-engine.ts/compare.ts are only ever reached
// server-side (app/qaf-differences/actions.ts, a server action — verified:
// no 'use client' component imports compareQafPair/evaluateRuleEngine), so
// the bundle-size constraint that motivates the dynamic-import pattern
// elsewhere does not apply here. This PR extends the SAME already-static
// import (adds byModule-equivalent lookups via the existing byCanonicalId)
// rather than introducing a second, inconsistent loading pattern into one
// file.
//
// Rausch-Kontrolle (task instruction, "wie oben" referring to KAR-908's
// SUMMARY reasoning): unlike SUMMARY identity fields, MATERIAL/SBM/RMR/
// LOGISTICS rows mix TEXT and NUMERIC columns exactly like QAFRow — numeric
// columns already carry a real rawText provenance channel (KAR-886/891
// pattern, identical to STEP fields), so `provided_invalid` is genuinely
// reachable here (unlike bare SUMMARY TEXT fields) and buildViolation's
// existing state-gate (`empty_allowed` -> null, never emitted) already does
// exactly the desired "leer bleibt still" conservatism with ZERO extra
// cross-side logic needed — this is the STEP-field pattern (evaluateStepRules
// above), just parameterized over 4 additional field-key spaces instead of a
// 5th hand-written per-module dictionary.
function evaluateDetailRowRules<K extends string>(params: {
  rows: ReadonlyArray<Record<K, string | number | null> & { rawText: Partial<Record<K, string>> }> | null | undefined
  side: ComparisonSide
  section: 'material' | 'sbm' | 'rmr' | 'logistics'
  fieldKeyToCanonical: Record<K, string>
  config: RuleEngineConfig
}): RuleViolation[] {
  const { rows, side, section, fieldKeyToCanonical, config } = params
  const out: RuleViolation[] = []
  if (!rows) return out
  const fieldKeys = Object.keys(fieldKeyToCanonical) as K[]

  rows.forEach((row, rowIndex) => {
    const positionNumber = String((row as Record<string, unknown>).positionNumber ?? '').trim()
    const step = positionNumber ? `Pos. ${positionNumber}` : '(unbenannt)'

    for (const key of fieldKeys) {
      const canonicalId = fieldKeyToCanonical[key]
      const canonical = byCanonicalId(canonicalId)
      const mandatory = canonical?.requirement === 'mandatory'
      const fieldLabel = canonical?.labelDe ?? String(key)
      const fieldLabelEn = canonical?.labelEn ?? fieldLabel
      const value = row[key]
      const rawText = row.rawText[key]
      const state = classifyFieldState({ value, rawText, mandatory })
      const v = buildViolation({
        state,
        mandatory,
        section,
        side,
        fieldLabel,
        fieldLabelEn,
        step,
        rawText,
        config,
        rowIndex,
      })
      if (v) out.push(v)
    }
  })
  return out
}

function evaluateMaterialRules(rows: MaterialRow[] | null | undefined, side: ComparisonSide, config: RuleEngineConfig): RuleViolation[] {
  return evaluateDetailRowRules<MaterialFieldKey>({
    rows,
    side,
    section: 'material',
    fieldKeyToCanonical: MATERIAL_FIELD_KEY_TO_CANONICAL,
    config,
  })
}

function evaluateSbmRules(rows: SbmRow[] | null | undefined, side: ComparisonSide, config: RuleEngineConfig): RuleViolation[] {
  return evaluateDetailRowRules<SbmFieldKey>({
    rows,
    side,
    section: 'sbm',
    fieldKeyToCanonical: SBM_FIELD_KEY_TO_CANONICAL,
    config,
  })
}

function evaluateRmrRules(rows: RmrRow[] | null | undefined, side: ComparisonSide, config: RuleEngineConfig): RuleViolation[] {
  return evaluateDetailRowRules<RmrFieldKey>({
    rows,
    side,
    section: 'rmr',
    fieldKeyToCanonical: RMR_FIELD_KEY_TO_CANONICAL,
    config,
  })
}

function evaluateLogisticsRules(
  rows: LogisticsRow[] | null | undefined,
  side: ComparisonSide,
  config: RuleEngineConfig,
): RuleViolation[] {
  return evaluateDetailRowRules<LogisticsFieldKey>({
    rows,
    side,
    section: 'logistics',
    fieldKeyToCanonical: LOG_FIELD_KEY_TO_CANONICAL,
    config,
  })
}

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

export function evaluateRuleEngine(
  input: RuleEngineInput,
  config: RuleEngineConfig = RULE_ENGINE_CONFIG,
): RuleViolation[] {
  return [
    ...evaluateSummaryRules(input.alt.summary, input.neu.summary, config),
    ...evaluateStepRules(input.alt.steps, 'ALT', config),
    ...evaluateStepRules(input.neu.steps, 'NEU', config),
    ...evaluateStructuralRules(input.alt.steps, input.neu.steps),
    // KAR-909/P3.4 — detail-sheet channels, see evaluateDetailRowRules above.
    ...evaluateMaterialRules(input.alt.materialRows, 'ALT', config),
    ...evaluateMaterialRules(input.neu.materialRows, 'NEU', config),
    ...evaluateSbmRules(input.alt.sbmRows, 'ALT', config),
    ...evaluateSbmRules(input.neu.sbmRows, 'NEU', config),
    ...evaluateRmrRules(input.alt.rmrRows, 'ALT', config),
    ...evaluateRmrRules(input.neu.rmrRows, 'NEU', config),
    ...evaluateLogisticsRules(input.alt.logisticsRows, 'ALT', config),
    ...evaluateLogisticsRules(input.neu.logisticsRows, 'NEU', config),
  ]
}

/**
 * Lookup consumed by compare.ts to gate the exact FieldDiff a blocking R2
 * violation refers to (KAR-889 reviewer finding: block mode must actually
 * suppress the affected field/section's delta, not just tag the persisted
 * issue_type). Only R2 violations with `comparisonBlocked === true` (i.e.
 * config.ruleEnforcement === 'block') contribute — R3 never blocks, and
 * warn-mode R2 violations stay informational only, exactly as before.
 */
export function blockedStepFields(
  violations: readonly RuleViolation[],
): Record<ComparisonSide, Map<number, Set<QAFFieldKey>>> {
  const out: Record<ComparisonSide, Map<number, Set<QAFFieldKey>>> = { ALT: new Map(), NEU: new Map() }
  for (const v of violations) {
    if (v.ruleId !== 'R2' || !v.comparisonBlocked) continue
    if (v.side === undefined || v.rowIndex === undefined || v.fieldKey === undefined) continue
    const bySide = out[v.side]
    const set = bySide.get(v.rowIndex) ?? new Set<QAFFieldKey>()
    set.add(v.fieldKey)
    bySide.set(v.rowIndex, set)
  }
  return out
}

// ── Persistence bridge ───────────────────────────────────────────────────────
//
// qaf_plausibility_issue is generic enough already (issue_type TEXT,
// severity/step_label/field/explanation — no color/rule-specific columns) to
// carry rule-engine violations without a migration: the rule identity and
// block/warn consequence are namespaced into `issue_type` instead of a new
// column (P0.4 deliberate decision — migrations are operator-gated, see
// CLAUDE.md). `explanation` carries the German message, matching every
// existing PlausibilityIssue; `explanationEn` (KAR-906/P3.2) now carries the
// violation's `messageEn` too — persistence-mapper.ts's encodeBilingual folds
// both into the single TEXT column at insert time (no schema change), and
// bilingual-message.ts's decodeBilingual unpacks it again for the UI/export.
export function ruleViolationToPlausibilityIssue(v: RuleViolation): PlausibilityIssue {
  const suffix =
    v.ruleId === 'R2'
      ? v.comparisonBlocked
        ? 'mandatory_blocked'
        : 'mandatory_warn'
      : v.ruleId === 'R3'
        ? 'optional_invalid'
        : v.ruleId === 'R4'
          ? 'structural_diff'
          : 'other'
  // KAR-909/P3.4: namespace the 4 new detail-sheet sections into issue_type
  // (`rule_r2_material_mandatory_warn`, `rule_r3_sbm_optional_invalid`, ...)
  // so persisted/exported issues stay distinguishable by module — but ONLY
  // for these new sections. 'summary'/'fertigungskosten' keep the exact
  // pre-KAR-909 issue_type string (no sectionTag), so every existing
  // persisted comparison and every existing test assertion on
  // `rule_r2_mandatory_warn`/`rule_r3_optional_invalid`/etc. stays
  // byte-identical.
  const sectionTag =
    v.section === 'material' || v.section === 'sbm' || v.section === 'rmr' || v.section === 'logistics'
      ? `${v.section}_`
      : ''
  const step = v.step && v.side ? `${v.side} · ${v.step}` : (v.step ?? (v.side ? v.side : undefined))
  return {
    type: `rule_${v.ruleId.toLowerCase()}_${sectionTag}${suffix}`,
    severity: v.severity,
    field: v.field,
    step,
    explanation: v.messageDe,
    explanationEn: v.messageEn,
  }
}
