// Multi-QAF Aggregat-Impact mit Validitäts-Gates (KAR-944 / Multi-QAF-
// Programm P3.3, Epic KAR-925 — the LAST P3 item).
//
// Problem this closes (30-backlog-phasenplan.md P3.3 + Master-Prompt §19):
// "Only calculate an aggregate Multi-QAF impact when the basis is valid.
// Check: compatible currencies, available volumes, non-duplicated variants,
// clear variant matches, consistent units, valid baseline, no blocked
// critical mappings. Clearly distinguish: unit-price delta per variant,
// annual impact per variant, lifetime impact per variant, aggregate annual
// impact, aggregate lifetime impact, unweighted structural comparison. Do
// not aggregate unmatched or uncertain variants silently. Show the
// assumptions and included variant population."
//
// Every prior P2/P3 module (material-differ.ts/profile-differ.ts) already
// computes a per-finding, currency-gated commercial impact — this module
// never re-derives those numbers, it only SUMS them across a variant
// population that has cleared six explicit gates, and reports the sum
// PER CURRENCY (never cross-currency), PER TIMEFRAME (annual/lifetime,
// never conflated), with the excluded population and the reason for every
// exclusion.
//
// ── Gate design (six gates, each individually evaluated and reported in
// `gates`, never silently folded into a single boolean) ────────────────────
//   compatible_currencies       — no included variant had a material/
//                                  profile contribution DROPPED because its
//                                  own currency gate (material-differ.ts's
//                                  `mixed_currency`/`currency_unknown`
//                                  VariantImpact.reason, profile-differ.ts's
//                                  `ProfileBindingCurrencyState`, or this
//                                  module's own ALT/NEU
//                                  VariantDefinition.currency check for item
//                                  2) fired. A variant with SOME contribution
//                                  dropped for this reason still stays in the
//                                  population with whatever OTHER
//                                  currency-bucketed contributions it does
//                                  have — this gate documents the gap, it
//                                  never removes the variant (the buckets
//                                  themselves already guarantee no
//                                  cross-currency addition ever happens).
//   volumes_available           — every population variant carries BOTH
//                                  `annualVolume` and `lifetimeVolume`
//                                  (NEU side — see "Which side's volume"
//                                  below). KAR-940's own module header
//                                  documents that real files often have
//                                  missing volume data; a variant missing
//                                  EITHER metric is excluded from that ONE
//                                  timeframe's aggregate (never both, unless
//                                  both are actually missing) — see
//                                  `excludedVariants`.
//   no_duplicates_or_ambiguous  — no `ambiguous`/`split_suspected`/
//                                  `merge_suspected` matchResult entry
//                                  touches a variant in the base population
//                                  (KAR-936 classes, variant-matcher.ts).
//                                  Structurally guaranteed by construction
//                                  (this module only ever reads `matched`
//                                  entries into the base population) — this
//                                  gate exists to make the EXCLUSION visible,
//                                  not to filter anything itself.
//   consistent_units             — reuses variant-reconciliation.ts's own
//                                  `currency` check (`VariantDefinition.
//                                  currency` vs. the variant's own material-
//                                  row currency, KAR-940/P2.6) rather than
//                                  inventing a second unit-consistency
//                                  signal: an `'abweichung'` on EITHER side
//                                  of a matched pair means that variant's own
//                                  currency label cannot be trusted as the
//                                  bucket key for its profile-derived
//                                  contributions (item 2/4 below), so the
//                                  pair is excluded from the population
//                                  entirely (not merely from one component,
//                                  reason `inconsistent_units`). KAR-944
//                                  adversarial review F1 fix: a
//                                  `'nicht_pruefbar'` currency check whose
//                                  `nichtPruefbarReason` is
//                                  `'fingerprint_unavailable'` (the
//                                  container's own `templateFingerprint.
//                                  structuralHash` is `null`, so variant-
//                                  reconciliation.ts's fail-closed identity
//                                  guard could not even ATTEMPT the check —
//                                  `matchToContainer`) or `'unmatched_variant'`
//                                  (the guard positively rejected the
//                                  pairing) is FAIL-CLOSED, not a silent
//                                  pass — it excludes the pair too (reason
//                                  `consistent_units_not_verifiable`,
//                                  distinct from `inconsistent_units` so
//                                  "checked and wrong" stays visibly
//                                  different from "could not be checked").
//                                  "Not verified" is never treated as
//                                  "verified ok" (Master-Prompt §19: never
//                                  silently aggregate the uncertain). The
//                                  other two `nicht_pruefbar` reasons this
//                                  same check can report — `'mixed_currency'`
//                                  (the Buckets-Doktrin's own normal case)
//                                  and `'fehlende_daten'` (a variant simply
//                                  has no material rows at all) — are NOT
//                                  this bug and stay excluded from this
//                                  fail-closed rule (see
//                                  `IDENTITY_GUARD_NICHT_PRUEFBAR_REASONS`'s
//                                  own comment for why).
//   valid_baseline                — both containers free of their OWN
//                                  review-relevant/critical warnings
//                                  (`reviewStatusForMultiQafContainer`,
//                                  reflected here via the caller-supplied
//                                  `inputs.reviewRequired`) — Master-Prompt
//                                  §19's "valid baseline" bullet. A failing
//                                  baseline suppresses ONLY the SUMMED
//                                  `annual.aggregate`/`lifetime.aggregate`
//                                  figures (population forced empty, with an
//                                  `aggregate_suppressed_baseline_invalid`
//                                  assumption) — the per-variant unit-price/
//                                  annual/lifetime tables and the structural
//                                  fallback stay populated regardless (§19
//                                  "getrennte Ausweise" are informational
//                                  facts about individual variants, not the
//                                  trusted container-wide total this gate
//                                  protects). `options.
//                                  acknowledgeReviewRequired: true` computes
//                                  the summed aggregate anyway ("trotz
//                                  Review") but the RESULT's own top-level
//                                  `reviewRequired` stays `true` regardless —
//                                  acknowledging computes the number, it
//                                  never silences the review signal.
//   no_blocked_critical_mappings — a matched pair referencing a variant a
//                                  container warning marks with one of
//                                  `BLOCKED_CRITICAL_MAPPING_CODES` (profile
//                                  binding unresolved/ambiguous, canonical-
//                                  key collision, a material-matrix row-
//                                  identity collision, a fuzzy material match
//                                  below the aggregation-gate) is excluded —
//                                  Master-Prompt §19 "no blocked critical
//                                  mappings". Code membership in
//                                  `BLOCKED_CRITICAL_MAPPING_CODES` is
//                                  sufficient BY ITSELF (KAR-944 adversarial
//                                  review F2 fix — no producer in this
//                                  package has ever set `severity:
//                                  'critical'`, and the real
//                                  `variant_profile_binding_unresolved`/
//                                  `material_matrix_row_identity_collision`
//                                  producers never set `reviewRelevant`
//                                  either, so the old severity/reviewRelevant
//                                  pre-filter silently dropped both codes on
//                                  every real path). `reviewRelevant ===
//                                  true` is a SEPARATE, generic criterion,
//                                  independent of `code` — so a future
//                                  producer's ambiguous-mapping flag blocks
//                                  automatically without this module's own
//                                  list needing a matching update first.
//
// ── Which side's volume/currency? (NEU, documented, never silently ALT) ────
// `VariantDefinition.annualVolume`/`lifetimeVolume`/`currency` are read from
// the NEU side throughout — the forward-looking, current-state figures a
// commercial-impact reader cares about. A genuine ALT<->NEU volume or
// currency CHANGE is already its own separate, dedicated finding elsewhere
// (container-differ.ts's `activeStateChanges`/dimension diffs, the
// `consistent_units` gate's currency check) — this module never re-derives
// that comparison, it only picks ONE side as the scaling/bucketing basis and
// says so explicitly (this comment + the `assumptions[]` entry
// `'volume_and_currency_basis'`).
//
// ── Sign convention (IDENTICAL to every other differ in this package — never
// re-derived, never inverted) ───────────────────────────────────────────────
// Every number this module sums is already `NEU − ALT` (differ.ts's
// `computeNumericDelta`, material-differ.ts's `factor * deltaAbsolute`,
// profile-differ.ts's `deltaAbsolute`) — this module performs NO sign
// manipulation of its own. Positive = Verteuerung (a cost increase from ALT
// to NEU); negative = Ersparnis (a cost decrease). A caller scaling by
// volume (`delta * annualVolume`/`delta * lifetimeVolume`) preserves this
// sign unchanged.
//
// ── "material-differ-Impacts" scope (KAR-944 task spec, deliberately
// narrower than material-differ.ts's full output) ───────────────────────────
// Only the THREE 11.1 shared-component finding groups that ever carry a
// non-empty `impact.impacts` array are summed here: `unitCostValueChanges`,
// `logisticsOrDutyChanges`, `materialOverheadChanges` (material-differ.ts's
// own doc: `unitCostCurrencyChanges`/`exchangeRateChanges`/`formulaChanges`
// deliberately carry an affected-variant list ONLY, no numeric impact — see
// that module's own field docs for why). 11.2 `variantAllocation.findings`
// (quantity-factor/inclusion changes) are NOT folded in — the task spec
// names "material-differ-Impacts" (the `SharedComponentImpactSummary`-shaped
// 11.1 numbers), not the 11.2 allocation deltas, and conflating the two would
// contradict material-differ.ts's own "two levels, two separate finding
// groups (never conflated)" doctrine. A finding whose OWN `reviewRelevant` is
// `true` (its underlying row is not `validationStatus: 'ok'`) is excluded
// from the sum — its numbers are shown on the finding itself already, never
// rolled into a trusted total (material-differ.ts's own
// `withReviewRelevantImpact` doctrine, mirrored here one level up).
//
// ── "Fertigungs-Deltas aus profile-differ Item-2/4" scope ──────────────────
// Item 2 (`totalChanges`, a shared profile's own total moved) and item 4
// (`bindingValueImpacts`, a variant's binding moved or its bound profile's
// total moved together with a rebind) — never item 1 (`componentValueChanges`,
// a component-level process-parameter note with no volume-scaled money
// figure of its own) and never item 3 (volume-band thresholds, #309's job,
// not a per-variant money delta). `SharedCostProfile.values` carries NO
// currency field (profile-differ.ts's own module header) — item 2's
// per-variant contribution therefore uses the variant's OWN
// `VariantDefinition.currency` (ALT vs. NEU, gated: differing/unknown ->
// excluded, folded into `compatible_currencies`) as its bucket key, exactly
// mirroring item 4's own `ProfileBindingCurrencyState` gate (which already
// reads `VariantDefinition.currency` for the identical reason).
//
// tdd-guard: covered by __tests__/aggregate-impact.test.ts (synthetic, one
// scenario per gate pass/fail + per-currency bucketing + volume-missing
// exclusion + ambiguous-exclusion + sign-direction + structural fallback)
// and __tests__/aggregate-impact.real-files.test.ts (env-gated: Datei 3 vs.
// Datei 4 empty-aggregate-with-reason, self-compare full-population-zero).

