// Multi-QAF zweistufiger Material-Vergleich (KAR-938 / Multi-QAF-Programm
// P2.4, Epic KAR-925).
//
// Problem this closes (30-backlog-phasenplan.md P2.4 + Master-Prompt §11):
// "The material comparison must operate at two levels" — 11.1 shared
// component level (position/label/currency/unit cost/transport/duty/
// exchange rate/overhead/formulas/row identity, i.e. the MASTER-DATA row
// itself) and 11.2 variant allocation level (quantity factor, inclusion/
// exclusion, added/removed/substituted component, effective cost
// contribution — i.e. how ONE variant participates in that row). §11.2 also
// states the KERN-REGEL this module exists to enforce: "A shared unit-cost
// change may affect many variants... Do not duplicate the same shared price
// change as an unexplained independent defect for every variant. Show: one
// shared master-data change, the affected variants, the commercial impact
// per variant, the aggregate impact where meaningful."
//
// container-differ.ts (KAR-937/#309, P2.3) diffs the same
// `container.sharedMaterialMaster` array but is explicitly, deliberately
// VALUE-BLIND (see its own module header: "shared-material unit-cost/
// quantity-factor VALUES -> P2.4"). This module is that P2.4: it never
// reports a row/column merely existing or not (that stays #309's job —
// `SharedMaterialRowChangedFinding` there already covers the row's own
// `validationStatus` transition and its referenced-variant-id-SET change);
// this module ONLY asks "did a row's own field VALUES change" (11.1) and
// "did a specific variant's ALLOCATION against a row change" (11.2). Row
// pairing (`pairRows` below) is computed independently here — not imported
// from #309, whose own exported types (`SharedMaterialRowIdentityRef`) carry
// no VALUE data to diff at all — but uses the IDENTICAL keying algorithm
// (`canonicalComponentIdentity`, last-parsed-row-wins on a within-side
// duplicate) so the two modules never disagree about which ALT row pairs
// with which NEU row.
//
// ── Two levels, two separate finding groups (never conflated) ──────────────
//   11.1 Shared-Komponenten-Ebene (`sharedComponents`, keyed by
//        canonicalComponentIdentity, computed ONCE per row-pair regardless of
//        how many variants reference it): unitCostValueChanges (value and
//        currency reported SEPARATELY — a currency swap and a value swap are
//        different findings, KAR-938 task spec), exchangeRateChanges,
//        logisticsOrDutyChanges, materialOverheadChanges, formulaChanges
//        (reuses formula-engine.ts's compareFormulaPair verbatim — "geänderte
//        Formel bei gleichem cached Wert" is its own reportable kind there
//        already, `formel_geaendert_wert_gleich`), rowIdentityChanges (a
//        position-stable row whose LABEL changed — Master-Prompt §11.1's own
//        "row identity" bullet, see `pairRows`' rename-correlation pass).
//   11.2 Varianten-Allokations-Ebene (`variantAllocation`, one finding per
//        (matched variant pair, row) where the ALLOCATION itself — not the
//        row's own values — changed): `factor_changed` (both sides
//        applicable, numeric value differs — this is where an explicit-0
//        factor toggling to/from a nonzero factor is reported, NEVER
//        conflated with `included`/`excluded` below), `included`/`excluded`
//        (blank<->applicable transition regardless of which numeric value the
//        applicable side carries, including an explicit 0 — "blank≠0"),
//        `component_added`/`component_removed` (the row itself is new/gone —
//        one finding per matched variant that references it, so a caller
//        never has to cross-reference #309's row-level added/removed sets to
//        know which of ITS OWN variants are affected).
//
// ── Mixed-Currency-Doktrin (KAR-935 precedent, respected here) ─────────────
// A shared value's commercial IMPACT on a variant (`factor × valueDelta`) is
// only ever computed when: (a) the row's own currency is IDENTICAL on both
// sides (a currency SWAP is its own separate, un-quantified finding — never
// silently converted), and (b) the variant's own quantity factor is IDENTICAL
// on both sides (a SIMULTANEOUS factor change makes "how much of this delta
// is this variant's" ambiguous — reported as its own `factor_changed` finding
// at 11.2 instead, never double-counted into the 11.1 impact number). Neither
// condition holding => `VariantImpact.impact` is `null` with a `reason`, NEVER
// a fabricated number — same "never force an interpretation" discipline
// Master-Prompt §7 states and VirtualVariantSummaryTotals.materialCostsByCurrency
// (types.ts, KAR-935) already established for the summary-aggregation path.
// Aggregates (`MaterialImpactAggregate`) are per-CURRENCY buckets for exactly
// this reason — a finding touching two currencies never produces one mixed
// total. The SAME gate applies to the 11.2 `VariantAllocationFinding.
// effectiveCostDeltaAbsolute/Percent` (`altEffectiveCost`/`neuEffectiveCost`
// live in the row's own `unitCost.currency`, so a `factor_changed` finding on
// a row whose currency also swapped would otherwise diff two different
// currencies against each other) — adversarial review KAR-938/#310 F2. The
// currency check itself is a THREE-way gate, not a boolean: `same_currency`
// (impact computed), `currency_changed` (a genuine ALT<->NEU swap — its own
// `mixed_currency` reason/warning), and `currency_unknown` (the currency was
// simply never captured on at least one side — its own separate
// `currency_unknown` reason/warning, NEVER reported as a swap that never
// happened) — adversarial review KAR-938/#310 F3.
//
// ── Fail-closed (Master-Prompt §7, KAR-936 matched-only gate) ──────────────
// Every 11.2 finding, every impact computation, and every substitution
// candidate is scoped to variant pairs the CALLER's own `matchResult` already
// classified `'matched'` (variant-matcher.ts) — an `'ambiguous'`/
// `'split_suspected'`/`'merge_suspected'` variant pair is NEVER guessed at
// here; its ids are instead carried through verbatim on `uncertainMatches`
// (same "pass through, never resolve" discipline container-differ.ts already
// established for its own `variants.uncertainMatches`) so a caller can render
// "N variants not compared" rather than silently seeing zero findings for
// them. A row whose OWN `validationStatus` is not `'ok'` (KAR-931's
// `missing_unit_cost`/`missing_factor_for_all_variants`/
// `formula_error_cached`/`orphaned_no_summary_link`/`needs_review`) still
// produces findings (never silently dropped — a human needs to see the row
// changed) but every such finding is `reviewRelevant: true` and its impact's
// `aggregates` is forced empty — an uncertain row's numbers are shown but
// never rolled into a trusted total (same `reviewRelevant`-not-`severity`
// axis MultiQafWarning.reviewRelevant's own doc comment establishes).
//
// ── Determinism ─────────────────────────────────────────────────────────
// Every finding array is sorted on a stable key (canonicalComponentIdentity,
// then variant id, then finding kind — all plain string sorts); every
// currency-bucket aggregate is sorted by currency code. Same inputs always
// produce a byte-identical (JSON.stringify-equal) MaterialDiffResult.
//
// ── Toleranzen (engine-config-Muster, keine Magic Numbers) ─────────────────
// Value-delta magnitude bands reuse differ.ts's `computeNumericDelta`
// verbatim (same 'konstant'/'anstieg'/'senkung'/'auffaellig_10'/
// 'auffaellig_25'/'kritisch_50' status-band machinery every other QAF value
// comparison in this codebase already uses) — `MaterialDifferConfig.bands`
// defaults to `DEFAULT_DIFFER_BANDS_CONFIG` (differ.ts) so a caller who never
// touches config gets byte-identical thresholds to every other diff surface.
// `substitutionLabelSimilarityThreshold` is this module's own named section
// (no precedent to reuse — no other module in this tree does removed/added
// label-similarity correlation), analogous in SHAPE to
// RECONCILIATION_CONFIG/DifferBandsConfig (named type + named DEFAULT_
// constant + explicit function parameter that falls back to it).
//
// tdd-guard: covered by __tests__/material-differ.test.ts (synthetic, one
// scenario per finding kind + determinism) and
// __tests__/material-differ.real-files.test.ts (env-gated: self-diff of each
// of the 4 real files against an identical copy -> zero findings in every
// group, plus one in-memory single-row-mutation proof).

