// QAF comparison orchestrator (KAR-799, spec A2/B — connective tissue).
//
// Assembles the engine for ONE part number: match steps → diff each pair →
// derive structure changes → plausibility → deterministic root-cause. Different
// part numbers must never be compared (the caller groups by part number; the
// plausibility critical is a guard). Net-new. Pure.

import type { QAFRow, QAFFieldKey } from '@/lib/qaf-parser'
import type { FieldDiff, QafSummary, StepMatch } from './types'
import type { QafFileRef } from './baseline'
import { matchStepsWithPins, type ManualPin } from './matcher'
import { diffSteps } from './differ'
import { diffSummaryMetrics, type SummaryMetricDiff, type SummaryMetricsParse } from './summary-metrics'
import {
  buildMaterialMappings,
  classifyMaterialEffects,
  mappablePositionsFromMaterialRows,
  type MaterialEffectResult,
} from './material-mapping'
import {
  checkPlausibility,
  checkFilenamePartNumber,
  checkMakeOrBuyIndication,
  manufacturingParseDegradationToPlausibilityIssue,
  manufacturingIgnoredCandidatesToPlausibilityIssue,
  type ManufacturingParseDegradation,
  type PlausibilityIssue,
} from './plausibility'
import { checkReconciliation, type ReconciliationConfig } from './reconciliation'
import {
  evaluateBusinessRules,
  businessRuleResultToPlausibilityIssue,
  type BusinessRuleCheckId,
  type BusinessRuleResult,
  type BusinessRulesConfig,
} from './business-rules'
import type { MaterialRow } from './material-parser'
import { materialParseMetaToPlausibilityIssue } from './material-parser'
import type { SbmRow } from './sbm-parser'
import { sbmParseMetaToPlausibilityIssue } from './sbm-parser'
import type { IgnoredCandidateSheetsMeta } from './candidate-sheet-plausibility'
import type { RmrRow, RmrParseMeta } from './rmr-parser'
import {
  evaluateRmrValidation,
  rmrValidationResultToPlausibilityIssue,
  rmrParseMetaToPlausibilityIssue,
  type RmrValidationConfig,
} from './rmr-parser'
import type { LogisticsRow } from './logistics-parser'
import {
  evaluateLogisticsValidation,
  logisticsValidationResultToPlausibilityIssue,
  type LogisticsValidationConfig,
} from './logistics-parser'
import type { LccnSummaryValues } from './lccn-parser'
import {
  evaluateLccnValidation,
  lccnValidationResultToPlausibilityIssue,
  type LccnValidationConfig,
} from './lccn-parser'
import type { Co2eMaterialRow } from './co2e-parser'
import {
  evaluateCo2eValidation,
  co2eValidationResultToPlausibilityIssue,
  type Co2eValidationConfig,
} from './co2e-parser'
import {
  evaluateRuleEngine,
  ruleViolationToPlausibilityIssue,
  blockedStepFields,
  type RuleEngineConfig,
  type RuleEnforcement,
} from './rule-engine'
import { computeRootCause, type RootCauseResult, type StepDiffSummary } from './root-cause'
import { DEFAULT_ENGINE_CONFIG, type EngineConfig } from './engine-config'
import { formulaFindingToPlausibilityIssue } from './formula-engine'
import { workbookSafetyToPlausibilityIssues, type WorkbookSafetyResult } from './workbook-safety'
import { multiQafDetectionToPlausibilityIssue, type MultiQafDetectionResult } from './qaf-type-detector'