import type { MultiQafContainer, VariantDefinition } from './types'
import { allMultiQafContainerVariants, type MultiQafContainerDiff } from './container-differ'
import { round4 } from '../differ'
import type { MaterialDiffResult } from './material-differ'
import type { ProfileDiffResult } from './profile-differ'
import type { VariantMatchResult } from './variant-matcher'
import type { VariantReconciliationCheck, VariantReconciliationNichtPruefbarReason, VariantReconciliationResult } from './variant-reconciliation'

export const AGGREGATE_IMPACT_RESULT_VERSION = 1 as const
// round4 imported from ../differ (KAR-944 adversarial review F3 — was this
// module's own private copy, the FOURTH one across differ.ts/material-
// differ.ts/profile-differ.ts/aggregate-impact.ts; see differ.ts's own doc
// comment on round4 for why variant-reconciliation.ts's copy is unaffected).

// ── Blocked-critical-mapping warning codes (Master-Prompt §19 "no blocked
// critical mappings") — every code across the multi-qaf/ package a producer
// currently emits for an unresolved/ambiguous/colliding MAPPING (as opposed
// to a mere value change or a structural/lineage observation). Extend only
// with real evidence, same discipline every other named-code list in this
// package follows (e.g. material-differ.ts's own warning `code` vocabulary). ─
const BLOCKED_CRITICAL_MAPPING_CODES: ReadonlySet<string> = new Set([
  'variant_profile_binding_unresolved',
  'ambiguous_literal_profile_binding',
  'canonical_key_collision',
  'material_matrix_row_identity_collision',
  'material_variant_fuzzy_match_below_aggregation_gate',
])

// ── `nicht_pruefbar` reasons that mean "verification could not even be
// ATTEMPTED" (KAR-944 adversarial review F1) — variant-reconciliation.ts's
// fail-closed `matchToContainer` identity guard, see its own module header
// ("Fail-closed: reconcileVirtualVariant refuses to reconcile a variant that
// does not verifiably originate from the given container"). Deliberately
// EXCLUDES `'mixed_currency'` (the Buckets-Doktrin's own normal case — a
// variant's material rows genuinely span two currencies, already handled
// correctly by per-currency bucketing, not a reason to drop the variant) and
// `'fehlende_daten'` (routine — a variant simply has no material rows at
// all, e.g. an entirely profile-cost-driven variant) — see the
// `consistent_units` gate's own comment for why a blanket "every
// nicht_pruefbar excludes" broke legitimate, already-tested scenarios. ─────
const IDENTITY_GUARD_NICHT_PRUEFBAR_REASONS: ReadonlySet<VariantReconciliationNichtPruefbarReason> = new Set([
  'fingerprint_unavailable',
  'unmatched_variant',
])