import type { DiffStatus } from '../types'
import { computeNumericDelta, DEFAULT_DIFFER_BANDS_CONFIG, round4, type DifferBandsConfig } from '../differ'
import {
  buildFormulaProvenance,
  compareFormulaPair,
  type FormulaComparisonKind,
  type FormulaProvenance,
} from '../formula-engine'
import { normalizedSimilarity } from '@/lib/duplicates/fuzzy'
import type {
  MultiQafCellRef,
  MultiQafContainer,
  MultiQafFormulaValue,
  MultiQafMoneyAmount,
  MultiQafWarning,
  VariantMatrixRow,
  VariantMatrixRowValidationStatus,
} from './types'
import type {
  VariantAmbiguousResult,
  VariantMatchedResult,
  VariantMatchResult,
  VariantMergeSuspectedResult,
  VariantSplitSuspectedResult,
} from './variant-matcher'

// ── Config (own named section — see module header "Toleranzen") ────────────

export interface MaterialDifferConfig {
  /** Status-band thresholds for every numeric value delta this module
   * computes (unit cost, exchange rate, logistics/duty, material overhead,
   * effective-cost contribution) — same shape/semantics as differ.ts's
   * DifferBandsConfig, kept as its own field (not a re-export) because a
   * material row is a structurally different domain (no QAFRow/percent-field
   * concept) even though the threshold PHILOSOPHY is identical. */
  bands: DifferBandsConfig
  /** Minimum Levenshtein-based label similarity (lib/duplicates/fuzzy.ts
   * normalizedSimilarity, 0..1) above which a wholly-removed row and a
   * wholly-added row are surfaced as a `SubstitutionSuspectedFinding` —
   * never auto-resolved into a match, purely a review hint (Master-Prompt
   * §11.2 "component substitution" + KAR-938 task spec "nie automatisch").
   * Deliberately LOWER than variant-matcher.ts's own
   * DEFAULT_VARIANT_MATCH_CONFIG.similarityFloor (0.8, used to accept a
   * pairing) — this is a "worth a human look" bar, not an acceptance bar. */
  substitutionLabelSimilarityThreshold: number
  /** Minimum label similarity (same `normalizedSimilarity` scale) required
   * before `pairRows`' position-based rename pass will pair two leftover
   * ALT/NEU rows that merely reuse the same position number. Deliberately
   * HIGHER than `substitutionLabelSimilarityThreshold` — pairing two rows as
   * "the same component, relabeled" is a much stronger claim than "worth a
   * human look", and reused position numbers are exactly the case
   * `detectSubstitutions` cannot see (its input is already ALT/NEU-only
   * rows). Below this bar the two rows stay in `altOnly`/`neuOnly` and flow
   * through the ordinary component_removed/component_added +
   * `detectSubstitutions` path instead (adversarial review KAR-938/#310 F1 —
   * a position-reused genuine substitution, e.g. a bracket replaced by an
   * unrelated sensor, must never be silently reported as a harmless label
   * rename). */
  rowRenameLabelSimilarityThreshold: number
  /** Hard cap on the number of `SubstitutionSuspectedFinding` entries
   * `detectSubstitutions` returns. The best-match restriction (one finding
   * per `altOnly` row, ties collapsed into one collective finding) already
   * bounds this to O(altOnly.length) instead of O(altOnly × neuOnly), but a
   * container with many generic/near-duplicate labels can still produce a
   * long list; beyond this cap the remainder is suppressed and reported via
   * a single `substitution_candidates_capped` warning instead (adversarial
   * review KAR-938/#310 F5). */
  substitutionFindingsCap: number
}

export const DEFAULT_MATERIAL_DIFFER_CONFIG: MaterialDifferConfig = {
  bands: DEFAULT_DIFFER_BANDS_CONFIG,
  substitutionLabelSimilarityThreshold: 0.6,
  rowRenameLabelSimilarityThreshold: 0.8,
  substitutionFindingsCap: 50,
}

// ── Shared row-reference + impact shapes ────────────────────────────────────

export interface MaterialComponentRef {
  canonicalComponentIdentity: string
  sourceRow: number
  sourceCells: readonly MultiQafCellRef[]
  validationStatus: VariantMatrixRowValidationStatus
}

function toComponentRef(row: VariantMatrixRow): MaterialComponentRef {
  return {
    canonicalComponentIdentity: row.canonicalComponentIdentity,
    sourceRow: row.sourceRow,
    sourceCells: row.sourceCells,
    validationStatus: row.validationStatus,
  }
}

/** Why `VariantImpact.impact` is `null` for an otherwise-affected variant —
 * see module header "Mixed-Currency-Doktrin". Never guessed; always one of
 * these four documented reasons.
 * `mixed_currency` vs `currency_unknown` (adversarial review KAR-938/#310
 * F3): a currency captured as DIFFERENT non-null values on ALT/NEU is a
 * genuine swap (`mixed_currency`); a currency that is `null` on at least one
 * side was simply never extracted — no swap ever happened, so it gets its
 * own reason/message instead of collapsing into the same "Währungswechsel"
 * claim (which would be false for honestly-sparse data). */
export type VariantImpactUncomputableReason = 'mixed_currency' | 'currency_unknown' | 'factor_changed' | 'value_unknown'

export interface VariantImpact {
  variantId: string
  /** The (identical-on-both-sides) quantity factor used to compute `impact`
   * — null exactly when `impact` is null. */
  factor: number | null
  /** `factor * valueDelta`, in `currency` — null when not computable (see
   * `reason`), NEVER a fabricated/best-guess number. */
  impact: number | null
  currency: string | null
  reason?: VariantImpactUncomputableReason
}

export interface MaterialImpactAggregate {
  currency: string
  totalImpact: number
  /** Count of variants folded into `totalImpact` (impacts with a `reason`
   * are never included). */
  variantCount: number
}

export interface SharedComponentImpactSummary {
  /** Variants that reference this row on BOTH sides (matched pair, key
   * present in both `quantityFactorByVariant` records) — "the affected
   * variants" Master-Prompt §11.2 requires listing alongside a shared
   * change. Sorted. */
  affectedVariantIds: readonly string[]
  /** One entry per `affectedVariantIds` member, same order. */
  impacts: readonly VariantImpact[]
  /** Per-currency roll-up over only the NON-null impacts above — "the
   * aggregate impact where meaningful" (Master-Prompt §11.2). Empty when
   * nothing is computable, or when the owning finding's row is
   * `reviewRelevant` (fail-closed — see module header). */
  aggregates: readonly MaterialImpactAggregate[]
}

// ── 11.1 Shared-Komponenten-Ebene finding types ─────────────────────────────

export interface UnitCostValueChangeFinding {
  canonicalComponentIdentity: string
  alt: MaterialComponentRef
  neu: MaterialComponentRef
  altValue: number | null
  neuValue: number | null
  deltaAbsolute: number | null
  deltaPercent: number | null
  status: DiffStatus
  impact: SharedComponentImpactSummary
  reviewRelevant: boolean
}