export interface QafFileParsed {
  ref: QafFileRef
  summary: QafSummary
  steps: QAFRow[]
  /** Canonical summary money metrics (KAR-840); absent for pre-foundation callers. */
  summaryMetrics?: SummaryMetricsParse
  /** Fertigungskosten header-mapping diagnostics (KAR-893/P1.2) — set by the
   * ingest path from parseQAFTemplate's QAFParseResult meta. Absent for
   * rehydrated/historical comparisons (rehydrate.ts never reconstructs this,
   * same optional-and-tolerant pattern as summaryMetrics above) and for
   * pre-KAR-893 callers/tests. */
  manufacturingParseMeta?: ManufacturingParseDegradation
  /**
   * MATERIAL rows (KAR-897/P1.6), tri-state exactly like
   * reconciliation.ts's ReconciliationInput.materialRows: undefined (default,
   * omitted) means no MATERIAL parse was attempted for this side — the
   * material_detail_sum reconciliation check is skipped entirely, keeping
   * every pre-P1.6/rehydrated caller unaffected. null means a MATERIAL parse
   * was attempted but the file has none (or it was too degraded to trust).
   * Cross-file matching/diffing of these rows against the other side is
   * explicitly out of scope for this PR (see material-parser.ts header) —
   * compareQafPair only threads them through to reconciliation, it does not
   * produce MATERIAL stepComparisons.
   */
  materialRows?: MaterialRow[] | null
  /**
   * MATERIAL parser-candidate diagnostics (KAR-927/P0.2, "Kandidaten-
   * Sichtbarkeit an .find()-Kollaps-Stellen") — set by the ingest path from
   * parseMaterialSheet's MaterialParseResult meta, same optional/tolerant
   * three-hop wiring (ingest -> g60_meta.material.parseMeta -> rehydrate) as
   * `manufacturingParseMeta`/`rmrParseMeta` above. `undefined` for the G60
   * ingest path, for rehydrated/historical comparisons that predate this
   * fix, and for the common case where at most one MATERIAL-named sheet
   * existed — never fabricated. Typed as the tolerant `IgnoredCandidateSheetsMeta`
   * (not `Pick<MaterialParseMeta, 'ignoredCandidateSheets'>`) so a
   * rehydrated pre-KAR-927-redesign `string[]` value type-checks here too
   * (candidate-sheet-plausibility.ts, "undefined-/altformat-tolerant").
   */
  materialParseMeta?: IgnoredCandidateSheetsMeta
  /**
   * SBM-DEVICES-FWZ rows (KAR-898/P1.7), tri-state exactly like
   * reconciliation.ts's ReconciliationInput.sbmRows / this interface's
   * materialRows above: undefined (default, omitted) means no SBM parse was
   * attempted for this side — the sbm_detail_sum reconciliation check is
   * skipped entirely, keeping every pre-P1.7/rehydrated caller unaffected.
   * null means an SBM parse was attempted but the file has none (or it was
   * too degraded to trust). Cross-file matching/diffing of these rows
   * against the other side, and automatic Positionsnummer matching against
   * MANUFACTURING COSTS rows, are both explicitly out of scope for this PR
   * (see sbm-parser.ts header) — compareQafPair only threads sbmRows through
   * to reconciliation, it does not produce SBM stepComparisons.
   */
  sbmRows?: SbmRow[] | null
  /**
   * SBM parser-candidate diagnostics (KAR-927/P0.2), same tri-hop wiring and
   * absence contract as `materialParseMeta` above — set from
   * parseSbmSheet's SbmParseResult meta. Typed as the tolerant
   * `IgnoredCandidateSheetsMeta`, same reason as `materialParseMeta` above.
   */
  sbmParseMeta?: IgnoredCandidateSheetsMeta
  /**
   * RAW MATERIAL RISKS rows (KAR-902/P2.3), tri-state exactly like
   * materialRows/sbmRows above: undefined (default, omitted) means no RMR
   * parse was attempted for this side — the rmr_raw_material_surcharge
   * validation is skipped entirely, keeping every pre-P2.3/rehydrated caller
   * unaffected. null means an RMR parse was attempted but the file has none
   * (or it was too degraded to trust). Cross-file matching/diffing of these
   * rows, and cross-sheet reconciliation against MATERIAL, are both
   * explicitly out of scope for this PR (see rmr-parser.ts header) —
   * compareQafPair only threads rmrRows through to the RMR validation, it
   * does not produce RMR stepComparisons.
   */
  rmrRows?: RmrRow[] | null
  /**
   * RMR parse-level diagnostics (KAR-902 follow-up, "Zwei-Block-Layout"
   * adversarial-review fix) — carries `possibleUnparsedBlockRow` (see
   * rmr-parser.ts's RmrParseMeta doc comment). Absent for rehydrated/pre-fix
   * callers (rehydrate.ts does not reconstruct it, same optional-and-
   * tolerant pattern as `manufacturingParseMeta` above) and for callers that
   * never attempted an RMR parse. `undefined` here means "no diagnostic to
   * report", NOT "definitely nothing was missed" — the Sicherheitsnetz issue
   * is only ever emitted when this is actually threaded through from a live
   * or rehydrated parse.
   */
  rmrParseMeta?: RmrParseMeta
  /**
   * LOGISTICS&CUSTOM rows (KAR-903/P2.4), tri-state exactly like
   * materialRows/sbmRows/rmrRows above: undefined (default, omitted) means no
   * LOGISTICS parse was attempted for this side — BOTH the
   * log_calc_cost_per_delivery_site formula check and the log_incoterm_domain
   * check are skipped entirely, AND the logistics_transport_detail_sum/
   * logistics_customs_detail_sum reconciliation checks are skipped entirely,
   * keeping every pre-P2.4/rehydrated caller unaffected. null means a
   * LOGISTICS parse was attempted but the file has none (or it was too
   * degraded to trust). Cross-file matching/diffing of these rows against the
   * other side is explicitly out of scope for this PR (see logistics-parser.ts
   * header) — compareQafPair only threads logisticsRows through to the
   * LOGISTICS validation + reconciliation, it does not produce LOGISTICS
   * stepComparisons.
   */
  logisticsRows?: LogisticsRow[] | null
  /**
   * LC-CN Zusammenfassungs-Record (KAR-904/P2.5), tri-state exactly like
   * materialRows/sbmRows/rmrRows/logisticsRows above: undefined (default,
   * omitted) means no LC-CN parse was attempted for this side — all 4 LC-CN
   * validation checks (SUMME HERSTELLKOSTEN, beide LC-Rate-Lesarten,
   * Komplement) are skipped entirely, keeping every pre-P2.5/rehydrated
   * caller unaffected. null means an LC-CN parse was attempted but the file
   * has none (or it was too degraded to trust). Unlike the row-based
   * modules, this is a SINGLE record (one Zusammenfassungsblatt per file, see
   * lccn-parser.ts header "Struktur-Entscheidung") — compareQafPair only
   * threads lccnValues through to the LC-CN validation, it does not produce
   * LC-CN stepComparisons.
   */
  lccnValues?: LccnSummaryValues | null
  /**
   * CO2e-Material rows (KAR-904/P2.5), tri-state exactly like logisticsRows
   * above: undefined (default, omitted) means no CO2e parse was attempted for
   * this side — the co2e_material_emissions validation is skipped entirely.
   * null means a CO2e parse was attempted but no usable CO2e-Material row
   * block was found (see co2e-parser.ts header "Struktur-Entscheidung" for
   * why this is a row block, distinct from the separate CO2e-Zusammenfassung
   * panel this interface does not currently thread through at all — the
   * panel's 4 fields carry no documented, task-scoped formula to validate,
   * see that module's header). Cross-file matching/diffing of these rows
   * against the other side is explicitly out of scope for this PR.
   */
  co2eMaterialRows?: Co2eMaterialRow[] | null
  /**
   * Untrusted-Excel-Hardening pre-parse signals (KAR-914/P4.4) — captured
   * ONCE at ingest by runPreParseWorkbookSafetyCheck (workbook-safety.ts),
   * BEFORE the full ExcelJS parse. Blocking checks (zip-ratio,
   * sheet-dimension cap) already ran at ingest time and reject the file
   * outright before a QafFileParsed is ever built; this field only carries
   * the two NON-blocking advisory signals (macro presence, external links).
   *
   * Persisted into qaf_file.g60_meta.workbookSafety (actions.ts) and
   * REHYDRATED from there on every recompare (adversarial-review F3 fix,
   * 10.07.2026) — same fix KAR-899's follow-up already applied to
   * `manufacturingParseMeta` above: without persisting+rehydrating this, a
   * refreshPlausibility:true recompare (replaceComparisonFile) would DELETE
   * a persisted security_macro_present/security_external_links finding and
   * could never regenerate it, permanently losing a still-true finding on
   * the file's very first replace even though the file's macro/link content
   * never changed. Absent (undefined) only for historical files that
   * predate this fix (no key in g60_meta yet) — never fabricated.
   */
  workbookSafety?: WorkbookSafetyResult | null
  /**
   * Multi-QAF type detection (KAR-926 / Multi-QAF-Programm P0.1) — captured
   * ONCE at ingest by detectMultiQaf (qaf-type-detector.ts), same "computed
   * live, persisted into qaf_file.g60_meta, rehydrated on every recompare"
   * discipline as `workbookSafety` above. `undefined` (default, omitted)
   * means the detector never ran for this side — `multiQafDetection.enabled`
   * was false at ingest time for this comparison (since KAR-925, 13.07.2026,
   * `true` is the DEFAULT_ENGINE_CONFIG value — see engine-config.ts's 1.4.0
   * note — but a pre-flip comparison's first file replace still runs with it
   * false, see resolveReplaceMultiQafDetectionConfig/rehydrate.ts), or this
   * is a historical file/comparison that predates KAR-926. A present,
   * non-'standard_qaf' classification is what can produce a plausibility
   * issue below (`standard_qaf` is itself a real, present result — the
   * "detector ran and saw nothing unusual" case — not `null`; only "detector
   * never ran" is `undefined`). Since KAR-935/P2.1, confirmed/probable no
   * longer blocks the upload at ingest
   * time — it is ingested as a Multi-QAF container instead (container
   * assembly first, pre-KAR-935 reject only as a fail-closed fallback on
   * assembly failure, see actions.ts's ingestQafUpload) — so in practice
   * only 'ambiguous' ever reaches this field with real data on a
   * summary/g60-kind file (task instruction: "NICHT blockieren, aber Befund
   * ... sichtbar machen").
   */
  multiQafDetection?: MultiQafDetectionResult | null
}