// ── Gates ────────────────────────────────────────────────────────────────

export type AggregateImpactGateId =
  | 'compatible_currencies'
  | 'volumes_available'
  | 'no_duplicates_or_ambiguous'
  | 'consistent_units'
  | 'valid_baseline'
  | 'no_blocked_critical_mappings'

export interface AggregateImpactGateOutcome {
  gate: AggregateImpactGateId
  passed: boolean
  messageDe: string
  messageEn: string
  /** NEU-side variant ids (or, for a container-wide gate like
   * `valid_baseline`, an empty array) this gate's outcome concerns. */
  affectedVariantIds: readonly string[]
}

// ── Exclusion transparency (Master-Prompt §19 "show ... the included variant
// population" — the complement, WHO was excluded and WHY, every reason its
// own entry so a variant excluded for two independent reasons shows both). ──

export type AggregateImpactExclusionReason =
  | 'ambiguous_or_uncertain_match'
  | 'blocked_critical_mapping'
  | 'inconsistent_units'
  /** KAR-944 adversarial review F1: the currency reconciliation check
   * (variant-reconciliation.ts, checkId `currency`) reported
   * `nicht_pruefbar` for this variant (either side) — e.g. its own
   * container's `templateFingerprint.structuralHash` is `null`, so the
   * fail-closed identity guard could not even attempt the check
   * (`nichtPruefbarReason: 'fingerprint_unavailable'`), or the currency
   * comparison was otherwise not possible (`'unmatched_variant'`/
   * `'mixed_currency'`/`'fehlende_daten'`). "Not verified" is NOT "verified
   * ok" — silently treating an un-attempted check as a pass would let an
   * unverified variant's currency label be trusted as a bucket key
   * (Master-Prompt §19 "never silently aggregate the uncertain"). Excluded
   * exactly like a genuine `'abweichung'` (`inconsistent_units`), under its
   * own distinct reason so a caller can tell "checked and wrong" apart from
   * "could not be checked". */
  | 'consistent_units_not_verifiable'
  | 'volume_missing_annual'
  | 'volume_missing_lifetime'

export interface AggregateImpactExclusion {
  variantId: string
  reason: AggregateImpactExclusionReason
  messageDe: string
  messageEn: string
}

// ── Per-variant unit-price delta (§19 "unit-price delta per variant") ──────

export type VariantUnitPriceDeltaSource = 'material_shared_component' | 'profile_total_change' | 'profile_binding_value_impact'

export interface VariantUnitPriceDeltaComponent {
  source: VariantUnitPriceDeltaSource
  currency: string
  /** NEU − ALT, see module header "Sign convention". */
  delta: number
}

export interface CurrencyBucket {
  currency: string
  /** NEU − ALT, see module header "Sign convention". */
  delta: number
}

export interface VariantUnitPriceDelta {
  variantId: string
  altVariantId: string
  /** Per-currency buckets — NEVER summed across currencies (Buckets-
   * Doktrin, material-differ.ts precedent). An empty array means every
   * contribution was either zero/unchanged or excluded (see
   * `excludedVariants`/`gates.compatible_currencies`) — NOT "not computed". */
  byCurrency: readonly CurrencyBucket[]
  components: readonly VariantUnitPriceDeltaComponent[]
}

export interface VariantTimeframeImpact {
  variantId: string
  altVariantId: string
  /** `delta * annualVolume` / `delta * lifetimeVolume` per currency bucket. */
  byCurrency: readonly CurrencyBucket[]
}

export interface AggregateImpactCurrencyTotal {
  currency: string
  totalImpact: number
  /** Count of population variants that contributed a non-zero-eligible entry
   * to THIS currency's total (mirrors material-differ.ts's
   * `MaterialImpactAggregate.variantCount`). */
  variantCount: number
}

export interface AggregateImpactTimeframe {
  /** NEU-side variant ids included in `aggregate` below — Master-Prompt §19
   * "show ... the included variant population", explicit per timeframe
   * (annual/lifetime populations can differ when only one volume metric is
   * missing for a given variant). Empty when `valid_baseline` failed and was
   * not acknowledged (see `gates`/`assumptions`), even if `perVariant` below
   * is non-empty. */
  population: readonly string[]
  perVariant: readonly VariantTimeframeImpact[]
  /** Per-currency sums over `population` only. Empty (not fabricated as a
   * zero) when nothing was computable. */
  aggregate: readonly AggregateImpactCurrencyTotal[]
}

// ── Unweighted structural fallback (§19 "unweighted structural comparison",
// always available regardless of any gate — counts only, container-differ.ts
// already computed every one of these). ─────────────────────────────────────

export interface AggregateImpactStructuralFallback {
  variantsAdded: number
  variantsRemoved: number
  variantsRenamed: number
  variantsReordered: number
  variantsActiveStateChanged: number
  uncertainMatches: number
  materialRowsAdded: number
  materialRowsRemoved: number
  materialRowsChanged: number
  profilesAdded: number
  profilesRemoved: number
  volumeBandThresholdChanges: number
  profileBindingChanges: number
}

export interface AggregateImpactAssumption {
  code: string
  messageDe: string
  messageEn: string
}

export interface AggregateImpactResult {
  modelVersion: typeof AGGREGATE_IMPACT_RESULT_VERSION
  gates: readonly AggregateImpactGateOutcome[]
  gatesPassed: readonly AggregateImpactGateId[]
  gatesFailed: readonly AggregateImpactGateId[]
  /** Lifted verbatim from `inputs.reviewRequired` — computing the aggregate
   * despite a failed `valid_baseline` gate (`options.
   * acknowledgeReviewRequired`) never clears this; it only unblocks the
   * summed figures. */
  reviewRequired: boolean
  excludedVariants: readonly AggregateImpactExclusion[]
  unitPriceDeltas: readonly VariantUnitPriceDelta[]
  annual: AggregateImpactTimeframe
  lifetime: AggregateImpactTimeframe
  structuralFallback: AggregateImpactStructuralFallback
  assumptions: readonly AggregateImpactAssumption[]
}

// ── Inputs (structurally compatible with — but NOT importing —
// compare-flow.ts's MultiQafComparisonResult, to avoid a circular import;
// compare-flow.ts passes its own already-computed locals directly, which
// satisfy this shape by construction). "computeAggregateImpact(comparisonResult
// | Bausteine)" (KAR-944 task spec) is realized exactly this way: a caller
// holding a full MultiQafComparisonResult can pass it here unchanged (extra
// fields are ignored by TypeScript's structural typing), or assemble this
// smaller shape directly from individually-held pieces. ────────────────────