export interface UnitCostCurrencyChangeFinding {
  canonicalComponentIdentity: string
  alt: MaterialComponentRef
  neu: MaterialComponentRef
  altCurrency: string | null
  neuCurrency: string | null
  /** Affected-variant list only — a bare currency SWAP has no well-defined
   * numeric impact (see module header "Mixed-Currency-Doktrin"), so
   * `impacts`/`aggregates` are always empty here. */
  impact: SharedComponentImpactSummary
  reviewRelevant: boolean
}

export interface ExchangeRateChangeFinding {
  canonicalComponentIdentity: string
  alt: MaterialComponentRef
  neu: MaterialComponentRef
  altValue: number | null
  neuValue: number | null
  deltaAbsolute: number | null
  deltaPercent: number | null
  status: DiffStatus
  /** Affected-variant list only — `exchangeRate` is tracked provenance on
   * the row (material-matrix-parser.ts) but is NOT itself a factor in
   * `effectiveCostByVariant = unitCost * quantityFactor` (that computation
   * never reads exchangeRate) — fabricating a `factor * exchangeRateDelta`
   * "impact" would assert a multiplier relationship this data model does not
   * actually encode. */
  impact: SharedComponentImpactSummary
  reviewRelevant: boolean
}

export type MoneyFieldKind = 'logistics_or_duty' | 'material_overhead'

export interface MoneyFieldChangeFinding {
  field: MoneyFieldKind
  canonicalComponentIdentity: string
  alt: MaterialComponentRef
  neu: MaterialComponentRef
  altAmount: MultiQafMoneyAmount | null
  neuAmount: MultiQafMoneyAmount | null
  valueChanged: boolean
  currencyChanged: boolean
  /** Only set when `valueChanged` (a currency-only swap has no numeric
   * delta to band). */
  deltaAbsolute: number | null
  deltaPercent: number | null
  status: DiffStatus | null
  impact: SharedComponentImpactSummary
  reviewRelevant: boolean
}

export interface FormulaChangeFinding {
  canonicalComponentIdentity: string
  alt: MaterialComponentRef
  neu: MaterialComponentRef
  /** Always one of the 3 REPORTABLE formula-engine.ts kinds
   * ('formel_geaendert_wert_gleich' | 'formel_geaendert_wert_geaendert' |
   * 'formel_zu_konstante') — 'no_formula_data'/'unauffaellig' never produce a
   * finding here, same filter formulaFindingToPlausibilityIssue applies. */
  comparisonKind: FormulaComparisonKind
  explanation: string
  explanationEn?: string
  /** Affected-variant list only — the unit-cost VALUE impact of a formula
   * change (when the formula change also moved the cached value) is already
   * reported, with real numbers, by this same row-pair's own
   * `unitCostValueChanges` entry; duplicating that number here under a
   * "formula" label would double-count it in an aggregate that sums across
   * finding groups. */
  impact: SharedComponentImpactSummary
  reviewRelevant: boolean
}

export interface RowIdentityChangeFinding {
  altCanonicalComponentIdentity: string
  neuCanonicalComponentIdentity: string
  /** The (unchanged) position number that pairs the two identities — see
   * `pairRows`' position-based rename correlation. */
  position: string
  altLabel: string
  neuLabel: string
  alt: MaterialComponentRef
  neu: MaterialComponentRef
  reviewRelevant: boolean
}

export interface MaterialSharedComponentDiff {
  unitCostValueChanges: readonly UnitCostValueChangeFinding[]
  unitCostCurrencyChanges: readonly UnitCostCurrencyChangeFinding[]
  exchangeRateChanges: readonly ExchangeRateChangeFinding[]
  logisticsOrDutyChanges: readonly MoneyFieldChangeFinding[]
  materialOverheadChanges: readonly MoneyFieldChangeFinding[]
  formulaChanges: readonly FormulaChangeFinding[]
  rowIdentityChanges: readonly RowIdentityChangeFinding[]
}

// ── 11.2 Varianten-Allokations-Ebene finding types ──────────────────────────

export type VariantAllocationChangeKind =
  | 'factor_changed'
  | 'included'
  | 'excluded'
  | 'component_added'
  | 'component_removed'

export interface VariantAllocationFinding {
  kind: VariantAllocationChangeKind
  /** NEU-side identity when the row exists in NEU (every kind except
   * `component_removed`), else the ALT-side identity. */
  canonicalComponentIdentity: string
  altVariantId: string
  neuVariantId: string
  /** `null` exactly when `altApplicable` is false (key absent — "not
   * applicable", never conflated with an explicit `0`, see module header). */
  altFactor: number | null
  neuFactor: number | null
  altApplicable: boolean
  neuApplicable: boolean
  altEffectiveCost: number | null
  neuEffectiveCost: number | null
  /** `null` when the delta could not be honestly computed — see
   * `effectiveCostDeltaReason`; NEVER a fabricated cross-currency number
   * (adversarial review KAR-938/#310 F2 — same "Mixed-Currency-Doktrin" gate
   * `computeSharedImpact`/11.1 already applies, extended to this
   * variant-allocation-level delta). */
  effectiveCostDeltaAbsolute: number | null
  effectiveCostDeltaPercent: number | null
  effectiveCostStatus: DiffStatus
  /** Set (only) when `effectiveCostDeltaAbsolute`/`effectiveCostDeltaPercent`
   * were suppressed — currently only `'mixed_currency'`: this row's ALT/NEU
   * `unitCost.currency` differ (or one is unknown) while BOTH sides are
   * applicable to this variant (`kind === 'factor_changed'`), so
   * `altEffectiveCost`/`neuEffectiveCost` are not expressed in the same
   * currency and a raw numeric delta between them would be fabricated.
   * `undefined` whenever the delta was computed normally (including the
   * `included`/`excluded`/`component_added`/`component_removed` cases, which
   * are already single-sided and never reach the gate). */
  effectiveCostDeltaReason?: 'mixed_currency'
  /** `null` exactly for `component_added` (no ALT-side row at all). */
  alt: MaterialComponentRef | null
  /** `null` exactly for `component_removed` (no NEU-side row at all). */
  neu: MaterialComponentRef | null
  reviewRelevant: boolean
}

export interface SubstitutionSuspectedFinding {
  removed: MaterialComponentRef
  /** The best-matching `neuOnly` row(s) for `removed`, by label similarity —
   * a BEST-MATCH restriction (adversarial review KAR-938/#310 F5): normally
   * exactly one entry (the single highest-similarity `neuOnly` candidate
   * above `substitutionLabelSimilarityThreshold`), never one
   * SubstitutionSuspectedFinding per candidate above threshold (that was the
   * unbounded altOnly×neuOnly cross-product this fixes). More than one entry
   * ONLY when multiple `neuOnly` rows TIE for the best similarity score —
   * a single collective finding rather than an arbitrary pick or one finding
   * per tied candidate. */
  addedCandidates: readonly MaterialComponentRef[]
  /** 0..1, lib/duplicates/fuzzy.ts normalizedSimilarity — the (single) best
   * similarity score shared by every entry in `addedCandidates`. */
  labelSimilarity: number
  /** Matched variant pairs that had a factor on BOTH the removed row (ALT
   * side) and at least one of `addedCandidates` (NEU side) — i.e. plausibly
   * "the same variant's allocation moved from one component to another".
   * Can be empty (the candidate is still surfaced — a container-wide
   * substitution can matter even with no currently-matched variant
   * overlap). */
  affectedVariantPairs: readonly { altVariantId: string; neuVariantId: string }[]
  /** Always `true` by construction — Master-Prompt §11.2 "nie automatisch",
   * same discipline VariantAmbiguousResult.reviewRelevant already
   * establishes for variant-identity ambiguity. */
  reviewRelevant: true
}