export interface StepComparison {
  altIndex: number | null
  neuIndex: number | null
  stepLabel: string
  match: StepMatch
  fieldDiffs: FieldDiff[]
  /** True only for confirmed matches that feed the auto-diff (spec B3 stage 4). */
  includedInDelta: boolean
}

export interface QafComparisonResult {
  partNumber: string | null
  altRef: QafFileRef
  neuRef: QafFileRef
  stepComparisons: StepComparison[]
  /** Summary-level metric deltas (KAR-840); empty when metrics were not parsed. */
  summaryDiffs: SummaryMetricDiff[]
  /**
   * Material-Zuordnungen samt Wirkungsarten und Positionsebenen-Reconciliation
   * (Block 2, Erstverdrahtung). undefined = mindestens eine Seite ohne
   * Material-Parse (tri-state, wie materialRows im Input); ein geparstes Blatt
   * mit null Positionen liefert dagegen ein Ergebnis mit leeren Zuordnungen —
   * „nicht geparst" bleibt von „nichts gefunden" unterscheidbar.
   */
  materialEffects?: MaterialEffectResult
  structureChanges: { new: string[]; removed: string[]; possible: string[] }
  plausibility: PlausibilityIssue[]
  rootCause: RootCauseResult
  matchReviewCount: number
  /**
   * The rule-engine enforcement mode actually used to produce this result
   * (KAR-889 reviewer finding, reproducibility). persistence-mapper.ts folds
   * this into the persisted engine_version so a later flip of
   * RULE_ENGINE_CONFIG's default doesn't make historical comparisons
   * ambiguous about which mode created their rule-engine issues/blocks.
   * Kept alongside `engineConfig` below (not derived from it inline at every
   * call site) for backward compatibility — this field predates KAR-896 and
   * existing readers (persistence-mapper.ts, tests) still read it directly.
   * Always equal to `engineConfig.ruleEngine.ruleEnforcement`.
   */
  ruleEnforcement: RuleEnforcement
  /**
   * The full effective engine configuration actually used to produce this
   * result (P1.5/KAR-896 — generalizes the ruleEnforcement field above to
   * every config section: reconciliation, g60StructureGuard, differBands,
   * matching). persistence-mapper.ts folds a delta-against-default of this
   * into the persisted engine_version so a later change to
   * DEFAULT_ENGINE_CONFIG never retroactively reinterprets an already-run
   * comparison — same reproducibility reasoning as ruleEnforcement.
   */
  engineConfig: EngineConfig
}

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