export interface AggregateImpactInputs {
  matchResult: readonly VariantMatchResult[]
  materialDiff: MaterialDiffResult
  profileDiff: ProfileDiffResult
  containerDiff: MultiQafContainerDiff
  reconciliation: {
    alt: readonly VariantReconciliationResult[]
    neu: readonly VariantReconciliationResult[]
  }
  reviewRequired: boolean
  reviewRequiredReasons: readonly string[]
}

export interface AggregateImpactOptions {
  /** Master-Prompt §19 "valid baseline" escape hatch — task spec "explizit
   * als 'trotz Review' markiert". When `true` and `valid_baseline` would
   * otherwise fail, the summed `annual.aggregate`/`lifetime.aggregate`
   * figures ARE computed, but `AggregateImpactResult.reviewRequired` stays
   * `true` regardless (see that field's own doc). Default `false`. */
  acknowledgeReviewRequired?: boolean
}

// ── Small local helpers ─────────────────────────────────────────────────

function isUncertain(r: VariantMatchResult): r is Extract<VariantMatchResult, { kind: 'ambiguous' | 'split_suspected' | 'merge_suspected' }> {
  return r.kind === 'ambiguous' || r.kind === 'split_suspected' || r.kind === 'merge_suspected'
}

function uncertainVariantIds(results: readonly VariantMatchResult[]): Set<string> {
  const out = new Set<string>()
  for (const r of results) {
    if (r.kind === 'ambiguous') {
      r.leftIds.forEach((id) => out.add(id))
      r.rightIds.forEach((id) => out.add(id))
    } else if (r.kind === 'split_suspected') {
      out.add(r.leftId)
      r.rightIds.forEach((id) => out.add(id))
    } else if (r.kind === 'merge_suspected') {
      out.add(r.rightId)
      r.leftIds.forEach((id) => out.add(id))
    }
  }
  return out
}

/** Full `currency` check entry (not just `.status`) — KAR-944 adversarial
 * review F1 needs `nichtPruefbarReason` too, to report WHY a `nicht_pruefbar`
 * currency check could not verify the variant's own currency label (see
 * `AggregateImpactExclusionReason.consistent_units_not_verifiable`). */
function currencyCheckEntry(results: readonly VariantReconciliationResult[], variantId: string): VariantReconciliationCheck | null {
  const entry = results.find((r) => r.variantId === variantId)
  if (!entry) return null
  return entry.checks.find((c) => c.checkId === 'currency') ?? null
}

interface EligiblePair {
  altId: string
  neuId: string
  altVariant: VariantDefinition
  neuVariant: VariantDefinition
}

function addToBucket(map: Map<string, { currency: string; delta: number }>, currency: string, delta: number): void {
  const existing = map.get(currency)
  if (existing) {
    existing.delta = round4(existing.delta + delta)
  } else {
    map.set(currency, { currency, delta: round4(delta) })
  }
}

// ── computeAggregateImpact ───────────────────────────────────────────────