export interface MaterialVariantAllocationDiff {
  findings: readonly VariantAllocationFinding[]
  substitutionsSuspected: readonly SubstitutionSuspectedFinding[]
}

// ── Top-level result + options ──────────────────────────────────────────────

export interface MaterialDiffOptions {
  config?: MaterialDifferConfig
}

export interface MaterialDiffResult {
  sharedComponents: MaterialSharedComponentDiff
  variantAllocation: MaterialVariantAllocationDiff
  /** Passed through verbatim from `matchResult` (never resolved/guessed) —
   * variant pairs this module could not diff at the allocation level at all.
   * Same "pass through, never resolve" discipline container-differ.ts's own
   * `variants.uncertainMatches` established. */
  uncertainMatches: readonly (VariantAmbiguousResult | VariantSplitSuspectedResult | VariantMergeSuspectedResult)[]
  /** Four codes: `mixed_currency_material_impact` (one per shared-component
   * finding where at least one otherwise-affected variant's impact could not
   * be computed due to a genuine ALT<->NEU currency SWAP, see module header
   * "Mixed-Currency-Doktrin") and its sibling `currency_unknown_material_impact`
   * (same shape, but the currency was simply never captured on at least one
   * side — never a swap, kept as its own code, adversarial review
   * KAR-938/#310 F3), plus `substitution_candidates_capped` (at most one,
   * KAR-938/#310 F5 — see `detectSubstitutions`). */
  warnings: readonly MultiQafWarning[]
}

// ── Row identity parsing (canonicalComponentIdentity = "position::label",
// optionally "#<ordinal>"-suffixed on a within-side duplicate — see
// material-matrix-parser.ts's own `baseIdentity`/`identity` construction,
// this is the exact inverse) ────────────────────────────────────────────────