/**
 * R2 block mode (KAR-889 reviewer finding): replace the delta with a
 * blocked state instead of showing a possibly-wrong computed number.
 * altValue/neuValue are kept as-is (mirrors the nicht_anwendbar pattern,
 * KAR-891) — only the derived delta is suppressed, not the still-known side.
 */
function blockFieldDiff(fd: FieldDiff): FieldDiff {
  return { ...fd, deltaAbsolute: null, deltaPercent: null, deltaPercentagePoints: null, status: 'blockiert' }
}

export function compareQafPair(
  alt: QafFileParsed,
  neu: QafFileParsed,
  options?: {
    pins?: ManualPin[]
    ruleEngineConfig?: RuleEngineConfig
    reconciliationConfig?: ReconciliationConfig
    /** Business-Rule-Nachrechnungs-Toleranzen (business-rules.ts, KAR-901/P2.2). */
    businessRulesConfig?: BusinessRulesConfig
    /** Rohstoffzuschlag-Validierungs-Toleranzen (rmr-parser.ts, KAR-902/P2.3). */
    rmrValidationConfig?: RmrValidationConfig
    /** LOGISTICS&CUSTOM-Validierungs-Toleranzen (logistics-parser.ts, KAR-903/P2.4). */
    logisticsValidationConfig?: LogisticsValidationConfig
    /** LC-CN-Validierungs-Toleranzen (lccn-parser.ts, KAR-904/P2.5). */
    lccnValidationConfig?: LccnValidationConfig
    /** CO2e-Validierungs-Toleranzen (co2e-parser.ts, KAR-904/P2.5). */
    co2eValidationConfig?: Co2eValidationConfig
    /**
     * Full engine config (P1.5/KAR-896). Sections not overridden here fall
     * back to DEFAULT_ENGINE_CONFIG. The discrete `ruleEngineConfig`/
     * `reconciliationConfig` options above take precedence over the
     * corresponding engineConfig section when BOTH are passed (abwärts-
     * kompatibel — existing call sites that only know the old, narrower
     * options keep working unchanged; see rehydrate.ts/actions.ts).
     */
    engineConfig?: EngineConfig
  },
): QafComparisonResult {
  const engineConfig = options?.engineConfig ?? DEFAULT_ENGINE_CONFIG
  const matchConfig = engineConfig.matching
  const differBandsConfig = engineConfig.differBands
  const matches = matchStepsWithPins(alt.steps, neu.steps, options?.pins ?? [], matchConfig)

  // Fehlerreport-Regel-Engine R1-R6 (KAR-889/P0.4) runs alongside the
  // existing plausibility checks and feeds the same qaf_plausibility_issue
  // persistence path — no schema change, rule violations are namespaced via
  // issue_type (see rule-engine.ts persistence-bridge comment). Computed
  // BEFORE the step loop (not after, as in the original wiring) so a block-
  // mode R2 violation can actually gate the specific FieldDiff it refers to
  // — reviewer finding: tagging issue_type alone left the (possibly wrong)
  // computed delta visible in the A1-Delta-Tabelle regardless of mode.
  // Config is taken explicitly (default RULE_ENGINE_CONFIG, or the
  // engineConfig.ruleEngine section, KAR-896) instead of an implicit
  // module-global read, so the mode actually used is traceable on the
  // result (see ruleEnforcement below / reproducibility finding).
  const ruleEngineConfig = options?.ruleEngineConfig ?? engineConfig.ruleEngine
  const ruleViolations = evaluateRuleEngine(
    {
      // KAR-909/P3.4: material/sbm/rmr/logisticsRows are the SAME tri-state
      // arrays reconciliationIssues below already reads off alt/neu — no new
      // parse, just threaded into the rule engine's R2/R3 evaluation too.
      alt: {
        summary: alt.summary,
        steps: alt.steps,
        materialRows: alt.materialRows,
        sbmRows: alt.sbmRows,
        rmrRows: alt.rmrRows,
        logisticsRows: alt.logisticsRows,
      },
      neu: {
        summary: neu.summary,
        steps: neu.steps,
        materialRows: neu.materialRows,
        sbmRows: neu.sbmRows,
        rmrRows: neu.rmrRows,
        logisticsRows: neu.logisticsRows,
      },
    },
    ruleEngineConfig,
  )
  const blocked = blockedStepFields(ruleViolations)

  const stepComparisons: StepComparison[] = []
  const newLabels: string[] = []
  const removedLabels: string[] = []
  const possibleLabels: string[] = []

  // Formel-Vergleich (KAR-900/P2.1) — gated by engineConfig.formulaEngine.enabled
  // (default true). diffSteps() itself already no-ops every formula-derived
  // override/annotation when the flag is false; this array only collects the
  // three "reportable" kinds' PlausibilityIssue projection (see
  // formula-engine.ts formulaFindingToPlausibilityIssue) for the shared
  // qaf_plausibility_issue channel every other check in this module uses.
  const formulaEngineIssues: PlausibilityIssue[] = []

  for (const m of matches) {
    const altRow = m.altIndex !== null ? alt.steps[m.altIndex] : undefined
    const neuRow = m.neuIndex !== null ? neu.steps[m.neuIndex] : undefined
    const label = labelOf(neuRow ?? altRow)
    const blockedAlt = m.altIndex !== null ? blocked.ALT.get(m.altIndex) : undefined
    const blockedNeu = m.neuIndex !== null ? blocked.NEU.get(m.neuIndex) : undefined
    const fieldDiffs = diffSteps(
      altRow ?? null,
      neuRow ?? null,
      differBandsConfig,
      engineConfig.formulaEngine.enabled,
    ).map((fd) => {
      const key = fd.field as QAFFieldKey
      return blockedAlt?.has(key) || blockedNeu?.has(key) ? blockFieldDiff(fd) : fd
    })
    for (const fd of fieldDiffs) {
      if (!fd.formulaFinding) continue
      const issue = formulaFindingToPlausibilityIssue(
        { kind: fd.formulaFinding.kind, inputsChanged: false, explanation: fd.formulaFinding.explanation },
        String(fd.field),
        label,
      )
      if (issue) formulaEngineIssues.push(issue)
    }
    const includedInDelta =
      (m.matchStatus === 'safe_match' || m.matchStatus === 'probable_match') && !m.requiresReview

    stepComparisons.push({
      altIndex: m.altIndex,
      neuIndex: m.neuIndex,
      stepLabel: label,
      match: m,
      fieldDiffs,
      includedInDelta,
    })

    if (m.matchStatus === 'new_step') newLabels.push(label)
    else if (m.matchStatus === 'removed_step') removedLabels.push(label)
    else if (m.matchStatus === 'possible_structure_change') possibleLabels.push(label)
  }

  // Summen-Rekonziliation (KAR-887/P0.2): runs PER FILE (ALT/NEU independently,
  // not diffed against each other) — see reconciliation.ts header. Only when
  // summaryMetrics were actually parsed for that side (same guard pattern as
  // summaryDiffs below): a rehydrate caller that didn't reconstruct
  // summaryMetrics (see rehydrate.ts rehydrateFile) simply gets no
  // reconciliation issues instead of a crash, exactly like summaryDiffs.
  const reconciliationConfig = options?.reconciliationConfig ?? engineConfig.reconciliation
  const reconciliationIssues = [
    ...(alt.summaryMetrics
      ? checkReconciliation(
          {
            side: 'ALT',
            steps: alt.steps,
            summaryMetrics: alt.summaryMetrics,
            materialRows: alt.materialRows,
            sbmRows: alt.sbmRows,
            logisticsRows: alt.logisticsRows,
          },
          reconciliationConfig,
        )
      : []),
    ...(neu.summaryMetrics
      ? checkReconciliation(
          {
            side: 'NEU',
            steps: neu.steps,
            summaryMetrics: neu.summaryMetrics,
            materialRows: neu.materialRows,
            sbmRows: neu.sbmRows,
            logisticsRows: neu.logisticsRows,
          },
          reconciliationConfig,
        )
      : []),
  ]

  // Business-Rule-Reconciliation (KAR-901/P2.2): runs PER FILE (ALT/NEU
  // independently), same contract as reconciliation.ts above — see
  // business-rules.ts header. Unlike reconciliationIssues, this does NOT
  // require summaryMetrics (it never reads the summary sheet at all — it
  // only recomputes ROW-internal formulas), so it always runs when steps
  // exist, staying byte-identical for rehydrated callers whose steps array
  // is reconstructed (rehydrate.ts) regardless of whether summaryMetrics is.
  const businessRulesConfig = options?.businessRulesConfig ?? engineConfig.businessRules
  const businessRuleResults = [
    ...evaluateBusinessRules(
      { side: 'ALT', steps: alt.steps, materialRows: alt.materialRows, sbmRows: alt.sbmRows },
      businessRulesConfig,
    ),
    ...evaluateBusinessRules(
      { side: 'NEU', steps: neu.steps, materialRows: neu.materialRows, sbmRows: neu.sbmRows },
      businessRulesConfig,
    ),
  ]
  // Formula-Engine-Synergie (KAR-900 <-> KAR-901, text-only per task
  // instruction "keine neue Kopplung der Engines"): only MANUFACTURING row
  // checks have a corresponding stepComparisons FieldDiff to look up (MATERIAL/
  // SBM rows are never step-diffed, see QafFileParsed doc comments above) —
  // when the SAME (side, row, targetField) also carries a formulaFinding
  // (formula changed ALT->NEU), append that explanation text to the
  // business-rule issue's message (see business-rules.ts
  // businessRuleResultToPlausibilityIssue doc comment).
  const MANUFACTURING_RULE_CHECK_IDS = new Set<BusinessRuleCheckId>([
    'rule_calc_fek',
    'rule_calc_fk',
    'rule_calc_ausschuss_fertigung',
  ])
  function linkedFormulaChangeExplanation(r: BusinessRuleResult): string | undefined {
    if (r.status !== 'abweichung' || !MANUFACTURING_RULE_CHECK_IDS.has(r.checkId)) return undefined
    const sc = stepComparisons.find((c) => (r.side === 'ALT' ? c.altIndex === r.rowIndex : c.neuIndex === r.rowIndex))
    const fd = sc?.fieldDiffs.find((f) => f.field === r.targetField)
    return fd?.formulaFinding?.explanation
  }
  const businessRuleIssues = businessRuleResults
    .map((r) => businessRuleResultToPlausibilityIssue(r, linkedFormulaChangeExplanation(r)))
    .filter((x): x is PlausibilityIssue => x !== null)

  // Rohstoffzuschlag-Validierung (KAR-902/P2.3): runs PER FILE (ALT/NEU
  // independently), same tri-state contract as businessRuleResults above —
  // see rmr-parser.ts header. Does not require summaryMetrics (never reads
  // the summary sheet), so it stays byte-identical for rehydrated callers
  // whose steps array is reconstructed regardless of whether summaryMetrics
  // is (same reasoning businessRuleResults documents above).
  const rmrValidationConfig = options?.rmrValidationConfig
  const rmrIssues = [
    ...evaluateRmrValidation({ side: 'ALT', rmrRows: alt.rmrRows }, rmrValidationConfig),
    ...evaluateRmrValidation({ side: 'NEU', rmrRows: neu.rmrRows }, rmrValidationConfig),
  ]
    .map(rmrValidationResultToPlausibilityIssue)
    .filter((x): x is PlausibilityIssue => x !== null)

  // RMR Zwei-Block-Sicherheitsnetz (KAR-902 follow-up, adversarial-review
  // finding confidence 85): only present when the ingest/rehydrate path
  // captured it (see QafFileParsed.rmrParseMeta doc) — absent for pre-fix
  // callers, same optional guard pattern as parseDegradationIssues below.
  const rmrParseDegradationIssues = [
    alt.rmrParseMeta ? rmrParseMetaToPlausibilityIssue(alt.rmrParseMeta, 'ALT') : null,
    neu.rmrParseMeta ? rmrParseMetaToPlausibilityIssue(neu.rmrParseMeta, 'NEU') : null,
  ].filter((x): x is PlausibilityIssue => x !== null)

  // LOGISTICS&CUSTOM-Validierung (KAR-903/P2.4): runs PER FILE (ALT/NEU
  // independently), same tri-state contract as rmrIssues above — see
  // logistics-parser.ts header. Does not require summaryMetrics (never reads
  // the summary sheet), so it stays byte-identical for rehydrated callers
  // whose steps array is reconstructed regardless of whether summaryMetrics
  // is (same reasoning rmrIssues documents above).
  const logisticsValidationConfig = options?.logisticsValidationConfig
  const logisticsIssues = [
    ...evaluateLogisticsValidation({ side: 'ALT', logisticsRows: alt.logisticsRows }, logisticsValidationConfig),
    ...evaluateLogisticsValidation({ side: 'NEU', logisticsRows: neu.logisticsRows }, logisticsValidationConfig),
  ]
    .map(logisticsValidationResultToPlausibilityIssue)
    .filter((x): x is PlausibilityIssue => x !== null)

  // LC-CN-Validierung (KAR-904/P2.5): runs PER FILE (ALT/NEU independently),
  // same tri-state contract as logisticsIssues above — see lccn-parser.ts
  // header. Does not require summaryMetrics (never reads the summary sheet),
  // so it stays byte-identical for rehydrated callers whose steps array is
  // reconstructed regardless of whether summaryMetrics is.
  const lccnValidationConfig = options?.lccnValidationConfig
  const lccnIssues = [
    ...evaluateLccnValidation({ side: 'ALT', lccnValues: alt.lccnValues }, lccnValidationConfig),
    ...evaluateLccnValidation({ side: 'NEU', lccnValues: neu.lccnValues }, lccnValidationConfig),
  ]
    .map(lccnValidationResultToPlausibilityIssue)
    .filter((x): x is PlausibilityIssue => x !== null)

  // CO2e-Validierung (KAR-904/P2.5): runs PER FILE (ALT/NEU independently),
  // same tri-state contract as lccnIssues above — see co2e-parser.ts header.
  const co2eValidationConfig = options?.co2eValidationConfig
  const co2eIssues = [
    ...evaluateCo2eValidation({ side: 'ALT', materialRows: alt.co2eMaterialRows }, co2eValidationConfig),
    ...evaluateCo2eValidation({ side: 'NEU', materialRows: neu.co2eMaterialRows }, co2eValidationConfig),
  ]
    .map(co2eValidationResultToPlausibilityIssue)
    .filter((x): x is PlausibilityIssue => x !== null)

  // Fertigungskosten-Parser-Degradation (KAR-893/P1.2): only present when the
  // ingest path captured it (see QafFileParsed.manufacturingParseMeta doc) —
  // absent on rehydrated/pre-KAR-893 callers, same optional guard pattern as
  // reconciliationIssues above.
  const parseDegradationIssues = [
    alt.manufacturingParseMeta
      ? manufacturingParseDegradationToPlausibilityIssue(alt.manufacturingParseMeta, 'ALT')
      : null,
    neu.manufacturingParseMeta
      ? manufacturingParseDegradationToPlausibilityIssue(neu.manufacturingParseMeta, 'NEU')
      : null,
  ].filter((x): x is PlausibilityIssue => x !== null)

  // MANUFACTURING candidate-sheet visibility (KAR-927 / Multi-QAF-Programm
  // P0.2): sibling signal to parseDegradationIssues above, same optional
  // guard (absent on rehydrated/pre-KAR-927 callers) — see
  // manufacturingIgnoredCandidatesToPlausibilityIssue doc comment.
  const manufacturingIgnoredCandidateIssues = [
    alt.manufacturingParseMeta
      ? manufacturingIgnoredCandidatesToPlausibilityIssue(alt.manufacturingParseMeta, 'ALT')
      : null,
    neu.manufacturingParseMeta
      ? manufacturingIgnoredCandidatesToPlausibilityIssue(neu.manufacturingParseMeta, 'NEU')
      : null,
  ].filter((x): x is PlausibilityIssue => x !== null)

  // MATERIAL / SBM candidate-sheet visibility (KAR-927/P0.2): same optional
  // guard pattern as manufacturingIgnoredCandidateIssues above — absent on
  // rehydrated/pre-KAR-927 callers and on the G60 ingest path.
  const materialIgnoredCandidateIssues = [
    alt.materialParseMeta ? materialParseMetaToPlausibilityIssue(alt.materialParseMeta, 'ALT') : null,
    neu.materialParseMeta ? materialParseMetaToPlausibilityIssue(neu.materialParseMeta, 'NEU') : null,
  ].filter((x): x is PlausibilityIssue => x !== null)
  const sbmIgnoredCandidateIssues = [
    alt.sbmParseMeta ? sbmParseMetaToPlausibilityIssue(alt.sbmParseMeta, 'ALT') : null,
    neu.sbmParseMeta ? sbmParseMetaToPlausibilityIssue(neu.sbmParseMeta, 'NEU') : null,
  ].filter((x): x is PlausibilityIssue => x !== null)

  // Untrusted-Excel-Hardening advisory issues (KAR-914/P4.4): only present
  // when the ingest path captured them (see QafFileParsed.workbookSafety
  // doc) — absent for rehydrated/pre-KAR-914 callers, same optional guard
  // pattern as parseDegradationIssues above. security_macro_present /
  // security_external_links never block — the blocking checks (zip-ratio,
  // sheet-dimension cap) already ran at ingest, before this file's data
  // ever reached compareQafPair.
  const workbookSafetyIssues = [
    alt.workbookSafety ? workbookSafetyToPlausibilityIssues(alt.workbookSafety, 'ALT', alt.ref.fileName) : [],
    neu.workbookSafety ? workbookSafetyToPlausibilityIssues(neu.workbookSafety, 'NEU', neu.ref.fileName) : [],
  ].flat()

  // Multi-QAF type detection advisory issues (KAR-926/Multi-QAF-Programm
  // P0.1): same "only present when the ingest path captured them, absent for
  // rehydrated/pre-KAR-926 callers" optional guard as workbookSafetyIssues
  // above. multiQafDetectionToPlausibilityIssue itself only ever returns
  // non-null for a non-'standard_qaf' result — confirmed/probable never
  // reach here in practice (they already blocked the upload at ingest when
  // the flag was on), so this is effectively the 'ambiguous' surfacing path.
  const multiQafDetectionIssues = [
    multiQafDetectionToPlausibilityIssue(alt.multiQafDetection ?? null, 'ALT', alt.ref.fileName),
    multiQafDetectionToPlausibilityIssue(neu.multiQafDetection ?? null, 'NEU', neu.ref.fileName),
  ].filter((x): x is PlausibilityIssue => x !== null)

  // Summen-Formel-Vergleich (KAR-900/P2.1): computed here (BEFORE the
  // plausibility array below, unlike the original placement further down) so
  // its formulaFinding-derived issues can join the same array in one shot —
  // same guard pattern as summaryDiffs always had (only when both sides'
  // summaryMetrics were actually parsed).
  const summaryDiffs =
    alt.summaryMetrics && neu.summaryMetrics
      ? diffSummaryMetrics(alt.summaryMetrics, neu.summaryMetrics, engineConfig.formulaEngine.enabled)
      : []
  for (const d of summaryDiffs) {
    if (!d.formulaFinding) continue
    const issue = formulaFindingToPlausibilityIssue(
      { kind: d.formulaFinding.kind, inputsChanged: false, explanation: d.formulaFinding.explanation },
      d.metricKey,
    )
    if (issue) formulaEngineIssues.push(issue)
  }

  // Material-Zuordnungen (Block 2 — Erstverdrahtung der Zuordnungs-Durchgänge
  // in den Vergleich): dieselben tri-state materialRows, die Rule-Engine und
  // Reconciliation oben bereits lesen, erstmals durch buildMaterialMappings +
  // classifyMaterialEffects geführt. Das Blattdelta der Positionsebenen-
  // Reconciliation ist der Summary-Materialkosten-Diff — exakt die Zahl, gegen
  // die auch der Referenzlauf misst; fehlt er, bleibt die Reconciliation
  // offen (null), die Zuordnungen entstehen trotzdem. undefined nur, wenn
  // mindestens eine Seite gar keinen Material-Parse hat (tri-state).
  const materialEffects: MaterialEffectResult | undefined =
    alt.materialRows != null && neu.materialRows != null
      ? classifyMaterialEffects(
          buildMaterialMappings(
            mappablePositionsFromMaterialRows(alt.materialRows),
            mappablePositionsFromMaterialRows(neu.materialRows),
          ).mappings,
          summaryDiffs.find((d) => d.metricKey === 'materialCosts')?.deltaAbsolute ?? null,
        )
      : undefined

  // D15/D17 (Spec-Erhebung 08.08.2026, PO-Linie „weiche Befunde"): Dateiname-
  // vs-Inhalt je Seite und Fertigungstiefen-Indiz aus den bereits gerechneten
  // summaryDiffs — beide speisen denselben qaf_plausibility_issue-Kanal wie
  // jede andere Prüfung hier, kein Schema-Change.
  const filenameIdentityIssues = [
    checkFilenamePartNumber({ fileName: alt.ref.fileName, partNumber: alt.summary.partNumber.value }, 'ALT'),
    checkFilenamePartNumber({ fileName: neu.ref.fileName, partNumber: neu.summary.partNumber.value }, 'NEU'),
  ].filter((i): i is PlausibilityIssue => i !== null)
  const makeOrBuyIssue = checkMakeOrBuyIndication(summaryDiffs, engineConfig.makeOrBuyIndication)

  const plausibility = [
    ...checkPlausibility({
      altSummary: alt.summary,
      neuSummary: neu.summary,
      altSteps: alt.steps,
      neuSteps: neu.steps,
    }),
    ...ruleViolations.map(ruleViolationToPlausibilityIssue),
    ...reconciliationIssues,
    ...businessRuleIssues,
    ...rmrIssues,
    ...rmrParseDegradationIssues,
    ...logisticsIssues,
    ...lccnIssues,
    ...co2eIssues,
    ...parseDegradationIssues,
    ...manufacturingIgnoredCandidateIssues,
    ...materialIgnoredCandidateIssues,
    ...sbmIgnoredCandidateIssues,
    ...formulaEngineIssues,
    ...workbookSafetyIssues,
    ...multiQafDetectionIssues,
    ...filenameIdentityIssues,
    ...(makeOrBuyIssue ? [makeOrBuyIssue] : []),
  ]
  const currencyChanged = plausibility.some((i) => i.type === 'currency_change')

  const rcSteps: StepDiffSummary[] = stepComparisons
    .filter((s) => s.includedInDelta)
    .map((s) => ({ stepLabel: s.stepLabel, matchStatus: s.match.matchStatus, fieldDiffs: s.fieldDiffs }))

  const partNumber = neu.summary.partNumber.value ?? alt.summary.partNumber.value

  const rootCause = computeRootCause({
    partNumber,
    steps: rcSteps,
    newSteps: newLabels,
    removedSteps: removedLabels,
    structureChangeSteps: possibleLabels,
    currencyChanged,
  })

  // The EFFECTIVE config for this run: engineConfig with any discrete
  // ruleEngineConfig/reconciliationConfig/businessRulesConfig override folded
  // back in, so result.engineConfig always reflects what was actually used —
  // not just what was passed via the (optional) engineConfig option.
  const effectiveEngineConfig: EngineConfig = {
    ...engineConfig,
    ruleEngine: ruleEngineConfig,
    reconciliation: reconciliationConfig,
    businessRules: businessRulesConfig,
  }

  return {
    partNumber,
    altRef: alt.ref,
    neuRef: neu.ref,
    stepComparisons,
    summaryDiffs,
    ...(materialEffects !== undefined ? { materialEffects } : {}),
    structureChanges: { new: newLabels, removed: removedLabels, possible: possibleLabels },
    plausibility,
    rootCause,
    matchReviewCount: matches.filter((m) => m.requiresReview).length,
    ruleEnforcement: ruleEngineConfig.ruleEnforcement,
    engineConfig: effectiveEngineConfig,
  }
}