export function computeAggregateImpact(
  alt: MultiQafContainer,
  neu: MultiQafContainer,
  inputs: AggregateImpactInputs,
  options: AggregateImpactOptions = {},
): AggregateImpactResult {
  const { matchResult, materialDiff, profileDiff, containerDiff } = inputs

  const matchedPairsRaw = matchResult.filter((r): r is Extract<VariantMatchResult, { kind: 'matched' }> => r.kind === 'matched')
  const uncertain = matchResult.filter(isUncertain)
  const uncertainIds = uncertainVariantIds(matchResult)

  const excludedVariants: AggregateImpactExclusion[] = []
  for (const id of [...uncertainIds].sort()) {
    excludedVariants.push({
      variantId: id,
      reason: 'ambiguous_or_uncertain_match',
      messageDe: 'Variante ist Teil eines ambiguous/split/merge-Befunds (variant-matcher.ts) — keine automatische Zuordnung, nie aggregiert (Master-Prompt §9).',
      messageEn: 'The variant is part of an ambiguous/split/merge finding (variant-matcher.ts) — no automatic pairing, never aggregated (Master-Prompt §9).',
    })
  }

  const gateNoDuplicates: AggregateImpactGateOutcome = {
    gate: 'no_duplicates_or_ambiguous',
    passed: uncertain.length === 0,
    messageDe:
      uncertain.length === 0
        ? 'Keine ambiguous/split/merge-Varianten im Match-Ergebnis.'
        : `${uncertain.length} ambiguous/split/merge-Befund(e) — betroffene Varianten sind von der Aggregation ausgeschlossen.`,
    messageEn:
      uncertain.length === 0
        ? 'No ambiguous/split/merge variants in the match result.'
        : `${uncertain.length} ambiguous/split/merge finding(s) — affected variants are excluded from aggregation.`,
    affectedVariantIds: [...uncertainIds].sort(),
  }

  // ── no_blocked_critical_mappings ──────────────────────────────────────
  // KAR-944 adversarial review F2 fix: the previous pre-filter
  // (`severity !== 'critical' && reviewRelevant !== true -> skip`) was a
  // FAKE gate on every real path — no producer in this package has ever set
  // `severity: 'critical'` (see MultiQafWarning.reviewRelevant's own doc
  // comment in types.ts), and the two structurally most important blocked-
  // mapping codes — `variant_profile_binding_unresolved`
  // (profile-parser.ts's `unresolvedBindingWarning`) and
  // `material_matrix_row_identity_collision` (material-matrix-parser.ts) —
  // never set `reviewRelevant` either. The old unit test only "passed"
  // because it manually set `reviewRelevant: true` on a fixture warning no
  // real producer ever constructs that way, masking the gap.
  //
  // Fix, structural instead of doubly filtered: a warning whose `code` is in
  // BLOCKED_CRITICAL_MAPPING_CODES is blocking BY CODE MEMBERSHIP ALONE —
  // the list itself is the criterion, no severity/reviewRelevant pre-filter
  // on top of it. `reviewRelevant === true` stays as a SEPARATE, generic
  // criterion, independent of `code`, so a future producer that flags a
  // genuinely ambiguous/uncertain mapping via `reviewRelevant` blocks
  // automatically without this module's own named-code list needing an
  // update first (this also newly catches
  // `material_variant_unmatched_to_summary`/
  // `material_matrix_variant_never_referenced`/
  // `material_variant_fuzzy_matched_to_summary` — container-assembly.ts's
  // own `reviewRelevant: true` material-identity warnings, the same class of
  // "don't trust this variant's mapping" problem this gate exists for, that
  // the old pre-filter accidentally let through unless a code was ALSO
  // critical).
  const blockedVariantIds = new Set<string>()
  for (const w of [...alt.warnings, ...neu.warnings]) {
    const isBlockedMappingCode = BLOCKED_CRITICAL_MAPPING_CODES.has(w.code)
    const isGenericReviewRelevant = w.reviewRelevant === true
    if (!isBlockedMappingCode && !isGenericReviewRelevant) continue
    for (const vid of w.variantIds ?? []) blockedVariantIds.add(vid)
  }
  const blockedPairs = matchedPairsRaw.filter((p) => blockedVariantIds.has(p.leftId) || blockedVariantIds.has(p.rightId))
  for (const p of blockedPairs) {
    excludedVariants.push({
      variantId: p.rightId,
      reason: 'blocked_critical_mapping',
      messageDe: 'Variante referenziert eine geblockte kritische Mapping (unresolved/ambiguous Profilbindung, Canonical-Key-Kollision, Material-Zeilen-Identitätskollision oder fuzzy-Match unter dem Aggregations-Gate).',
      messageEn: 'The variant references a blocked critical mapping (unresolved/ambiguous profile binding, canonical-key collision, material-row identity collision, or a fuzzy match below the aggregation gate).',
    })
  }
  const gateNoBlockedMappings: AggregateImpactGateOutcome = {
    gate: 'no_blocked_critical_mappings',
    passed: blockedPairs.length === 0,
    messageDe:
      blockedPairs.length === 0
        ? 'Keine geblockten kritischen Mappings unter den gematchten Varianten.'
        : `${blockedPairs.length} gematchte Variante(n) referenzieren eine geblockte kritische Mapping — ausgeschlossen.`,
    messageEn:
      blockedPairs.length === 0
        ? 'No blocked critical mappings among the matched variants.'
        : `${blockedPairs.length} matched variant(s) reference a blocked critical mapping — excluded.`,
    affectedVariantIds: blockedPairs.map((p) => p.rightId).sort(),
  }

  // ── consistent_units (reuses variant-reconciliation.ts's own `currency`
  // check — see module header). KAR-944 adversarial review F1 fix: this
  // gate used to test ONLY `status === 'abweichung'` — a `'nicht_pruefbar'`
  // currency check with reason `'fingerprint_unavailable'` (the container's
  // own `templateFingerprint.structuralHash` is `null`, so variant-
  // reconciliation.ts's fail-closed identity guard could not even ATTEMPT
  // the comparison — see `matchToContainer`) or `'unmatched_variant'` (the
  // guard positively rejected the variant/container pairing) silently
  // PASSED this gate — an UNVERIFIED currency label was then trusted as the
  // bucket key for that variant's profile-derived contributions (item 2/4
  // below), exactly the "never silently aggregate the uncertain" Master-
  // Prompt §19 forbids.
  //
  // Fix scope, deliberately narrower than "every `nicht_pruefbar`": only the
  // two reasons above genuinely mean "verification could not even be
  // attempted" (`IDENTITY_GUARD_NICHT_PRUEFBAR_REASONS` below). The other two
  // `nicht_pruefbar` reasons this same check can report are NOT this bug —
  // `'mixed_currency'` is the Buckets-Doktrin's own normal case (a variant's
  // material rows genuinely span two currencies; already handled correctly
  // by per-currency bucketing elsewhere, not a reason to drop the whole
  // variant), and `'fehlende_daten'` fires whenever a variant simply has NO
  // material rows at all (routine for a variant whose entire cost is
  // profile-driven) — treating either as a trust failure would have excluded
  // variants that were never uncertain to begin with (confirmed by running
  // the full test suite: blanket-excluding every `nicht_pruefbar` broke 6
  // unrelated existing tests covering exactly these two legitimate cases).
  // Excluded under its own distinct reason (`consistent_units_not_verifiable`)
  // so "checked and wrong" (`inconsistent_units`) stays visibly different
  // from "could not be checked at all" — both still fail the SAME gate
  // (`consistent_units` asks "is every included variant's own currency label
  // VERIFIED", not merely "was it verified and found wrong"). ─────────────
  const unitInconsistentPairs = matchedPairsRaw.filter((p) => {
    const altStatus = currencyCheckEntry(inputs.reconciliation.alt, p.leftId)?.status ?? null
    const neuStatus = currencyCheckEntry(inputs.reconciliation.neu, p.rightId)?.status ?? null
    return altStatus === 'abweichung' || neuStatus === 'abweichung'
  })
  for (const p of unitInconsistentPairs) {
    excludedVariants.push({
      variantId: p.rightId,
      reason: 'inconsistent_units',
      messageDe: 'Varianten-Rekonziliation (variant-reconciliation.ts, checkId "currency") meldet eine Abweichung zwischen Definitions- und Material-Zeilen-Währung — keine verlässliche Einheiten-Basis für diese Variante.',
      messageEn: 'Variant reconciliation (variant-reconciliation.ts, checkId "currency") reports a mismatch between the definition currency and the material-row currency — no reliable unit basis for this variant.',
    })
  }
  const unitInconsistentIds = new Set(unitInconsistentPairs.map((p) => p.rightId))
  const unitNotVerifiablePairs = matchedPairsRaw.filter((p) => {
    if (unitInconsistentIds.has(p.rightId)) return false // already excluded/counted via the genuine 'abweichung' above.
    const altCheck = currencyCheckEntry(inputs.reconciliation.alt, p.leftId)
    const neuCheck = currencyCheckEntry(inputs.reconciliation.neu, p.rightId)
    return (
      (altCheck?.status === 'nicht_pruefbar' && IDENTITY_GUARD_NICHT_PRUEFBAR_REASONS.has(altCheck.nichtPruefbarReason!)) ||
      (neuCheck?.status === 'nicht_pruefbar' && IDENTITY_GUARD_NICHT_PRUEFBAR_REASONS.has(neuCheck.nichtPruefbarReason!))
    )
  })
  for (const p of unitNotVerifiablePairs) {
    const altCheck = currencyCheckEntry(inputs.reconciliation.alt, p.leftId)
    const neuCheck = currencyCheckEntry(inputs.reconciliation.neu, p.rightId)
    const neuQualifies = neuCheck?.status === 'nicht_pruefbar' && IDENTITY_GUARD_NICHT_PRUEFBAR_REASONS.has(neuCheck.nichtPruefbarReason!)
    const nichtPruefbarReason = (neuQualifies ? neuCheck!.nichtPruefbarReason : altCheck?.nichtPruefbarReason) ?? 'unbekannt'
    excludedVariants.push({
      variantId: p.rightId,
      reason: 'consistent_units_not_verifiable',
      messageDe: `Varianten-Rekonziliation (variant-reconciliation.ts, checkId "currency") konnte die Einheiten-/Währungs-Konsistenz dieser Variante NICHT prüfen (nichtPruefbarReason: "${nichtPruefbarReason}") — ein ungeprüfter Zustand wird nie wie ein bestandener behandelt, die Variante wird von der Summenbildung ausgeschlossen.`,
      messageEn: `Variant reconciliation (variant-reconciliation.ts, checkId "currency") could NOT verify this variant's unit/currency consistency (nichtPruefbarReason: "${nichtPruefbarReason}") — an unverified state is never treated as a pass, the variant is excluded from the summed aggregate.`,
    })
  }
  const gateConsistentUnits: AggregateImpactGateOutcome = {
    gate: 'consistent_units',
    passed: unitInconsistentPairs.length === 0 && unitNotVerifiablePairs.length === 0,
    messageDe:
      unitInconsistentPairs.length === 0 && unitNotVerifiablePairs.length === 0
        ? 'Varianten-Währungs-Rekonziliation zeigt keine Abweichung und keine ungeprüfte Variante.'
        : `${unitInconsistentPairs.length} Variante(n) mit Varianten-/Material-Währungs-Abweichung, ${unitNotVerifiablePairs.length} Variante(n) nicht prüfbar — beide Gruppen von der Summenbildung ausgeschlossen.`,
    messageEn:
      unitInconsistentPairs.length === 0 && unitNotVerifiablePairs.length === 0
        ? 'Variant currency reconciliation shows no mismatch and no unverifiable variant.'
        : `${unitInconsistentPairs.length} variant(s) with a variant/material currency mismatch, ${unitNotVerifiablePairs.length} variant(s) not verifiable — both groups excluded from the summed aggregate.`,
    affectedVariantIds: [...unitInconsistentPairs, ...unitNotVerifiablePairs].map((p) => p.rightId).sort(),
  }

  // ── valid_baseline ─────────────────────────────────────────────────────
  const baselinePassed = !inputs.reviewRequired || options.acknowledgeReviewRequired === true
  const gateValidBaseline: AggregateImpactGateOutcome = {
    gate: 'valid_baseline',
    passed: baselinePassed,
    messageDe: !inputs.reviewRequired
      ? 'Keine blockierenden Review-Zustände auf ALT/NEU-Container-Ebene.'
      : options.acknowledgeReviewRequired
        ? `Blockierender Review-Zustand vorhanden (${inputs.reviewRequiredReasons.join(', ') || 'siehe reviewRequiredReasons'}), aber explizit "trotz Review" bestätigt — Aggregat wird berechnet, reviewRequired bleibt true.`
        : `Blockierender Review-Zustand vorhanden (${inputs.reviewRequiredReasons.join(', ') || 'siehe reviewRequiredReasons'}) — Jahres-/Lifetime-Aggregat wird unterdrückt (Population leer).`,
    messageEn: !inputs.reviewRequired
      ? 'No blocking review state on the ALT/NEU container level.'
      : options.acknowledgeReviewRequired
        ? `A blocking review state exists (${inputs.reviewRequiredReasons.join(', ') || 'see reviewRequiredReasons'}), but was explicitly acknowledged "despite review" — the aggregate is computed, reviewRequired stays true.`
        : `A blocking review state exists (${inputs.reviewRequiredReasons.join(', ') || 'see reviewRequiredReasons'}) — the annual/lifetime aggregate is suppressed (empty population).`,
    affectedVariantIds: [],
  }

  // ── Base eligible population (post structural/mapping/unit gates) ──────
  const excludedNeuIds = new Set<string>([
    ...blockedPairs.map((p) => p.rightId),
    ...unitInconsistentPairs.map((p) => p.rightId),
    ...unitNotVerifiablePairs.map((p) => p.rightId), // KAR-944 adversarial review F1 fix.
  ])
  const altIndex = new Map(allMultiQafContainerVariants(alt).map((v) => [v.stableInternalId, v] as const))
  const neuIndex = new Map(allMultiQafContainerVariants(neu).map((v) => [v.stableInternalId, v] as const))

  const resolvedEligible: EligiblePair[] = []
  for (const p of matchedPairsRaw) {
    if (excludedNeuIds.has(p.rightId)) continue
    const altVariant = altIndex.get(p.leftId)
    const neuVariant = neuIndex.get(p.rightId)
    if (!altVariant || !neuVariant) {
      throw new Error(
        `computeAggregateImpact: matchResult references variant id(s) "${p.leftId}"/"${p.rightId}" not present in the ALT/NEU container's own activeVariants/inactiveVariants — matchResult must be built from allMultiQafContainerVariants(alt)/(neu).`,
      )
    }
    resolvedEligible.push({ altId: p.leftId, neuId: p.rightId, altVariant, neuVariant })
  }
  const eligibleNeuIds = new Set(resolvedEligible.map((p) => p.neuId))
  const pairByNeuId = new Map(resolvedEligible.map((p) => [p.neuId, p] as const))

  // ── Per-variant, per-currency contribution buckets ──────────────────────
  const bucketsByVariant = new Map<string, Map<string, { currency: string; delta: number }>>()
  const componentsByVariant = new Map<string, VariantUnitPriceDeltaComponent[]>()
  for (const id of eligibleNeuIds) {
    bucketsByVariant.set(id, new Map())
    componentsByVariant.set(id, [])
  }
  const currencyExcludedVariantIds = new Set<string>()
  const reviewRelevantExcludedVariantIds = new Set<string>()

  // A) material-differ 11.1 shared-component impacts (see module header
  // scope note — only the three groups that ever carry a numeric impact).
  const materialFindingGroups = [
    ...materialDiff.sharedComponents.unitCostValueChanges,
    ...materialDiff.sharedComponents.logisticsOrDutyChanges,
    ...materialDiff.sharedComponents.materialOverheadChanges,
  ]
  for (const finding of materialFindingGroups) {
    if (finding.reviewRelevant) {
      for (const vid of finding.impact.affectedVariantIds) {
        if (eligibleNeuIds.has(vid)) reviewRelevantExcludedVariantIds.add(vid)
      }
      continue
    }
    for (const vi of finding.impact.impacts) {
      if (!eligibleNeuIds.has(vi.variantId)) continue
      if (vi.impact === null || vi.currency === null) {
        if (vi.reason === 'mixed_currency' || vi.reason === 'currency_unknown') currencyExcludedVariantIds.add(vi.variantId)
        continue
      }
      addToBucket(bucketsByVariant.get(vi.variantId)!, vi.currency, vi.impact)
      componentsByVariant.get(vi.variantId)!.push({ source: 'material_shared_component', currency: vi.currency, delta: round4(vi.impact) })
    }
  }

  // B) profile-differ item 2 (totalChanges) — bucket key is the variant's
  // OWN currency (ALT===NEU required — SharedCostProfile has no currency
  // field of its own, see module header).
  for (const tc of profileDiff.totalChanges) {
    if (tc.deltaAbsolute === null || tc.status === 'nicht_berechenbar' || tc.status === 'konstant') continue
    for (const neuVariantId of tc.affectedVariantIds) {
      if (!eligibleNeuIds.has(neuVariantId)) continue
      const pair = pairByNeuId.get(neuVariantId)!
      const altCur = pair.altVariant.currency
      const neuCur = pair.neuVariant.currency
      if (altCur === null || neuCur === null || altCur !== neuCur) {
        currencyExcludedVariantIds.add(neuVariantId)
        continue
      }
      addToBucket(bucketsByVariant.get(neuVariantId)!, neuCur, tc.deltaAbsolute)
      componentsByVariant.get(neuVariantId)!.push({ source: 'profile_total_change', currency: neuCur, delta: round4(tc.deltaAbsolute) })
    }
  }

  // C) profile-differ item 4 (bindingValueImpacts) — already carries its own
  // 3-state currency gate (mirrors item 2's gate exactly).
  for (const bvi of profileDiff.bindingValueImpacts) {
    const neuVariantId = bvi.neu.variantId
    if (!eligibleNeuIds.has(neuVariantId)) continue
    if (bvi.currencyState !== 'same_currency') {
      currencyExcludedVariantIds.add(neuVariantId)
      continue
    }
    if (bvi.deltaAbsolute === null) continue // single-sided bound/unbound — no delta to compute, not a currency problem.
    const pair = pairByNeuId.get(neuVariantId)!
    const currency = pair.neuVariant.currency
    if (currency === null) {
      currencyExcludedVariantIds.add(neuVariantId)
      continue
    }
    addToBucket(bucketsByVariant.get(neuVariantId)!, currency, bvi.deltaAbsolute)
    componentsByVariant.get(neuVariantId)!.push({ source: 'profile_binding_value_impact', currency, delta: round4(bvi.deltaAbsolute) })
  }

  const unitPriceDeltas: VariantUnitPriceDelta[] = [...eligibleNeuIds]
    .sort()
    .map((id) => {
      const pair = pairByNeuId.get(id)!
      const byCurrency = [...bucketsByVariant.get(id)!.values()].sort((a, b) => a.currency.localeCompare(b.currency))
      const components = [...componentsByVariant.get(id)!].sort((a, b) => a.currency.localeCompare(b.currency) || a.source.localeCompare(b.source))
      return { variantId: id, altVariantId: pair.altId, byCurrency, components }
    })

  const gateCompatibleCurrencies: AggregateImpactGateOutcome = {
    gate: 'compatible_currencies',
    passed: currencyExcludedVariantIds.size === 0,
    messageDe:
      currencyExcludedVariantIds.size === 0
        ? 'Keine Impact-Komponente wegen Währungswechsel/unbekannter Währung verworfen.'
        : `${currencyExcludedVariantIds.size} Variante(n) hatten mindestens eine Impact-Komponente, die wegen Währungswechsel/unbekannter Währung nicht berechnet werden konnte (Buckets-Doktrin — nie cross-currency addiert).`,
    messageEn:
      currencyExcludedVariantIds.size === 0
        ? 'No impact component was dropped due to a currency change/unknown currency.'
        : `${currencyExcludedVariantIds.size} variant(s) had at least one impact component that could not be computed due to a currency change/unknown currency (buckets doctrine — never added cross-currency).`,
    affectedVariantIds: [...currencyExcludedVariantIds].sort(),
  }

  // ── volumes_available (per-timeframe exclusion, NEU side — see module
  // header). ───────────────────────────────────────────────────────────
  const annualMissing = new Set(resolvedEligible.filter((p) => p.neuVariant.annualVolume === null).map((p) => p.neuId))
  const lifetimeMissing = new Set(resolvedEligible.filter((p) => p.neuVariant.lifetimeVolume === null).map((p) => p.neuId))
  for (const id of [...annualMissing].sort()) {
    excludedVariants.push({
      variantId: id,
      reason: 'volume_missing_annual',
      messageDe: 'Kein annualVolume auf der NEU-Seite vorhanden — von der Jahres-Impact-Aggregation ausgeschlossen (siehe KAR-940: Volumen-Daten sind auf Realdateien oft unvollständig).',
      messageEn: 'No annualVolume present on the NEU side — excluded from the annual-impact aggregation (see KAR-940: volume data is often incomplete on real files).',
    })
  }
  for (const id of [...lifetimeMissing].sort()) {
    excludedVariants.push({
      variantId: id,
      reason: 'volume_missing_lifetime',
      messageDe: 'Kein lifetimeVolume auf der NEU-Seite vorhanden — von der Lifetime-Impact-Aggregation ausgeschlossen (siehe KAR-940: Volumen-Daten sind auf Realdateien oft unvollständig).',
      messageEn: 'No lifetimeVolume present on the NEU side — excluded from the lifetime-impact aggregation (see KAR-940: volume data is often incomplete on real files).',
    })
  }
  const volumesMissingUnion = new Set<string>([...annualMissing, ...lifetimeMissing])
  const gateVolumesAvailable: AggregateImpactGateOutcome = {
    gate: 'volumes_available',
    passed: volumesMissingUnion.size === 0,
    messageDe:
      volumesMissingUnion.size === 0
        ? 'Alle einbezogenen Varianten tragen annualVolume UND lifetimeVolume.'
        : `${annualMissing.size} Variante(n) ohne annualVolume, ${lifetimeMissing.size} ohne lifetimeVolume — jeweils aus der betroffenen Zeitraum-Aggregation ausgeschlossen.`,
    messageEn:
      volumesMissingUnion.size === 0
        ? 'Every included variant carries both annualVolume and lifetimeVolume.'
        : `${annualMissing.size} variant(s) without annualVolume, ${lifetimeMissing.size} without lifetimeVolume — each excluded from the affected timeframe's aggregation.`,
    affectedVariantIds: [...volumesMissingUnion].sort(),
  }

  // ── Timeframe assembly ──────────────────────────────────────────────────
  function buildTimeframe(volumeField: 'annualVolume' | 'lifetimeVolume', missing: ReadonlySet<string>): AggregateImpactTimeframe {
    const included = resolvedEligible.filter((p) => !missing.has(p.neuId))
    const perVariant: VariantTimeframeImpact[] = included
      .map((p) => {
        const volume = p.neuVariant[volumeField]!
        const byCurrency = [...bucketsByVariant.get(p.neuId)!.values()]
          .map((b) => ({ currency: b.currency, delta: round4(b.delta * volume) }))
          .sort((a, b) => a.currency.localeCompare(b.currency))
        return { variantId: p.neuId, altVariantId: p.altId, byCurrency }
      })
      .sort((a, b) => a.variantId.localeCompare(b.variantId))

    if (!baselinePassed) {
      return { population: [], perVariant, aggregate: [] }
    }

    const byCurrency = new Map<string, { total: number; count: number }>()
    for (const pv of perVariant) {
      for (const b of pv.byCurrency) {
        const acc = byCurrency.get(b.currency) ?? { total: 0, count: 0 }
        acc.total = round4(acc.total + b.delta)
        acc.count += 1
        byCurrency.set(b.currency, acc)
      }
    }
    const aggregate = [...byCurrency.entries()]
      .map(([currency, { total, count }]) => ({ currency, totalImpact: total, variantCount: count }))
      .sort((a, b) => a.currency.localeCompare(b.currency))

    return {
      population: included.map((p) => p.neuId).sort(),
      perVariant,
      aggregate,
    }
  }

  const annual = buildTimeframe('annualVolume', annualMissing)
  const lifetime = buildTimeframe('lifetimeVolume', lifetimeMissing)

  // ── Structural fallback (always available — §19 "unweighted structural
  // comparison"). ─────────────────────────────────────────────────────────
  const structuralFallback: AggregateImpactStructuralFallback = {
    variantsAdded: containerDiff.variants.added.length,
    variantsRemoved: containerDiff.variants.removed.length,
    variantsRenamed: containerDiff.variants.renamed.length,
    variantsReordered: containerDiff.variants.reordered.length,
    variantsActiveStateChanged: containerDiff.variants.activeStateChanges.length,
    uncertainMatches: containerDiff.variants.uncertainMatches.length,
    materialRowsAdded: containerDiff.sharedMaterial.addedRows.length,
    materialRowsRemoved: containerDiff.sharedMaterial.removedRows.length,
    materialRowsChanged: containerDiff.sharedMaterial.changedRows.length,
    profilesAdded: containerDiff.profiles.added.length,
    profilesRemoved: containerDiff.profiles.removed.length,
    volumeBandThresholdChanges: containerDiff.profiles.volumeBandThresholdChanges.length,
    profileBindingChanges: containerDiff.profiles.bindingChanges.length,
  }

  // ── Assumptions (§19 "show the assumptions") ────────────────────────────
  const assumptions: AggregateImpactAssumption[] = [
    {
      code: 'sign_convention',
      messageDe: 'Delta = NEU − ALT (identisch zu differ.ts/computeNumericDelta): positiver Impact = Verteuerung ALT→NEU, negativer Impact = Ersparnis. Nie invertiert.',
      messageEn: 'Delta = NEU − ALT (identical to differ.ts/computeNumericDelta): a positive impact is a cost increase from ALT to NEU, a negative impact is a saving. Never inverted.',
    },
    {
      code: 'volume_and_currency_basis',
      messageDe: 'annualVolume/lifetimeVolume/currency werden von der NEU-Seite gelesen — eine echte ALT/NEU-Volumen- oder Währungsänderung ist eine eigene, separate Erkenntnis (siehe container-differ.ts/consistent_units-Gate), nicht Teil dieses Werts.',
      messageEn: 'annualVolume/lifetimeVolume/currency are read from the NEU side — a genuine ALT/NEU volume or currency change is its own separate finding (see container-differ.ts/the consistent_units gate), not part of this figure.',
    },
    {
      code: 'material_included',
      messageDe: 'Material-Impacts (Stückkosten, Transport/Zoll, MGK — material-differ.ts 11.1) sind eingeschlossen, sofern Faktor und Währung ALT/NEU unverändert sind und die zugrundeliegende Zeile nicht reviewRelevant ist.',
      messageEn: 'Material impacts (unit cost, logistics/duty, material overhead — material-differ.ts 11.1) are included where the factor and currency are unchanged ALT/NEU and the underlying row is not reviewRelevant.',
    },
    {
      code: 'manufacturing_included_where_currency_valid',
      messageDe: 'Fertigungs-Deltas (profile-differ.ts Item 2/4) sind eingeschlossen, wo die Varianten-Währung ALT=NEU bekannt ist; sonst ausgeschlossen (siehe gates.compatible_currencies).',
      messageEn: 'Manufacturing deltas (profile-differ.ts item 2/4) are included where the variant currency ALT=NEU is known; otherwise excluded (see gates.compatible_currencies).',
    },
  ]
  if (matchedPairsRaw.length === 0) {
    assumptions.push({
      code: 'no_matched_variants',
      messageDe: 'Keine gematchten Varianten (matchResult enthält keinen "matched"-Eintrag) — Aggregat ist leer, der ungewichtete Struktur-Vergleich bleibt verfügbar.',
      messageEn: 'No matched variants (matchResult contains no "matched" entry) — the aggregate is empty, the unweighted structural comparison stays available.',
    })
  }
  if (reviewRelevantExcludedVariantIds.size > 0) {
    assumptions.push({
      code: 'review_relevant_rows_excluded',
      messageDe: `${reviewRelevantExcludedVariantIds.size} Variante(n) hatten mindestens eine Material-Zeilen-Impact-Komponente, die wegen reviewRelevant=true (Zeile nicht validationStatus:'ok') aus der Summe ausgeschlossen wurde.`,
      messageEn: `${reviewRelevantExcludedVariantIds.size} variant(s) had at least one material-row impact component excluded from the sum due to reviewRelevant=true (row not validationStatus:'ok').`,
    })
  }
  if (!baselinePassed) {
    assumptions.push({
      code: 'aggregate_suppressed_baseline_invalid',
      messageDe: 'annual.aggregate/lifetime.aggregate wurden unterdrückt (leere Population) — gates.valid_baseline ist nicht erfüllt und wurde nicht per acknowledgeReviewRequired bestätigt.',
      messageEn: 'annual.aggregate/lifetime.aggregate were suppressed (empty population) — gates.valid_baseline failed and was not acknowledged via acknowledgeReviewRequired.',
    })
  } else if (inputs.reviewRequired && options.acknowledgeReviewRequired) {
    assumptions.push({
      code: 'aggregate_computed_despite_review',
      messageDe: 'Aggregat wurde trotz blockierendem Review-Zustand berechnet (acknowledgeReviewRequired:true) — reviewRequired bleibt am Ergebnis true.',
      messageEn: 'The aggregate was computed despite a blocking review state (acknowledgeReviewRequired:true) — reviewRequired stays true on the result.',
    })
  }

  const gates: readonly AggregateImpactGateOutcome[] = [
    gateCompatibleCurrencies,
    gateVolumesAvailable,
    gateNoDuplicates,
    gateConsistentUnits,
    gateValidBaseline,
    gateNoBlockedMappings,
  ]

  return {
    modelVersion: AGGREGATE_IMPACT_RESULT_VERSION,
    gates,
    gatesPassed: gates.filter((g) => g.passed).map((g) => g.gate),
    gatesFailed: gates.filter((g) => !g.passed).map((g) => g.gate),
    reviewRequired: inputs.reviewRequired,
    excludedVariants: excludedVariants.sort((a, b) => a.variantId.localeCompare(b.variantId) || a.reason.localeCompare(b.reason)),
    unitPriceDeltas,
    annual,
    lifetime,
    structuralFallback,
    assumptions,
  }
}