function parseComponentIdentity(identity: string): { position: string; label: string } {
  const withoutOrdinal = identity.replace(/#\d+$/, '')
  const sepIndex = withoutOrdinal.indexOf('::')
  if (sepIndex === -1) return { position: withoutOrdinal, label: '' }
  return { position: withoutOrdinal.slice(0, sepIndex), label: withoutOrdinal.slice(sepIndex + 2) }
}

// ── Row pairing (11.1 "row identity", KAR-938 own algorithm — see module
// header for why this is not imported from container-differ.ts) ───────────

interface RowPair {
  alt: VariantMatrixRow
  neu: VariantMatrixRow
  /** True when this pair was NOT matched by identical canonicalComponentIdentity
   * but by the position-based rename correlation below. */
  renamed: boolean
}

/**
 * Pairs ALT/NEU `sharedMaterialMaster` rows: first by exact
 * canonicalComponentIdentity match (last-parsed-row-wins per identity on a
 * within-side duplicate — identical convention to container-differ.ts's own
 * `diffSharedMaterial`, so the two modules never disagree on which row is
 * "the" row for a colliding identity), then a SECOND pass pairs any
 * still-unmatched ALT/NEU rows that share the SAME position number (11.1
 * "row identity... Label geändert bei gleicher Position") — but ONLY when
 * that position is unambiguous (exactly one leftover ALT row and exactly one
 * leftover NEU row share it) AND the two rows' labels clear
 * `config.rowRenameLabelSimilarityThreshold` (adversarial review KAR-938/#310
 * F1 — a reused position number is necessary but NOT sufficient evidence of a
 * rename; without a label-similarity check a genuine substitution sharing a
 * position with an unrelated component would be silently merged into one
 * "renamed" row); an ambiguous position-collision, or a position match whose
 * labels are too dissimilar, is left unpaired rather than guessed
 * (Master-Prompt §7) — the two rows stay in altOnly/neuOnly and surface as an
 * ordinary removed+added pair, eligible for `detectSubstitutions`.
 */
function pairRows(
  alt: MultiQafContainer,
  neu: MultiQafContainer,
  config: MaterialDifferConfig,
): { pairs: RowPair[]; altOnly: VariantMatrixRow[]; neuOnly: VariantMatrixRow[] } {
  const altIndex = new Map<string, VariantMatrixRow>()
  for (const row of alt.sharedMaterialMaster) altIndex.set(row.canonicalComponentIdentity, row)
  const neuIndex = new Map<string, VariantMatrixRow>()
  for (const row of neu.sharedMaterialMaster) neuIndex.set(row.canonicalComponentIdentity, row)

  const pairs: RowPair[] = []
  const consumedNeuIdentities = new Set<string>()
  const altOnly: VariantMatrixRow[] = []
  for (const [identity, altRow] of altIndex) {
    const neuRow = neuIndex.get(identity)
    if (neuRow) {
      pairs.push({ alt: altRow, neu: neuRow, renamed: false })
      consumedNeuIdentities.add(identity)
    } else {
      altOnly.push(altRow)
    }
  }
  let neuOnly = [...neuIndex.entries()].filter(([identity]) => !consumedNeuIdentities.has(identity)).map(([, r]) => r)

  const altByPosition = new Map<string, VariantMatrixRow[]>()
  for (const row of altOnly) {
    const { position } = parseComponentIdentity(row.canonicalComponentIdentity)
    if (position === '') continue
    const arr = altByPosition.get(position) ?? []
    arr.push(row)
    altByPosition.set(position, arr)
  }
  const neuByPosition = new Map<string, VariantMatrixRow[]>()
  for (const row of neuOnly) {
    const { position } = parseComponentIdentity(row.canonicalComponentIdentity)
    if (position === '') continue
    const arr = neuByPosition.get(position) ?? []
    arr.push(row)
    neuByPosition.set(position, arr)
  }

  const renamedAltIdentities = new Set<string>()
  const renamedNeuIdentities = new Set<string>()
  for (const [position, altRows] of altByPosition) {
    if (altRows.length !== 1) continue
    const neuRows = neuByPosition.get(position)
    if (!neuRows || neuRows.length !== 1) continue

    // Adversarial-review fix (KAR-938/#310 F1): a reused position number
    // alone is NOT evidence the two rows are the same component relabeled —
    // it can just as well be two genuinely different components that happen
    // to land on the same position (e.g. real split-rows disambiguated only
    // by an appended #N ordinal). Require the label similarity to clear
    // `rowRenameLabelSimilarityThreshold` (deliberately higher than
    // `substitutionLabelSimilarityThreshold`) before treating this as a
    // rename; otherwise leave both rows in altOnly/neuOnly so they surface
    // as a genuine removed+added pair and run through detectSubstitutions'
    // own (lower-bar, review-only) similarity check instead of being
    // silently merged into a harmless-looking label rename.
    const altLabel = parseComponentIdentity(altRows[0]!.canonicalComponentIdentity).label
    const neuLabel = parseComponentIdentity(neuRows[0]!.canonicalComponentIdentity).label
    const labelSimilarity = normalizedSimilarity(altLabel, neuLabel)
    if (labelSimilarity < config.rowRenameLabelSimilarityThreshold) continue

    pairs.push({ alt: altRows[0]!, neu: neuRows[0]!, renamed: true })
    renamedAltIdentities.add(altRows[0]!.canonicalComponentIdentity)
    renamedNeuIdentities.add(neuRows[0]!.canonicalComponentIdentity)
  }

  const finalAltOnly = altOnly.filter((r) => !renamedAltIdentities.has(r.canonicalComponentIdentity))
  neuOnly = neuOnly.filter((r) => !renamedNeuIdentities.has(r.canonicalComponentIdentity))

  return { pairs, altOnly: finalAltOnly, neuOnly }
}

// ── Formula provenance bridge (types.ts doc: "provenance is populated once
// a caller wants the full hash-based comparison... by calling
// buildFormulaProvenance(raw) itself" — this module is that caller) ────────

function toFormulaProvenance(fv: MultiQafFormulaValue | null): FormulaProvenance | undefined {
  if (fv === null || fv.formula === null) return undefined
  return fv.provenance ?? buildFormulaProvenance(fv.formula)
}

// ── Impact computation (11.1, shared across unit-cost/logistics/overhead) ──
// round4 imported from ../differ (KAR-944 adversarial review F3 — was this
// module's own private copy).

function affectedVariantIdsOnly(
  altRow: VariantMatrixRow,
  neuRow: VariantMatrixRow,
  altToNeu: ReadonlyMap<string, string>,
): string[] {
  const out: string[] = []
  for (const [altVariantId, neuVariantId] of altToNeu) {
    if (altVariantId in altRow.quantityFactorByVariant && neuVariantId in neuRow.quantityFactorByVariant) {
      out.push(neuVariantId)
    }
  }
  return out.sort()
}

function buildAggregates(impacts: readonly VariantImpact[]): MaterialImpactAggregate[] {
  const byCurrency = new Map<string, { total: number; count: number }>()
  for (const imp of impacts) {
    if (imp.impact === null || imp.currency === null) continue
    const bucket = byCurrency.get(imp.currency) ?? { total: 0, count: 0 }
    bucket.total += imp.impact
    bucket.count += 1
    byCurrency.set(imp.currency, bucket)
  }
  return [...byCurrency.entries()]
    .map(([currency, { total, count }]) => ({ currency, totalImpact: round4(total), variantCount: count }))
    .sort((a, b) => a.currency.localeCompare(b.currency))
}

/** Three-way currency-gate state (adversarial review KAR-938/#310 F3) — see
 * `VariantImpactUncomputableReason` doc for why `currency_unknown` must never
 * collapse into `currency_changed`'s "Währungswechsel" claim. */
type CurrencyGateState = 'same_currency' | 'currency_changed' | 'currency_unknown'

function currencyGateState(altCurrency: string | null, neuCurrency: string | null): CurrencyGateState {
  if (altCurrency === null || neuCurrency === null) return 'currency_unknown'
  return altCurrency === neuCurrency ? 'same_currency' : 'currency_changed'
}

/**
 * `factor * deltaAbsolute` per matched, both-sides-applicable variant — see
 * module header "Mixed-Currency-Doktrin" for the two gates
 * (currency-gate/`altFactor === neuFactor`) that keep `impact` from ever
 * being a fabricated number.
 */
function computeSharedImpact(
  altRow: VariantMatrixRow,
  neuRow: VariantMatrixRow,
  altToNeu: ReadonlyMap<string, string>,
  deltaAbsolute: number | null,
  altCurrency: string | null,
  neuCurrency: string | null,
): SharedComponentImpactSummary {
  const currencyGate = currencyGateState(altCurrency, neuCurrency)
  const affectedVariantIds: string[] = []
  const impacts: VariantImpact[] = []

  for (const [altVariantId, neuVariantId] of altToNeu) {
    const altHas = altVariantId in altRow.quantityFactorByVariant
    const neuHas = neuVariantId in neuRow.quantityFactorByVariant
    if (!altHas || !neuHas) continue
    affectedVariantIds.push(neuVariantId)

    const altFactor = altRow.quantityFactorByVariant[altVariantId]!
    const neuFactor = neuRow.quantityFactorByVariant[neuVariantId]!

    if (deltaAbsolute === null) {
      impacts.push({ variantId: neuVariantId, factor: null, impact: null, currency: null, reason: 'value_unknown' })
    } else if (currencyGate === 'currency_unknown') {
      impacts.push({ variantId: neuVariantId, factor: null, impact: null, currency: null, reason: 'currency_unknown' })
    } else if (currencyGate === 'currency_changed') {
      impacts.push({ variantId: neuVariantId, factor: null, impact: null, currency: null, reason: 'mixed_currency' })
    } else if (altFactor !== neuFactor) {
      impacts.push({ variantId: neuVariantId, factor: null, impact: null, currency: altCurrency, reason: 'factor_changed' })
    } else {
      impacts.push({ variantId: neuVariantId, factor: altFactor, impact: round4(altFactor * deltaAbsolute), currency: altCurrency })
    }
  }

  affectedVariantIds.sort()
  impacts.sort((a, b) => a.variantId.localeCompare(b.variantId))
  return { affectedVariantIds, impacts, aggregates: buildAggregates(impacts) }
}

function withReviewRelevantImpact(impact: SharedComponentImpactSummary, reviewRelevant: boolean): SharedComponentImpactSummary {
  // Fail-closed (module header): an uncertain row's numbers stay visible on
  // each VariantImpact but are never rolled into a trusted aggregate total.
  return reviewRelevant ? { ...impact, aggregates: [] } : impact
}

// ── 11.1 Shared-Komponenten-Ebene orchestration ─────────────────────────────

function buildMoneyFieldFinding(
  field: MoneyFieldKind,
  altAmount: MultiQafMoneyAmount | null,
  neuAmount: MultiQafMoneyAmount | null,
  altRow: VariantMatrixRow,
  neuRow: VariantMatrixRow,
  altRef: MaterialComponentRef,
  neuRef: MaterialComponentRef,
  altToNeu: ReadonlyMap<string, string>,
  config: MaterialDifferConfig,
  reviewRelevant: boolean,
): MoneyFieldChangeFinding | null {
  const altValue = altAmount?.value ?? null
  const neuValue = neuAmount?.value ?? null
  const delta = computeNumericDelta(altValue, neuValue, config.bands)
  const altCurrency = altAmount?.currency ?? null
  const neuCurrency = neuAmount?.currency ?? null
  const currencyChanged = altCurrency !== neuCurrency
  const valueChanged = delta.status !== 'konstant' && delta.status !== 'nicht_berechenbar'
  if (!valueChanged && !currencyChanged) return null

  const impact = valueChanged
    ? computeSharedImpact(altRow, neuRow, altToNeu, delta.deltaAbsolute, altCurrency, neuCurrency)
    : { affectedVariantIds: affectedVariantIdsOnly(altRow, neuRow, altToNeu), impacts: [], aggregates: [] }

  return {
    field,
    canonicalComponentIdentity: neuRow.canonicalComponentIdentity,
    alt: altRef,
    neu: neuRef,
    altAmount,
    neuAmount,
    valueChanged,
    currencyChanged,
    deltaAbsolute: valueChanged ? delta.deltaAbsolute : null,
    deltaPercent: valueChanged ? delta.deltaPercent : null,
    status: valueChanged ? delta.status : null,
    impact: withReviewRelevantImpact(impact, reviewRelevant),
    reviewRelevant,
  }
}

function diffSharedComponents(
  pairs: readonly RowPair[],
  altToNeu: ReadonlyMap<string, string>,
  config: MaterialDifferConfig,
): MaterialSharedComponentDiff {
  const unitCostValueChanges: UnitCostValueChangeFinding[] = []
  const unitCostCurrencyChanges: UnitCostCurrencyChangeFinding[] = []
  const exchangeRateChanges: ExchangeRateChangeFinding[] = []
  const logisticsOrDutyChanges: MoneyFieldChangeFinding[] = []
  const materialOverheadChanges: MoneyFieldChangeFinding[] = []
  const formulaChanges: FormulaChangeFinding[] = []
  const rowIdentityChanges: RowIdentityChangeFinding[] = []

  for (const { alt: altRow, neu: neuRow, renamed } of pairs) {
    const reviewRelevant = altRow.validationStatus !== 'ok' || neuRow.validationStatus !== 'ok'
    const altRef = toComponentRef(altRow)
    const neuRef = toComponentRef(neuRow)

    if (renamed) {
      const altParsed = parseComponentIdentity(altRow.canonicalComponentIdentity)
      const neuParsed = parseComponentIdentity(neuRow.canonicalComponentIdentity)
      rowIdentityChanges.push({
        altCanonicalComponentIdentity: altRow.canonicalComponentIdentity,
        neuCanonicalComponentIdentity: neuRow.canonicalComponentIdentity,
        position: neuParsed.position,
        altLabel: altParsed.label,
        neuLabel: neuParsed.label,
        alt: altRef,
        neu: neuRef,
        reviewRelevant,
      })
    }

    // Unit cost — value and currency reported SEPARATELY (KAR-938 task spec).
    const ucDelta = computeNumericDelta(altRow.unitCost.value, neuRow.unitCost.value, config.bands)
    if (ucDelta.status !== 'konstant' && ucDelta.status !== 'nicht_berechenbar') {
      const impact = computeSharedImpact(altRow, neuRow, altToNeu, ucDelta.deltaAbsolute, altRow.unitCost.currency, neuRow.unitCost.currency)
      unitCostValueChanges.push({
        canonicalComponentIdentity: neuRow.canonicalComponentIdentity,
        alt: altRef,
        neu: neuRef,
        altValue: altRow.unitCost.value,
        neuValue: neuRow.unitCost.value,
        deltaAbsolute: ucDelta.deltaAbsolute,
        deltaPercent: ucDelta.deltaPercent,
        status: ucDelta.status,
        impact: withReviewRelevantImpact(impact, reviewRelevant),
        reviewRelevant,
      })
    }
    if (altRow.unitCost.currency !== neuRow.unitCost.currency) {
      unitCostCurrencyChanges.push({
        canonicalComponentIdentity: neuRow.canonicalComponentIdentity,
        alt: altRef,
        neu: neuRef,
        altCurrency: altRow.unitCost.currency,
        neuCurrency: neuRow.unitCost.currency,
        impact: { affectedVariantIds: affectedVariantIdsOnly(altRow, neuRow, altToNeu), impacts: [], aggregates: [] },
        reviewRelevant,
      })
    }

    // Exchange rate.
    const erDelta = computeNumericDelta(altRow.exchangeRate, neuRow.exchangeRate, config.bands)
    if (erDelta.status !== 'konstant' && erDelta.status !== 'nicht_berechenbar') {
      exchangeRateChanges.push({
        canonicalComponentIdentity: neuRow.canonicalComponentIdentity,
        alt: altRef,
        neu: neuRef,
        altValue: altRow.exchangeRate,
        neuValue: neuRow.exchangeRate,
        deltaAbsolute: erDelta.deltaAbsolute,
        deltaPercent: erDelta.deltaPercent,
        status: erDelta.status,
        impact: { affectedVariantIds: affectedVariantIdsOnly(altRow, neuRow, altToNeu), impacts: [], aggregates: [] },
        reviewRelevant,
      })
    }

    // Logistics/duty + material overhead.
    const logisticsFinding = buildMoneyFieldFinding(
      'logistics_or_duty',
      altRow.logisticsOrDuty,
      neuRow.logisticsOrDuty,
      altRow,
      neuRow,
      altRef,
      neuRef,
      altToNeu,
      config,
      reviewRelevant,
    )
    if (logisticsFinding) logisticsOrDutyChanges.push(logisticsFinding)

    const overheadFinding = buildMoneyFieldFinding(
      'material_overhead',
      altRow.materialOverhead,
      neuRow.materialOverhead,
      altRow,
      neuRow,
      altRef,
      neuRef,
      altToNeu,
      config,
      reviewRelevant,
    )
    if (overheadFinding) materialOverheadChanges.push(overheadFinding)

    // Formula (formula-engine.ts, reused verbatim). Adversarial-review fix
    // (KAR-938/#310 F4): compareFormulaPair's own valuesEqual compares RAW
    // values at epsilon=1e-9, while unitCostValueChanges above (ucDelta)
    // compares the SAME altRow.unitCost.value/neuRow.unitCost.value pair via
    // computeNumericDelta, which rounds the delta to 4dp BEFORE its own
    // epsilon check — a real (but sub-0.0001) float-recalc delta can then
    // read "changed" here but "konstant" there, so the FormulaChangeFinding
    // design's assumption (impact lives on unitCostValueChanges, never here)
    // silently breaks and the change surfaces with zero quantified impact
    // anywhere. Round both values to the SAME 4dp precision computeNumericDelta
    // already applies before handing them to compareFormulaPair — this is the
    // one Vergleichs-Semantik (differ.ts's) both call sites now agree on.
    const formulaResult = compareFormulaPair({
      altFormula: toFormulaProvenance(altRow.formulaAndCachedValue),
      neuFormula: toFormulaProvenance(neuRow.formulaAndCachedValue),
      altValue: altRow.unitCost.value === null ? null : round4(altRow.unitCost.value),
      neuValue: neuRow.unitCost.value === null ? null : round4(neuRow.unitCost.value),
    })
    if (
      formulaResult.kind === 'formel_geaendert_wert_gleich' ||
      formulaResult.kind === 'formel_geaendert_wert_geaendert' ||
      formulaResult.kind === 'formel_zu_konstante'
    ) {
      formulaChanges.push({
        canonicalComponentIdentity: neuRow.canonicalComponentIdentity,
        alt: altRef,
        neu: neuRef,
        comparisonKind: formulaResult.kind,
        explanation: formulaResult.explanation,
        explanationEn: formulaResult.explanationEn,
        impact: { affectedVariantIds: affectedVariantIdsOnly(altRow, neuRow, altToNeu), impacts: [], aggregates: [] },
        reviewRelevant,
      })
    }
  }

  const byIdentity = <T extends { canonicalComponentIdentity: string }>(a: T, b: T): number =>
    a.canonicalComponentIdentity.localeCompare(b.canonicalComponentIdentity)

  return {
    unitCostValueChanges: unitCostValueChanges.sort(byIdentity),
    unitCostCurrencyChanges: unitCostCurrencyChanges.sort(byIdentity),
    exchangeRateChanges: exchangeRateChanges.sort(byIdentity),
    logisticsOrDutyChanges: logisticsOrDutyChanges.sort(byIdentity),
    materialOverheadChanges: materialOverheadChanges.sort(byIdentity),
    formulaChanges: formulaChanges.sort(byIdentity),
    rowIdentityChanges: rowIdentityChanges.sort(
      (a, b) => a.neuCanonicalComponentIdentity.localeCompare(b.neuCanonicalComponentIdentity),
    ),
  }
}

// ── 11.2 Varianten-Allokations-Ebene orchestration ──────────────────────────

/**
 * Adversarial-review fix (KAR-938/#310 F5): the previous implementation did
 * an unbounded full cross-product of altOnly×neuOnly rows above threshold,
 * so N generically-labeled removed rows against M generically-labeled added
 * rows produced up to N*M findings — combinatorial review-hint noise that
 * defeats the "worth a human look" purpose. Now BEST-MATCH only: each
 * altOnly row contributes AT MOST one finding, pairing it with whichever
 * neuOnly row(s) achieve its highest similarity above
 * `substitutionLabelSimilarityThreshold` — a unique best match produces one
 * `addedCandidates: [oneRow]` finding; a tie for the best score produces one
 * COLLECTIVE finding (`addedCandidates` lists every tied row) instead of one
 * finding per tied candidate. The result is additionally capped at
 * `config.substitutionFindingsCap` total findings, with the remainder
 * suppressed and reported via a single `substitution_candidates_capped`
 * warning (belt-and-braces for a container with many rows all clearing the
 * best-match bar).
 */
function detectSubstitutions(
  altOnly: readonly VariantMatrixRow[],
  neuOnly: readonly VariantMatrixRow[],
  matchedVariantPairs: readonly VariantMatchedResult[],
  config: MaterialDifferConfig,
): { findings: SubstitutionSuspectedFinding[]; warnings: MultiQafWarning[] } {
  const out: SubstitutionSuspectedFinding[] = []
  for (const altRow of altOnly) {
    const altLabel = parseComponentIdentity(altRow.canonicalComponentIdentity).label
    if (altLabel === '') continue

    let bestSimilarity = -1
    let bestCandidates: VariantMatrixRow[] = []
    for (const neuRow of neuOnly) {
      const neuLabel = parseComponentIdentity(neuRow.canonicalComponentIdentity).label
      if (neuLabel === '') continue
      const similarity = normalizedSimilarity(altLabel, neuLabel)
      if (similarity < config.substitutionLabelSimilarityThreshold) continue

      if (similarity > bestSimilarity) {
        bestSimilarity = similarity
        bestCandidates = [neuRow]
      } else if (similarity === bestSimilarity) {
        bestCandidates.push(neuRow)
      }
    }
    if (bestCandidates.length === 0) continue

    bestCandidates.sort((a, b) => a.canonicalComponentIdentity.localeCompare(b.canonicalComponentIdentity))

    const affectedVariantPairs = matchedVariantPairs
      .filter(
        (mv) =>
          mv.leftId in altRow.quantityFactorByVariant &&
          bestCandidates.some((c) => mv.rightId in c.quantityFactorByVariant),
      )
      .map((mv) => ({ altVariantId: mv.leftId, neuVariantId: mv.rightId }))
      .sort((a, b) => a.altVariantId.localeCompare(b.altVariantId))

    out.push({
      removed: toComponentRef(altRow),
      addedCandidates: bestCandidates.map(toComponentRef),
      labelSimilarity: Number(bestSimilarity.toFixed(4)),
      affectedVariantPairs,
      reviewRelevant: true,
    })
  }

  out.sort((a, b) => a.removed.canonicalComponentIdentity.localeCompare(b.removed.canonicalComponentIdentity))

  const cap = config.substitutionFindingsCap
  if (out.length <= cap) return { findings: out, warnings: [] }

  const suppressedCount = out.length - cap
  const capWarning: MultiQafWarning = {
    code: 'substitution_candidates_capped',
    severity: 'warning',
    message: `Substitutions-Verdachtsliste auf ${cap} Einträge begrenzt — ${suppressedCount} weitere(r) Kandidat(en) unterdrückt (zu viele ähnliche Labels für eine belastbare Review-Liste).`,
    messageEn: `Substitution-candidate list capped at ${cap} entries — ${suppressedCount} further candidate(s) suppressed (too many similar labels for a reliable review list).`,
    sourceReferences: [],
    reviewRelevant: true,
  }
  return { findings: out.slice(0, cap), warnings: [capWarning] }
}

function diffVariantAllocation(
  pairs: readonly RowPair[],
  altOnly: readonly VariantMatrixRow[],
  neuOnly: readonly VariantMatrixRow[],
  matchedVariantPairs: readonly VariantMatchedResult[],
  config: MaterialDifferConfig,
): { diff: MaterialVariantAllocationDiff; warnings: readonly MultiQafWarning[] } {
  const findings: VariantAllocationFinding[] = []

  for (const { alt: altRow, neu: neuRow } of pairs) {
    const reviewRelevant = altRow.validationStatus !== 'ok' || neuRow.validationStatus !== 'ok'
    // Row-level constant (does not vary per variant) — see
    // `effectiveCostDeltaReason` doc. Adversarial-review fix (KAR-938/#310
    // F2): computeSharedImpact (11.1) already refuses a cross-currency delta;
    // this row-pair's effective-cost delta (11.2, below) must honor the SAME
    // Mixed-Currency-Doktrin gate instead of feeding raw altEffectiveCost/
    // neuEffectiveCost — which are expressed in altRow.unitCost.currency /
    // neuRow.unitCost.currency respectively — straight into computeNumericDelta.
    const rowCurrencyOk = altRow.unitCost.currency !== null && altRow.unitCost.currency === neuRow.unitCost.currency
    for (const mv of matchedVariantPairs) {
      const altHas = mv.leftId in altRow.quantityFactorByVariant
      const neuHas = mv.rightId in neuRow.quantityFactorByVariant
      if (!altHas && !neuHas) continue

      const altFactor = altHas ? altRow.quantityFactorByVariant[mv.leftId]! : null
      const neuFactor = neuHas ? neuRow.quantityFactorByVariant[mv.rightId]! : null

      let kind: VariantAllocationChangeKind | null = null
      if (!altHas && neuHas) kind = 'included'
      else if (altHas && !neuHas) kind = 'excluded'
      else if (altHas && neuHas && altFactor !== neuFactor) kind = 'factor_changed'
      if (kind === null) continue

      const altEffectiveCost = altHas ? (altRow.effectiveCostByVariant[mv.leftId] ?? null) : null
      const neuEffectiveCost = neuHas ? (neuRow.effectiveCostByVariant[mv.rightId] ?? null) : null
      // Only `factor_changed` (both sides applicable) can ever reach a
      // fabricated cross-currency delta — `included`/`excluded` are already
      // single-sided (computeNumericDelta returns a null delta for those on
      // its own). Gate ONLY that case; leave single-sided kinds untouched.
      const bothApplicable = altHas && neuHas
      const effectiveCostCurrencyOk = !bothApplicable || rowCurrencyOk
      const effDelta = effectiveCostCurrencyOk
        ? computeNumericDelta(altEffectiveCost, neuEffectiveCost, config.bands)
        : ({ deltaAbsolute: null, deltaPercent: null, status: 'nicht_berechenbar' } as const)

      findings.push({
        kind,
        canonicalComponentIdentity: neuRow.canonicalComponentIdentity,
        altVariantId: mv.leftId,
        neuVariantId: mv.rightId,
        altFactor,
        neuFactor,
        altApplicable: altHas,
        neuApplicable: neuHas,
        altEffectiveCost,
        neuEffectiveCost,
        effectiveCostDeltaAbsolute: effDelta.deltaAbsolute,
        effectiveCostDeltaPercent: effDelta.deltaPercent,
        effectiveCostStatus: effDelta.status,
        ...(effectiveCostCurrencyOk ? {} : { effectiveCostDeltaReason: 'mixed_currency' as const }),
        alt: toComponentRef(altRow),
        neu: toComponentRef(neuRow),
        reviewRelevant: reviewRelevant || !effectiveCostCurrencyOk,
      })
    }
  }

  for (const neuRow of neuOnly) {
    for (const mv of matchedVariantPairs) {
      if (!(mv.rightId in neuRow.quantityFactorByVariant)) continue
      const neuFactor = neuRow.quantityFactorByVariant[mv.rightId]!
      const neuEffectiveCost = neuRow.effectiveCostByVariant[mv.rightId] ?? null
      const effDelta = computeNumericDelta(null, neuEffectiveCost, config.bands)
      findings.push({
        kind: 'component_added',
        canonicalComponentIdentity: neuRow.canonicalComponentIdentity,
        altVariantId: mv.leftId,
        neuVariantId: mv.rightId,
        altFactor: null,
        neuFactor,
        altApplicable: false,
        neuApplicable: true,
        altEffectiveCost: null,
        neuEffectiveCost,
        effectiveCostDeltaAbsolute: effDelta.deltaAbsolute,
        effectiveCostDeltaPercent: effDelta.deltaPercent,
        effectiveCostStatus: effDelta.status,
        alt: null,
        neu: toComponentRef(neuRow),
        reviewRelevant: neuRow.validationStatus !== 'ok',
      })
    }
  }

  for (const altRow of altOnly) {
    for (const mv of matchedVariantPairs) {
      if (!(mv.leftId in altRow.quantityFactorByVariant)) continue
      const altFactor = altRow.quantityFactorByVariant[mv.leftId]!
      const altEffectiveCost = altRow.effectiveCostByVariant[mv.leftId] ?? null
      const effDelta = computeNumericDelta(altEffectiveCost, null, config.bands)
      findings.push({
        kind: 'component_removed',
        canonicalComponentIdentity: altRow.canonicalComponentIdentity,
        altVariantId: mv.leftId,
        neuVariantId: mv.rightId,
        altFactor,
        neuFactor: null,
        altApplicable: true,
        neuApplicable: false,
        altEffectiveCost,
        neuEffectiveCost: null,
        effectiveCostDeltaAbsolute: effDelta.deltaAbsolute,
        effectiveCostDeltaPercent: effDelta.deltaPercent,
        effectiveCostStatus: effDelta.status,
        alt: toComponentRef(altRow),
        neu: null,
        reviewRelevant: altRow.validationStatus !== 'ok',
      })
    }
  }

  findings.sort(
    (a, b) =>
      a.canonicalComponentIdentity.localeCompare(b.canonicalComponentIdentity) ||
      a.neuVariantId.localeCompare(b.neuVariantId) ||
      a.altVariantId.localeCompare(b.altVariantId) ||
      a.kind.localeCompare(b.kind),
  )

  const { findings: substitutionsSuspected, warnings: substitutionWarnings } = detectSubstitutions(
    altOnly,
    neuOnly,
    matchedVariantPairs,
    config,
  )

  return { diff: { findings, substitutionsSuspected }, warnings: substitutionWarnings }
}

// ── Mixed-currency warnings (post-scan — see module header) ────────────────

/** Emits the two currency-related warning families (adversarial review
 * KAR-938/#310 F3) — `mixed_currency` (a genuine ALT->NEU currency SWAP, both
 * sides non-null and different) and `currency_unknown` (currency missing on
 * at least one side — never a swap, kept as a SEPARATE code/message so a
 * caller/reader can never mistake "we never captured the currency" for "the
 * currency changed"). */
function scanCurrencyImpactWarnings(
  findings: readonly { canonicalComponentIdentity: string; impact: SharedComponentImpactSummary; alt: MaterialComponentRef; neu: MaterialComponentRef }[],
  fieldLabelDe: string,
  fieldLabelEn: string,
): MultiQafWarning[] {
  const out: MultiQafWarning[] = []
  for (const f of findings) {
    const mixedVariantIds = f.impact.impacts.filter((i) => i.reason === 'mixed_currency').map((i) => i.variantId)
    if (mixedVariantIds.length > 0) {
      out.push({
        code: 'mixed_currency_material_impact',
        severity: 'warning',
        message: `Materialzeile "${f.canonicalComponentIdentity}" (${fieldLabelDe}): Währungswechsel zwischen ALT und NEU verhindert eine belastbare Kosten-Impact-Berechnung für ${mixedVariantIds.length} Variante(n) — kein Impact fabriziert.`,
        messageEn: `Material row "${f.canonicalComponentIdentity}" (${fieldLabelEn}): a currency change between ALT and NEU prevents a reliable cost-impact calculation for ${mixedVariantIds.length} variant(s) — no impact fabricated.`,
        sourceReferences: [...f.alt.sourceCells, ...f.neu.sourceCells],
        variantIds: mixedVariantIds,
        reviewRelevant: true,
      })
    }

    const unknownVariantIds = f.impact.impacts.filter((i) => i.reason === 'currency_unknown').map((i) => i.variantId)
    if (unknownVariantIds.length > 0) {
      out.push({
        code: 'currency_unknown_material_impact',
        severity: 'warning',
        message: `Materialzeile "${f.canonicalComponentIdentity}" (${fieldLabelDe}): Währung nicht erfasst — Impact nicht berechenbar für ${unknownVariantIds.length} Variante(n) (kein Währungswechsel, die Daten fehlen schlicht).`,
        messageEn: `Material row "${f.canonicalComponentIdentity}" (${fieldLabelEn}): currency not captured — impact not computable for ${unknownVariantIds.length} variant(s) (not a currency change, the data is simply missing).`,
        sourceReferences: [...f.alt.sourceCells, ...f.neu.sourceCells],
        variantIds: unknownVariantIds,
        reviewRelevant: true,
      })
    }
  }
  return out
}

function buildWarnings(diff: MaterialSharedComponentDiff, extra: readonly MultiQafWarning[]): MultiQafWarning[] {
  return [
    ...scanCurrencyImpactWarnings(diff.unitCostValueChanges, 'Einheitskosten', 'unit cost'),
    ...scanCurrencyImpactWarnings(diff.logisticsOrDutyChanges, 'Transport/Zoll', 'logistics/duty'),
    ...scanCurrencyImpactWarnings(diff.materialOverheadChanges, 'MGK', 'material overhead'),
    ...extra,
  ].sort((a, b) => a.message.localeCompare(b.message))
}

// ── diffMaterial ─────────────────────────────────────────────────────────

function isUncertainKind(
  r: VariantMatchResult,
): r is VariantAmbiguousResult | VariantSplitSuspectedResult | VariantMergeSuspectedResult {
  return r.kind === 'ambiguous' || r.kind === 'split_suspected' || r.kind === 'merge_suspected'
}

/**
 * Two-level material comparison (Master-Prompt §11) of two already-matched
 * Multi-QAF containers' `sharedMaterialMaster`. `matchResult` must have been
 * computed by `matchVariants`/`matchVariantsWithOverrides` (variant-
 * matcher.ts) with LEFT drawn from `alt`'s own variants and RIGHT drawn from
 * `neu`'s own variants — same contract container-differ.ts's `diffContainers`
 * already requires; an id that resolves to nothing in a row's
 * `quantityFactorByVariant`/`effectiveCostByVariant` simply never surfaces
 * as "applicable" (no throw — those records are honestly sparse by design,
 * see types.ts's own VariantMatrixRow doc: "a variant with NO entry... means
 * 'not applicable'").
 */
export function diffMaterial(
  alt: MultiQafContainer,
  neu: MultiQafContainer,
  matchResult: readonly VariantMatchResult[],
  options: MaterialDiffOptions = {},
): MaterialDiffResult {
  const config = options.config ?? DEFAULT_MATERIAL_DIFFER_CONFIG

  const matchedVariantPairs = matchResult.filter((r): r is VariantMatchedResult => r.kind === 'matched')
  const uncertainMatches = matchResult.filter(isUncertainKind)
  const altToNeu = new Map<string, string>(matchedVariantPairs.map((r) => [r.leftId, r.rightId] as const))

  const { pairs, altOnly, neuOnly } = pairRows(alt, neu, config)

  const sharedComponents = diffSharedComponents(pairs, altToNeu, config)
  const { diff: variantAllocation, warnings: substitutionWarnings } = diffVariantAllocation(
    pairs,
    altOnly,
    neuOnly,
    matchedVariantPairs,
    config,
  )
  const warnings = buildWarnings(sharedComponents, substitutionWarnings)

  return { sharedComponents, variantAllocation, uncertainMatches, warnings }
}
