// Multi-QAF Fertigungs-Vergleich mit 8 getrennten Befund-Typen (KAR-939 /
// Multi-QAF-Programm P2.5, Epic KAR-925).
//
// Problem this closes (30-backlog-phasenplan.md P2.5 + Master-Prompt §12):
// "Multi-QAF manufacturing logic may use shared profiles instead of one
// independent process list per variant... Report separately: (1) a changed
// manufacturing process, (2) a changed profile total, (3) a changed volume
// threshold, (4) a variant moved to another profile, (5) a newly created
// profile, (6) a removed profile, (7) an inconsistent profile mapping, (8) a
// summary value not reconciling with the selected profile. Do not assume
// that all variants use the same manufacturing total."
//
// container-differ.ts (KAR-937/#309, P2.3) already diffs the SAME
// `container.sharedManufacturingProfiles`/`setupCostProfiles`/
// `sharedToolingData`/`variantProfileBindings` arrays but is explicitly,
// deliberately VALUE-BLIND there (see its own module header: "manufacturing-
// /setup-cost profile VALUE totals -> P2.5"). This module is that P2.5.
//
// ── #309/#310 boundary — who reports what, no double-reporting ────────────
//   - Profile STRUCTURAL identity, added/removed PROFILE OBJECTS (without
//     values), and BINDING CHANGE existence (bound/unbound/rebound, without
//     a value comparison) are #309's `profiles.added/removed/bindingChanges`
//     — this module reuses the IDENTICAL structural-identity algorithm
//     (`profilesByStructuralIdentity`, imported verbatim from
//     container-differ.ts, never re-derived) so the two modules can never
//     disagree about "is this the same profile" across a cosmetic row shift,
//     but adds the VALUES #309 deliberately omits (`added`/`removed` below
//     carry `values`/`totalPerUnit`; #309's own `ProfileIdentityRef` does
//     not).
//   - Volume-band THRESHOLD changes (Master-Prompt §12 item 3) are ALREADY
//     fully owned by #309's `profiles.volumeBandThresholdChanges` (its own
//     `isVolumeBandStructuralKey` whitelist, exported and reused verbatim
//     here). This module's own `componentValueChanges`/`totalChanges` (items
//     1/2 below) EXCLUDE every threshold-structural key from their own
//     `values`-Record diff for exactly this reason — a volume-band's
//     threshold moving is reported ONCE, by #309, never a second time here
//     as a generic "component value changed" finding. This module
//     deliberately has NO finding group of its own for item 3; a caller
//     wanting item 3 combines this module's output with #309's
//     `profiles.volumeBandThresholdChanges`.
//   - `container-differ.ts`'s `allMultiQafContainerVariants` and its
//     fail-closed matchResult-id-resolution contract are reused/mirrored
//     verbatim (see `requireVariant` below) — this module never re-derives
//     variant identity, matching, or profile structural identity from
//     scratch.
//
// ── Master-Prompt §12 item -> finding group mapping ────────────────────────
//   1  changed manufacturing process (component-level, e.g. Zykluszeit/
//      Löhne/Maschinenstundensatz-shaped keys) -> `componentValueChanges`
//   2  changed profile total (`values.totalPerUnit`)  -> `totalChanges`
//   3  changed volume threshold                        -> NOT here, see #309
//      `profiles.volumeBandThresholdChanges` (boundary note above)
//   4  variant moved to another profile (VALUE impact,
//      #309's `bindingChanges` already reports the move itself structurally)
//                                                       -> `bindingValueImpacts`
//   5  newly created profile (with values)              -> `added`
//   6  removed profile (with last known values)          -> `removed`
//   7  inconsistent profile mapping (unbound / unresolved
//      reference / ambiguous reference / dangling profileId)
//                                                       -> `inconsistentBindings`
//   8  summary value not reconciling with the selected
//      profile                                          -> `summaryReconciliation`
//
// ── "Never assume all variants share one total" (task instruction) ────────
// Every finding below is scoped to a MATCHED variant pair's OWN binding
// (`container.variantProfileBindings`, resolved per-variant, never a
// container-wide default) — Datei 3's real-corpus shape (10-analyse-nafta.md
// E: all 5 variants legitimately resolve to the SAME absolute cell) and
// Datei 1/4's per-band/per-shape shapes (10-analyse-mx.md G,
// 10-analyse-ncar.md F) are both handled for free by this per-variant
// resolution — no group here ever assumes a single container-wide total.
//
// ── Fail-closed self-diff-safety discipline (why groups 7/8 are SET-DIFFS,
// not absolute per-side snapshots) ──────────────────────────────────────────
// profile-parser.real-files.test.ts's own real-corpus evidence
// (KAR-932/profile-parser.ts "Datei 2": binding rate genuinely < 1 on a real,
// otherwise-healthy file — some Summary variant columns are non-standard
// benchmark/delta-helper columns with no resolvable profile binding at all,
// an HONEST, EXPECTED outcome, not a parser defect) means an ABSOLUTE
// per-side inventory of "inconsistent"/"non-reconciling" entries is NOT
// necessarily empty even for a single real file compared against an exact
// copy of itself. Reporting such an inventory directly (unfiltered) would
// make the task's own required self-diff anchor test
// ("Selbst-Diff aller 4 Dateien -> 0 Befunde in allen 8 Gruppen")
// mathematically impossible to satisfy for any file with even one such
// honest pre-existing gap. `inconsistentBindings` and `summaryReconciliation`
// are therefore both SET-DIFFS (added/removed), exactly mirroring #309's own
// `detailWithoutSummarySetDiff`/`unattributedDetailColumnEntries` pattern —
// an entry is only ever reported when it exists on ONE side and not the
// other (a genuine change), never when it exists identically on both (a
// pre-existing, unchanged condition). This guarantees self-diff safety by
// CONSTRUCTION (identical containers on both sides can never produce a
// one-sided entry), independent of how many honest pre-existing gaps any
// single real file happens to carry.
//
// ── Set-diff key granularity (KAR-939 adversarial review F2/F3 — merge-
// blocker class) ────────────────────────────────────────────────────────
// A set-diff is only as good as its dedup key: if the key stays identical
// across a genuine underlying change, that change silently vanishes (never
// appears in `added` NOR `removed`) instead of surfacing as one-sided.
//   - `inconsistentBindings` (item 7): `unresolved_reference`/
//     `ambiguous_reference` entries carry `profileId: null` (there IS no
//     resolved target — that is the whole point of these two reasons), so
//     the key used to be `reason::variantId::''` regardless of WHICH
//     unresolved/ambiguous target was involved. A variant flipping from one
//     non-resolvable target to a DIFFERENT non-resolvable target (same
//     reason, same variant) was therefore invisible. Fixed by folding a
//     `targetRef` (a normalized slug of the warning's own message, minus the
//     "Variante X: " variant-id prefix already carried by `variantId` —
//     see `normalizeWarningTargetRef`) into both the entry and the key.
//   - `summaryReconciliation` (item 8): the key already included `status`
//     but never `deltaAbsolute` — two entries sharing `variantId::profileId
//     ::status` (typically two `'abweichung'` entries, or two
//     `nicht_pruefbar`/`same_source_no_independent_check` entries) with
//     DIFFERENT underlying deltas were treated as identical and dropped from
//     both `added`/`removed`. Raw floats are deliberately NOT added to the
//     key (float noise would break the self-diff anchor) — instead a THIRD
//     set-diff category, `changed`, catches exactly this case: same full key
//     on both sides, but `computeNumericDelta`+`DEFAULT_DIFFER_BANDS_CONFIG`
//     bands the ALT-vs-NEU delta pair as anything other than `'konstant'`/
//     `'nicht_berechenbar'`. `changed` and `added`/`removed` are mutually
//     exclusive by construction (an entry only ever qualifies for `changed`
//     when its full key ALREADY matched on both sides, i.e. it was excluded
//     from `added`/`removed`) — never a double report of the same drift.
//
// ── Reconciliation source (item 8) — an explicit, defense-in-depth check,
// not a raw-grid re-read ────────────────────────────────────────────────────
// This module (like #309/#310) operates purely on two already-assembled
// MultiQafContainer objects — it never re-reads the underlying workbook.
// "Summary value" (task instruction: "VirtualQafVariant-Fertigungs-Total")
// is therefore read from `generateVirtualVariants(container)`
// (container-assembly.ts, reused verbatim, never re-derived) rather than
// from a raw Summary-sheet cell profile-parser.ts's own
// `reconcileProfileToSummary` reads (that function needs a live `Worksheet`,
// unavailable here).
//
// ── Item 8 honesty fix (KAR-939 adversarial review F1/F4 — merge-blocker
// class) ─────────────────────────────────────────────────────────────────
// PRE-FIX shape (kept here as a documented cautionary tale, not current
// behavior): "bound profile value" read `formulaAndCachedValue.cachedValue`
// PREFERRED, falling back to `values.totalPerUnit`, and compared it against
// `generateVirtualVariants`' own total, which itself always derives from
// `values.totalPerUnit` of that SAME profile via that SAME binding
// (`container-assembly.ts`'s `profileTotalAsMoney` reads `profile.values.
// totalPerUnit` directly). Because profile-parser.ts's own
// `buildProfileFromCandidate` populates BOTH `values.totalPerUnit` and
// `formulaAndCachedValue.cachedValue` from the IDENTICAL parse-time source
// (`candidate.cachedValue`), the preferred `cachedValue` branch is, on every
// real freshly-parsed profile, numerically identical to `values.totalPerUnit`
// — so the comparison was a NO-OP BY CONSTRUCTION on any real file: it could
// only ever report a finding if a caller had ALREADY mutated one of the two
// representations in memory (exactly the shape this module's own
// mutation-proof test manufactures). A self-diff test built on real,
// non-mutated containers can therefore NEVER exercise this comparison's
// failure path — "0 findings" proved nothing about item 8's own logic, a
// fake-green anchor.
//
// FIX (current behavior): `boundProfileValue` now reads `values.totalPerUnit`
// as its PRIMARY (and only) source for the reconciliation-vs-virtual-variant
// comparison — the SAME source item 4 (`bindingValueImpacts`) and
// `generateVirtualVariants` itself already use (F4: one documented
// value-source strategy for both items, no more silent cachedValue
// substitution that could make item 4 and item 8 report contradictory deltas
// for the same underlying change). Given that shared source, this primary
// comparison is PROVABLY tautological today (both operands are the same
// profile's `values.totalPerUnit`, read twice) whenever a binding resolves
// to an existing profile with a resolvable virtual-variant total — so
// instead of silently treating that as a passed check ('bestanden', no
// finding — the old behavior), it is reported EXPLICITLY as
// `status: 'nicht_pruefbar'`, `nichtPruefbarReason:
// 'same_source_no_independent_check'`. This is the HONEST statement of what
// item 8 can verify today: because a self-diff of two identical containers
// produces the identical `nicht_pruefbar`/`same_source_no_independent_check`
// entry on BOTH sides (same key), the set-diff still reports ZERO findings
// for an unchanged file — self-diff safety holds by construction, same as
// before, but now for an honest reason instead of an accidental one.
//
// The ONE place item 8 can actually catch something TODAY is a SEPARATE,
// genuinely independent signal: `cachedValueDrift` — a direct, in-profile
// comparison of `formulaAndCachedValue.cachedValue` against `values.
// totalPerUnit` OF THE SAME PROFILE OBJECT (no `generateVirtualVariants`
// involved at all). Because these two fields are populated identically at
// parse time, `cachedValueDrift` is `null` for every real, freshly-parsed
// container — it only fires when something has updated ONE of a profile's
// two total-representations without the other: a caller mutating an
// in-memory container (this module's own mutation-proof test), a FUTURE
// pipeline stage that persists/rehydrates `values.totalPerUnit` from a
// different source than the original parse (e.g. a recalculated total) while
// `formulaAndCachedValue.cachedValue` stays the original parsed evidence, or
// stale/partial persistence. When `cachedValueDrift` is non-null, the entry's
// `status` becomes `'abweichung'` for real (never silently substituted as an
// alternative primary source — F4) and its `deltaAbsolute` mirrors that
// drift. This is the SAME "compare formula/cached value separately"
// philosophy Master-Prompt §13 states and formula-engine.ts already
// implements for material rows, applied here to a profile's own total
// instead — now scoped to exactly the one place it can be genuine.
//
// ── Currency-3-state-gate boundary (KAR-938/#310 doctrine, task instruction
// "wo Währungen dranhängen") ────────────────────────────────────────────────
// `SharedCostProfile` (types.ts) carries NO currency field at all — its
// `values` Record is unitless raw numbers. The mixed-currency 3-state gate
// therefore has NOTHING to gate on within `componentValueChanges`/
// `totalChanges` (items 1/2) — applying it there would fabricate a currency
// axis this domain model does not carry. The ONE place in this module where
// two independently-sourced currencies genuinely can diverge is item 4
// (`bindingValueImpacts`): a matched variant pair's OWN
// `VariantDefinition.currency` can legitimately differ ALT-vs-NEU (a
// currency reassignment independent of any profile change) while both sides
// still carry a bound-profile numeric total — computing a raw delta there
// without gating would silently compare two amounts implicitly expressed in
// different currencies. `bindingValueImpacts` therefore carries its own
// `currencyState` 3-state gate (`same_currency`/`currency_changed`/
// `currency_unknown`), mirroring material-differ.ts's own currency-gate
// PATTERN exactly (not imported — that helper is module-private there, same
// "small helper duplicated per module" precedent material-differ.ts and
// container-differ.ts already both independently establish for their own
// respective `isUncertainKind` helpers).
//
// ── Determinism ─────────────────────────────────────────────────────────
// Every finding array is sorted on a stable key (profile structural key /
// variant id / finding kind — all plain string sorts). Same inputs always
// produce a byte-identical (JSON.stringify-equal) ProfileDiffResult.
//
// ── Tolerances (engine-config pattern, no magic numbers) ────────────────────
// `componentValueChanges`/`totalChanges`/`bindingValueImpacts` reuse
// differ.ts's `computeNumericDelta` + `DEFAULT_DIFFER_BANDS_CONFIG` verbatim
// (same status-band machinery every other QAF value comparison in this
// codebase uses). `summaryReconciliation` reuses reconciliation.ts's
// `RECONCILIATION_CONFIG` VALUES (relativeTolerance/absoluteToleranceMinor)
// via a local `withinReconciliationTolerance` helper that mirrors that
// module's own private `withinTolerance` formula exactly (not imported —
// unexported there; material-matrix-parser.ts's own header already
// documents this as "the same tolerance philosophy reconciliation.ts already
// uses", the identical discipline followed here).
//
// tdd-guard: covered by __tests__/profile-differ.test.ts (synthetic, one
// scenario per finding group + #309-boundary/no-double-report tests +
// currency-3-state + nicht_pruefbar + determinism + self-diff safety) and
// __tests__/profile-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 an in-memory profile-total mutation proof).

import type { DiffStatus } from '../types'
import { computeNumericDelta, DEFAULT_DIFFER_BANDS_CONFIG, round4, type DifferBandsConfig } from '../differ'
import { RECONCILIATION_CONFIG, type ReconciliationConfig } from '../reconciliation'
import type {
  MultiQafCellRef,
  MultiQafContainer,
  MultiQafWarning,
  SharedCostProfile,
  SharedCostProfileKind,
  VariantDefinition,
} from './types'
import type {
  VariantAmbiguousResult,
  VariantMatchResult,
  VariantMergeSuspectedResult,
  VariantSplitSuspectedResult,
} from './variant-matcher'
import { generateVirtualVariants } from './container-assembly'
import {
  allMultiQafContainerVariants,
  isVolumeBandStructuralKey,
  profilesByStructuralIdentity,
  type ProfileIdentityRef,
  type VariantIdentityRef,
} from './container-differ'

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

export interface ProfileDifferConfig {
  bands: DifferBandsConfig
  reconciliation: ReconciliationConfig
}

export const DEFAULT_PROFILE_DIFFER_CONFIG: ProfileDifferConfig = {
  bands: DEFAULT_DIFFER_BANDS_CONFIG,
  reconciliation: RECONCILIATION_CONFIG,
}

export interface ProfileDiffOptions {
  config?: ProfileDifferConfig
}

// ── Shared small helpers (module-local — same "small helper per module"
// discipline container-differ.ts/material-differ.ts already establish) ─────

function numOrNull(v: number | string | null | undefined): number | null {
  return typeof v === 'number' ? v : null
}

// round4 imported from ../differ (KAR-944 adversarial review F3 — was this
// module's own private copy).

function toProfileIdentityRef(p: SharedCostProfile): ProfileIdentityRef {
  return { profileId: p.profileId, kind: p.kind, label: p.label, sheet: p.sheet, sourceReferences: p.sourceReferences }
}

function toIdentityRef(v: VariantDefinition): VariantIdentityRef {
  return { variantId: v.stableInternalId, originalColumn: v.originalColumn, originalVariantNumber: v.originalVariantNumber, labels: v.originalLabels, sourceReferences: v.sourceReferences }
}

function variantIndex(container: MultiQafContainer): ReadonlyMap<string, VariantDefinition> {
  return new Map(allMultiQafContainerVariants(container).map((v) => [v.stableInternalId, v] as const))
}

function requireVariant(index: ReadonlyMap<string, VariantDefinition>, id: string, side: 'alt' | 'neu'): VariantDefinition {
  const v = index.get(id)
  if (!v) {
    throw new Error(
      `diffProfiles: matchResult references ${side} variant id "${id}" that is not present in the ${side} container's own activeVariants/inactiveVariants — matchResult must be built from this container's own variants (see allMultiQafContainerVariants).`,
    )
  }
  return v
}

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

/** All manufacturing/setup-cost/tooling profiles of a container, pooled —
 * mirrors container-differ.ts's own (private) `allProfiles` exactly, kept as
 * its own module-local one-liner rather than imported (nothing to get wrong
 * duplicating a 1-line array spread; the STRUCTURAL IDENTITY logic built on
 * top of it is what is actually shared, via `profilesByStructuralIdentity`). */
function allProfiles(container: MultiQafContainer): readonly SharedCostProfile[] {
  return [...container.sharedManufacturingProfiles, ...container.setupCostProfiles, ...container.sharedToolingData]
}

/** Which `VirtualVariantSummaryTotals` field a profile's own `kind` feeds —
 * mirrors container-assembly.ts's `generateVirtualVariants` own 3-way
 * bucketing (`if (kind === 'setupCost') ... else if (kind === 'commonTooling')
 * ... else selectedManufacturingProfile = ...`) exactly, so this module's
 * item-4/item-8 findings never disagree with how the container itself
 * assembles its own VirtualQafVariant totals. */
export type ProfileBindingBucket = 'manufacturing' | 'setupCost' | 'commonTooling'

function profileBucket(kind: SharedCostProfileKind): ProfileBindingBucket {
  if (kind === 'setupCost') return 'setupCost'
  if (kind === 'commonTooling') return 'commonTooling'
  return 'manufacturing'
}

/** variantId -> profileId, single entry per variant — profile-parser.ts's
 * own `resolveVariantProfileBindings` resolves AT MOST ONE binding per
 * variant total (one Summary Fertigungskosten cell per variant, one
 * resolution outcome), never one-per-kind, so no bucket-collision handling
 * is needed here (unlike container-differ.ts's own defensive
 * `profileBindingsByKind`, which guards a DIFFERENT, currently-unreachable
 * invariant about kind-bucketed lookups it builds for its own bindingChanges
 * finding). */
function bindingByVariant(container: MultiQafContainer): ReadonlyMap<string, string> {
  return new Map(container.variantProfileBindings.map((b) => [b.variantId, b.profileId] as const))
}

function profileIdToStructuralKey(structural: ReadonlyMap<string, SharedCostProfile>): ReadonlyMap<string, string> {
  const out = new Map<string, string>()
  for (const [key, p] of structural) out.set(p.profileId, key)
  return out
}

// ── Item 1/2: values-Record diff (component values / profile total) ───────

export interface ProfileValueEntry {
  key: string
  /** `undefined` means the key is absent on that side (not merely `null`) —
   * mirrors container-differ.ts's own `ProfileValueMetadataChange` shape. */
  altValue: number | string | null | undefined
  neuValue: number | string | null | undefined
  /** `null` whenever at least one side is a non-numeric string (no delta to
   * band) — `status` is `'nicht_berechenbar'` in that case, mirroring
   * differ.ts's own "can't compute" semantics rather than a fabricated
   * comparison. */
  deltaAbsolute: number | null
  deltaPercent: number | null
  status: DiffStatus
}

export interface ProfileComponentValueChangedFinding {
  alt: ProfileIdentityRef
  neu: ProfileIdentityRef
  /** One entry per changed component key (Zykluszeit/Löhne/
   * Maschinenstundensatz-shaped keys — profile-parser.ts's
   * `componentValuesOnRow`), NEVER `'totalPerUnit'` (see `totalChanges`
   * below) and NEVER a volume-band threshold key (see module header
   * boundary note — #309 owns those). */
  changes: readonly ProfileValueEntry[]
}

export interface ProfileTotalChangedFinding {
  alt: ProfileIdentityRef
  neu: ProfileIdentityRef
  altTotal: number | null
  neuTotal: number | null
  deltaAbsolute: number | null
  deltaPercent: number | null
  status: DiffStatus
  /** Matched variant pairs whose OWN binding resolves to THIS structural
   * profile on BOTH sides (i.e. the binding itself did not change — only
   * this profile's value did; a variant whose BINDING also changed is
   * reported instead by `bindingValueImpacts`, never duplicated here) —
   * Master-Prompt §11.2/§12 "the affected variants" pattern, extended from
   * material-differ.ts's own shared-value-impact convention to manufacturing
   * profiles. Sorted. */
  affectedVariantIds: readonly string[]
}

/** Normalizes a non-numeric profile value for equality comparison — trims
 * string whitespace and collapses `null`/`undefined` (key absent vs. present-
 * but-empty) to the same empty representation, so "no value on either side"
 * never reads as a change. Numbers are stringified so a pure type-drift
 * (`5` vs `'5'`) between otherwise-identical values does not either. */
function normalizeNonNumericForCompare(v: number | string | null | undefined): string {
  if (v === null || v === undefined) return ''
  if (typeof v === 'number') return String(v)
  return v.trim()
}

function diffValueEntry(key: string, altV: number | string | null | undefined, neuV: number | string | null | undefined, bands: DifferBandsConfig): ProfileValueEntry {
  const altIsNumericOrMissing = altV === undefined || altV === null || typeof altV === 'number'
  const neuIsNumericOrMissing = neuV === undefined || neuV === null || typeof neuV === 'number'
  if (altIsNumericOrMissing && neuIsNumericOrMissing) {
    const delta = computeNumericDelta(numOrNull(altV), numOrNull(neuV), bands)
    return { key, altValue: altV, neuValue: neuV, deltaAbsolute: delta.deltaAbsolute, deltaPercent: delta.deltaPercent, status: delta.status }
  }
  // At least one side is a non-numeric string (KAR-942 adversarial-review F2
  // fix / KAR-945, 13.07.2026): this used to unconditionally return
  // 'nicht_berechenbar' here, so an IDENTICAL non-numeric field (e.g. a
  // `site` string equal on both sides) was still reported as a changed
  // component value — every caller of this file (diffProfileValuesAndTotal
  // below, and the totalPerUnit special-case) only filters out 'konstant',
  // never 'nicht_berechenbar', so the spurious entry always surfaced. A
  // delta genuinely cannot be CALCULATED for a text field (no meaningful
  // deltaAbsolute/deltaPercent — 'nicht_berechenbar' stays correct for that),
  // but whether there IS a change at all must still be decided by comparing
  // the two values, exactly like the numeric branch above does via
  // bandStatus's `< EPSILON` check.
  if (normalizeNonNumericForCompare(altV) === normalizeNonNumericForCompare(neuV)) {
    return { key, altValue: altV, neuValue: neuV, deltaAbsolute: null, deltaPercent: null, status: 'konstant' }
  }
  return { key, altValue: altV, neuValue: neuV, deltaAbsolute: null, deltaPercent: null, status: 'nicht_berechenbar' }
}

function diffProfileValuesAndTotal(
  altP: SharedCostProfile,
  neuP: SharedCostProfile,
  matchResult: readonly VariantMatchResult[],
  altBindings: ReadonlyMap<string, string>,
  neuBindings: ReadonlyMap<string, string>,
  altStructKeyByProfileId: ReadonlyMap<string, string>,
  neuStructKeyByProfileId: ReadonlyMap<string, string>,
  thisStructKey: string,
  bands: DifferBandsConfig,
): { componentFinding: ProfileComponentValueChangedFinding | null; totalFinding: ProfileTotalChangedFinding | null } {
  const keys = new Set<string>([...Object.keys(altP.values), ...Object.keys(neuP.values)])
  const componentEntries: ProfileValueEntry[] = []
  let totalFinding: ProfileTotalChangedFinding | null = null

  for (const key of keys) {
    if (isVolumeBandStructuralKey(key)) continue // #309 territory — see module header boundary note.
    const altHas = key in altP.values
    const neuHas = key in neuP.values
    const altV = altHas ? altP.values[key] : undefined
    const neuV = neuHas ? neuP.values[key] : undefined

    if (key === 'totalPerUnit') {
      const entry = diffValueEntry(key, altV, neuV, bands)
      if (entry.status === 'konstant' || entry.status === 'nicht_berechenbar') continue
      const affected: string[] = []
      for (const r of matchResult) {
        if (r.kind !== 'matched') continue
        const altProfileId = altBindings.get(r.leftId)
        const neuProfileId = neuBindings.get(r.rightId)
        if (altProfileId === undefined || neuProfileId === undefined) continue
        if (altStructKeyByProfileId.get(altProfileId) !== thisStructKey) continue
        if (neuStructKeyByProfileId.get(neuProfileId) !== thisStructKey) continue
        affected.push(r.rightId)
      }
      totalFinding = {
        alt: toProfileIdentityRef(altP),
        neu: toProfileIdentityRef(neuP),
        altTotal: numOrNull(altV),
        neuTotal: numOrNull(neuV),
        deltaAbsolute: entry.deltaAbsolute,
        deltaPercent: entry.deltaPercent,
        status: entry.status,
        affectedVariantIds: affected.sort(),
      }
      continue
    }

    const entry = diffValueEntry(key, altV, neuV, bands)
    if (entry.status === 'konstant') continue
    componentEntries.push(entry)
  }

  const componentFinding: ProfileComponentValueChangedFinding | null =
    componentEntries.length > 0
      ? { alt: toProfileIdentityRef(altP), neu: toProfileIdentityRef(neuP), changes: componentEntries.sort((a, b) => a.key.localeCompare(b.key)) }
      : null

  return { componentFinding, totalFinding }
}

// ── Item 4: binding VALUE impact (structural change is #309's job) ─────────

export type ProfileBindingDirection = 'bound' | 'unbound' | 'rebound'
export type ProfileBindingCurrencyState = 'same_currency' | 'currency_changed' | 'currency_unknown'

export interface ProfileBindingValueImpactFinding {
  alt: VariantIdentityRef
  neu: VariantIdentityRef
  bucket: ProfileBindingBucket
  direction: ProfileBindingDirection
  altProfile: ProfileIdentityRef | null
  neuProfile: ProfileIdentityRef | null
  altValue: number | null
  neuValue: number | null
  currencyState: ProfileBindingCurrencyState
  /** `null` unless `currencyState === 'same_currency'` AND both sides have a
   * numeric value — see module header "Currency-3-state-gate boundary". */
  deltaAbsolute: number | null
  deltaPercent: number | null
  status: DiffStatus | null
}

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

// ── Item 5/6: added/removed profiles WITH values ────────────────────────────

export interface ProfileWithValuesRef extends ProfileIdentityRef {
  values: Readonly<Record<string, number | string | null>>
  totalPerUnit: number | null
}

function toProfileWithValuesRef(p: SharedCostProfile): ProfileWithValuesRef {
  return { ...toProfileIdentityRef(p), values: p.values, totalPerUnit: numOrNull(p.values.totalPerUnit) }
}

// ── Item 7: inconsistent binding (unbound / unresolved / ambiguous /
// dangling profileId) — reported as a SET-DIFF, see module header ─────────

export type InconsistentBindingReason = 'unbound' | 'unresolved_reference' | 'ambiguous_reference' | 'unresolvable_profile_id'

export interface InconsistentBindingEntry {
  variantId: string
  reason: InconsistentBindingReason
  profileId: string | null
  /** Structural "which target?" disambiguator (KAR-939 adversarial review
   * F2) — `null` for `unbound`/`unresolvable_profile_id` (where `profileId`
   * or the reason itself already fully identifies the entry). For
   * `unresolved_reference`/`ambiguous_reference` (which always carry
   * `profileId: null` — there IS no resolved target) this is a normalized
   * slug of the producing warning's own `message`, via
   * `normalizeWarningTargetRef` — see module header "Set-diff key
   * granularity". */
  targetRef: string | null
  message: string
  sourceReferences: readonly MultiQafCellRef[]
}

export interface InconsistentBindingSetDiff {
  /** Present in NEU, not (with the same reason+profileId+targetRef) in ALT. */
  added: readonly InconsistentBindingEntry[]
  /** Present in ALT, not (with the same reason+profileId+targetRef) in NEU. */
  removed: readonly InconsistentBindingEntry[]
}

/** Strips the "Variante <id>: " producer-message prefix (redundant — this
 * module already keys on `variantId` separately) and returns the remainder,
 * trimmed, as a stable "which target?" slug — see module header "Set-diff
 * key granularity" (F2). Deliberately NOT a hash: keeping the text readable
 * costs nothing extra here (the full message is already carried verbatim on
 * the entry via `message`, so this adds no new confidentiality exposure) and
 * makes a `git diff`/debug session over recorded findings legible. */
function normalizeWarningTargetRef(message: string): string {
  return message.replace(/^Variante [^:]+:\s*/, '').trim()
}

function inconsistentBindingKey(e: InconsistentBindingEntry): string {
  return `${e.reason}::${e.variantId}::${e.profileId ?? ''}::${e.targetRef ?? ''}`
}

function inconsistentBindingEntries(container: MultiQafContainer): InconsistentBindingEntry[] {
  const profIds = new Set(allProfiles(container).map((p) => p.profileId))
  const boundVariantIds = new Set(container.variantProfileBindings.map((b) => b.variantId))
  const entries: InconsistentBindingEntry[] = []

  for (const w of container.warnings) {
    if (w.code === 'variant_profile_binding_unresolved') {
      for (const vid of w.variantIds ?? []) {
        entries.push({ variantId: vid, reason: 'unresolved_reference', profileId: null, targetRef: normalizeWarningTargetRef(w.message), message: w.message, sourceReferences: w.sourceReferences })
      }
    }
    if (w.code === 'ambiguous_literal_profile_binding') {
      for (const vid of w.variantIds ?? []) {
        entries.push({ variantId: vid, reason: 'ambiguous_reference', profileId: null, targetRef: normalizeWarningTargetRef(w.message), message: w.message, sourceReferences: w.sourceReferences })
      }
    }
  }

  const alreadyReasoned = new Set(entries.map((e) => e.variantId))
  for (const v of allMultiQafContainerVariants(container)) {
    if (v.activeState === 'reserved') continue // no identity at all — not applicable, mirrors container-differ.ts's detailLinkageStatus.
    if (boundVariantIds.has(v.stableInternalId)) continue
    if (alreadyReasoned.has(v.stableInternalId)) continue
    entries.push({
      variantId: v.stableInternalId,
      reason: 'unbound',
      profileId: null,
      targetRef: null,
      message: `Variante ${v.stableInternalId}: keine aufgelöste Fertigungs-/Rüstkosten-Profilbindung.`,
      sourceReferences: v.sourceReferences,
    })
  }

  for (const b of container.variantProfileBindings) {
    if (profIds.has(b.profileId)) continue
    entries.push({
      variantId: b.variantId,
      reason: 'unresolvable_profile_id',
      profileId: b.profileId,
      targetRef: null,
      message: `Variante ${b.variantId}: gebunden an Profil "${b.profileId}", das im aktuellen Profil-Pool nicht (mehr) existiert.`,
      sourceReferences: [],
    })
  }

  return entries.sort((a, b) => inconsistentBindingKey(a).localeCompare(inconsistentBindingKey(b)))
}

function diffInconsistentBindings(alt: MultiQafContainer, neu: MultiQafContainer): InconsistentBindingSetDiff {
  const altList = inconsistentBindingEntries(alt)
  const neuList = inconsistentBindingEntries(neu)
  const altKeys = new Set(altList.map(inconsistentBindingKey))
  const neuKeys = new Set(neuList.map(inconsistentBindingKey))
  return {
    added: neuList.filter((e) => !altKeys.has(inconsistentBindingKey(e))),
    removed: altList.filter((e) => !neuKeys.has(inconsistentBindingKey(e))),
  }
}

// ── Item 8: summary-value reconciliation — reported as a SET-DIFF, see
// module header ─────────────────────────────────────────────────────────────

export type SummaryReconciliationStatus = 'abweichung' | 'nicht_pruefbar'

/** WHY a `'nicht_pruefbar'` entry could not be genuinely checked — see module
 * header "Item 8 honesty fix". */
export type SummaryReconciliationNichtPruefbarReason = 'missing_value' | 'same_source_no_independent_check'

/** An independent, in-profile drift signal: `formulaAndCachedValue.
 * cachedValue` vs `values.totalPerUnit` OF THE SAME PROFILE OBJECT, outside
 * `RECONCILIATION_CONFIG` tolerance — `null` on every real, freshly-parsed
 * profile (see module header "Item 8 honesty fix"). This is the ONE thing
 * that promotes an entry's `status` to a genuine `'abweichung'`. */
export interface SummaryReconciliationCachedValueDrift {
  cachedValue: number
  totalPerUnit: number
  deltaAbsolute: number
}

export interface SummaryReconciliationEntry {
  variantId: string
  profileId: string
  bucket: ProfileBindingBucket
  status: SummaryReconciliationStatus
  /** Set whenever `status === 'nicht_pruefbar'`, `null` otherwise. */
  nichtPruefbarReason: SummaryReconciliationNichtPruefbarReason | null
  /** From the bound profile's own `values.totalPerUnit` — the SAME primary
   * source item 4 (`bindingValueImpacts`) and `generateVirtualVariants`
   * itself use (F4). See module header "Item 8 honesty fix" for why this
   * makes the comparison below provably tautological today, and
   * `cachedValueDrift` for the one comparison that is NOT. */
  boundProfileValue: number | null
  /** From `generateVirtualVariants(container)`'s matching
   * `VirtualVariantSummaryTotals` field for this profile's bucket. */
  virtualVariantTotal: number | null
  /** Set when `status === 'abweichung'` — either `virtualVariantTotal -
   * boundProfileValue` (the structurally-tautological-today primary
   * comparison, kept as defense-in-depth) or, when `cachedValueDrift` is
   * non-null, that drift's own `deltaAbsolute`. `null` otherwise. */
  deltaAbsolute: number | null
  cachedValueDrift: SummaryReconciliationCachedValueDrift | null
}

export interface SummaryReconciliationChangedEntry {
  variantId: string
  profileId: string
  bucket: ProfileBindingBucket
  /** Shared by both sides — a `changed` entry only ever exists for a full
   * key (`variantId::profileId::status::nichtPruefbarReason`) that matched
   * on both sides; only the underlying delta moved. */
  status: SummaryReconciliationStatus
  altDeltaAbsolute: number | null
  neuDeltaAbsolute: number | null
  /** `computeNumericDelta(altDeltaAbsolute, neuDeltaAbsolute, bands).status`
   * — see module header "Set-diff key granularity" (F3) for why a banded
   * status is used instead of a raw float. */
  band: DiffStatus
}

export interface SummaryReconciliationSetDiff {
  /** Present in NEU, not (with the same status+reason) in ALT. */
  added: readonly SummaryReconciliationEntry[]
  /** Present in ALT, not (with the same status+reason) in NEU. */
  removed: readonly SummaryReconciliationEntry[]
  /** Same status+reason on BOTH sides, but a meaningfully different
   * (banded) delta — KAR-939 adversarial review F3: without this category,
   * such a change would be invisible (same set-diff key on both sides). */
  changed: readonly SummaryReconciliationChangedEntry[]
}

function summaryReconciliationKey(e: SummaryReconciliationEntry): string {
  return `${e.variantId}::${e.profileId}::${e.status}::${e.nichtPruefbarReason ?? ''}`
}

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

/**
 * Exported for reuse by variant-reconciliation.ts (KAR-940/P2.6) — the
 * per-container "bound profile total vs generateVirtualVariants' own total"
 * evaluation below IS the manufacturing/setupCost/commonTooling half of
 * KAR-940's per-variant reconciliation cascade (Master-Prompt §13
 * "manufacturing profile to variant manufacturing total" / "setup-cost sheet
 * to applied setup cost" / tooling-assignment bullets), computed once per
 * container exactly as this module already does for its own item-8 set-diff
 * — KAR-940 must never re-derive this same-source-vs-cachedValueDrift logic
 * a third time (see that module's own header for the honesty-matrix this
 * feeds).
 */
export function reconciliationEntries(container: MultiQafContainer, cfg: ReconciliationConfig): SummaryReconciliationEntry[] {
  const profById = new Map(allProfiles(container).map((p) => [p.profileId, p] as const))
  const vvById = new Map(generateVirtualVariants(container).map((v) => [v.variantId, v] as const))
  const out: SummaryReconciliationEntry[] = []

  for (const b of container.variantProfileBindings) {
    const profile = profById.get(b.profileId)
    if (!profile) continue // dangling reference — already reported by `inconsistentBindings` (item 7), not re-reported here.
    const bucket = profileBucket(profile.kind)
    const vv = vvById.get(b.variantId)
    const virtualVariantTotal = vv
      ? bucket === 'manufacturing'
        ? (vv.summaryTotals.manufacturingCosts?.value ?? null)
        : bucket === 'setupCost'
          ? (vv.summaryTotals.setupCostAllocation?.value ?? null)
          : (vv.summaryTotals.toolingAndFixtureCost?.value ?? null)
      : null
    // F4: `values.totalPerUnit` is the PRIMARY (and only) source for this
    // comparison — the same source item 4/`generateVirtualVariants` use.
    // NEVER `formulaAndCachedValue.cachedValue` substituted in silently —
    // see `cachedValueDrift` below for that comparison's own, separate home.
    const boundProfileValue = numOrNull(profile.values.totalPerUnit)

    const cachedValueNum = numOrNull(profile.formulaAndCachedValue?.cachedValue ?? null)
    const cachedValueDrift: SummaryReconciliationCachedValueDrift | null =
      cachedValueNum !== null && boundProfileValue !== null && !withinReconciliationTolerance(boundProfileValue, cachedValueNum, cfg)
        ? { cachedValue: cachedValueNum, totalPerUnit: boundProfileValue, deltaAbsolute: round4(cachedValueNum - boundProfileValue) }
        : null

    let status: SummaryReconciliationStatus
    let nichtPruefbarReason: SummaryReconciliationNichtPruefbarReason | null = null
    let deltaAbsolute: number | null = null

    if (cachedValueDrift) {
      // F1(b): the ONE genuinely independent signal this module can check
      // today — see module header "Item 8 honesty fix".
      status = 'abweichung'
      deltaAbsolute = cachedValueDrift.deltaAbsolute
    } else if (boundProfileValue === null || virtualVariantTotal === null) {
      status = 'nicht_pruefbar'
      nichtPruefbarReason = 'missing_value'
    } else if (!withinReconciliationTolerance(boundProfileValue, virtualVariantTotal, cfg)) {
      // Structurally unreachable today given `boundProfileValue` and
      // `virtualVariantTotal` share the same source (see module header) —
      // kept as a real, defense-in-depth comparison in case that shared
      // derivation ever changes, never silently removed.
      deltaAbsolute = round4(virtualVariantTotal - boundProfileValue)
      status = 'abweichung'
    } else {
      // F1(a): provably the same source, no independent check possible —
      // said honestly instead of silently treated as 'bestanden'.
      status = 'nicht_pruefbar'
      nichtPruefbarReason = 'same_source_no_independent_check'
    }

    out.push({ variantId: b.variantId, profileId: b.profileId, bucket, status, nichtPruefbarReason, boundProfileValue, virtualVariantTotal, deltaAbsolute, cachedValueDrift })
  }

  return out.sort((a, b) => summaryReconciliationKey(a).localeCompare(summaryReconciliationKey(b)))
}

function diffSummaryReconciliation(alt: MultiQafContainer, neu: MultiQafContainer, cfg: ReconciliationConfig, bands: DifferBandsConfig): SummaryReconciliationSetDiff {
  const altList = reconciliationEntries(alt, cfg)
  const neuList = reconciliationEntries(neu, cfg)
  const altByFullKey = new Map(altList.map((e) => [summaryReconciliationKey(e), e] as const))
  const neuByFullKey = new Map(neuList.map((e) => [summaryReconciliationKey(e), e] as const))

  const added = neuList.filter((e) => !altByFullKey.has(summaryReconciliationKey(e)))
  const removed = altList.filter((e) => !neuByFullKey.has(summaryReconciliationKey(e)))

  // F3: same full key on both sides (so NOT in added/removed above) but a
  // meaningfully different delta — never a double report of the same drift.
  const changed: SummaryReconciliationChangedEntry[] = []
  for (const neuE of neuList) {
    const altE = altByFullKey.get(summaryReconciliationKey(neuE))
    if (!altE) continue
    const band = computeNumericDelta(altE.deltaAbsolute, neuE.deltaAbsolute, bands).status
    if (band === 'konstant' || band === 'nicht_berechenbar') continue
    changed.push({
      variantId: neuE.variantId,
      profileId: neuE.profileId,
      bucket: neuE.bucket,
      status: neuE.status,
      altDeltaAbsolute: altE.deltaAbsolute,
      neuDeltaAbsolute: neuE.deltaAbsolute,
      band,
    })
  }

  return {
    added,
    removed,
    changed: changed.sort((a, b) => `${a.variantId}::${a.profileId}`.localeCompare(`${b.variantId}::${b.profileId}`)),
  }
}

// ── Top-level result ─────────────────────────────────────────────────────

export interface ProfileDiffResult {
  /** Item 1. */
  componentValueChanges: readonly ProfileComponentValueChangedFinding[]
  /** Item 2. */
  totalChanges: readonly ProfileTotalChangedFinding[]
  /** Item 4. */
  bindingValueImpacts: readonly ProfileBindingValueImpactFinding[]
  /** Item 5. */
  added: readonly ProfileWithValuesRef[]
  /** Item 6. */
  removed: readonly ProfileWithValuesRef[]
  /** Item 7. */
  inconsistentBindings: InconsistentBindingSetDiff
  /** Item 8. */
  summaryReconciliation: SummaryReconciliationSetDiff
  /** Passed through verbatim from `matchResult` (never resolved/guessed) —
   * same "pass through, never resolve" discipline container-differ.ts's own
   * `variants.uncertainMatches` established. */
  uncertainMatches: readonly (VariantAmbiguousResult | VariantSplitSuspectedResult | VariantMergeSuspectedResult)[]
  warnings: readonly MultiQafWarning[]
}

/**
 * Fertigungs-/Rüst-Profil-Vergleich (Master-Prompt §12) of two already-
 * matched Multi-QAF containers — the 8 SEPARATE finding groups §12 requires,
 * never a flat cell-diff list. `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`/material-differ.ts's
 * `diffMaterial` already require (an unresolvable id throws, fail-closed).
 */
export function diffProfiles(alt: MultiQafContainer, neu: MultiQafContainer, matchResult: readonly VariantMatchResult[], options: ProfileDiffOptions = {}): ProfileDiffResult {
  const config = options.config ?? DEFAULT_PROFILE_DIFFER_CONFIG

  const altVariantIndex = variantIndex(alt)
  const neuVariantIndex = variantIndex(neu)
  const altBindings = bindingByVariant(alt)
  const neuBindings = bindingByVariant(neu)

  const altStructural = profilesByStructuralIdentity(alt)
  const neuStructural = profilesByStructuralIdentity(neu)
  const altStructKeyByProfileId = profileIdToStructuralKey(altStructural)
  const neuStructKeyByProfileId = profileIdToStructuralKey(neuStructural)

  // ── Items 1/2/5/6: per structurally-matched profile pair, plus added/
  // removed for structural keys present on only one side. ──────────────────
  const componentValueChanges: ProfileComponentValueChangedFinding[] = []
  const totalChanges: ProfileTotalChangedFinding[] = []
  const added: ProfileWithValuesRef[] = []
  const removed: ProfileWithValuesRef[] = []

  for (const [key, altP] of altStructural) {
    const neuP = neuStructural.get(key)
    if (!neuP) {
      removed.push(toProfileWithValuesRef(altP))
      continue
    }
    const { componentFinding, totalFinding } = diffProfileValuesAndTotal(
      altP,
      neuP,
      matchResult,
      altBindings,
      neuBindings,
      altStructKeyByProfileId,
      neuStructKeyByProfileId,
      key,
      config.bands,
    )
    if (componentFinding) componentValueChanges.push(componentFinding)
    if (totalFinding) totalChanges.push(totalFinding)
  }
  for (const [key, neuP] of neuStructural) {
    if (!altStructural.has(key)) added.push(toProfileWithValuesRef(neuP))
  }

  // ── Item 4: binding value impact, matched variant pairs only. ────────────
  const bindingValueImpacts: ProfileBindingValueImpactFinding[] = []
  for (const r of matchResult) {
    if (r.kind !== 'matched') continue
    const av = requireVariant(altVariantIndex, r.leftId, 'alt')
    const nv = requireVariant(neuVariantIndex, r.rightId, 'neu')

    const altProfileId = altBindings.get(av.stableInternalId) ?? null
    const neuProfileId = neuBindings.get(nv.stableInternalId) ?? null
    const altStructKey = altProfileId ? (altStructKeyByProfileId.get(altProfileId) ?? null) : null
    const neuStructKey = neuProfileId ? (neuStructKeyByProfileId.get(neuProfileId) ?? null) : null
    if (altStructKey === neuStructKey) continue // unchanged binding (both unbound, or same structural profile — a mere row shift never trips this).

    const altProfile = altStructKey ? altStructural.get(altStructKey) : undefined
    const neuProfile = neuStructKey ? neuStructural.get(neuStructKey) : undefined
    const altValue = altProfile ? numOrNull(altProfile.values.totalPerUnit) : null
    const neuValue = neuProfile ? numOrNull(neuProfile.values.totalPerUnit) : null
    const direction: ProfileBindingDirection = altProfileId === null ? 'bound' : neuProfileId === null ? 'unbound' : 'rebound'
    const currencyState = currencyGateState(av.currency, nv.currency)

    const canCompute = currencyState === 'same_currency' && altValue !== null && neuValue !== null
    const delta = canCompute ? computeNumericDelta(altValue, neuValue, config.bands) : null

    const bucketSourceKind = (neuProfile ?? altProfile)?.kind
    bindingValueImpacts.push({
      alt: toIdentityRef(av),
      neu: toIdentityRef(nv),
      bucket: bucketSourceKind ? profileBucket(bucketSourceKind) : 'manufacturing',
      direction,
      altProfile: altProfile ? toProfileIdentityRef(altProfile) : null,
      neuProfile: neuProfile ? toProfileIdentityRef(neuProfile) : null,
      altValue,
      neuValue,
      currencyState,
      deltaAbsolute: delta?.deltaAbsolute ?? null,
      deltaPercent: delta?.deltaPercent ?? null,
      status: delta?.status ?? null,
    })
  }

  // ── Item 7/8: set-diffs. ───────────────────────────────────────────────
  const inconsistentBindings = diffInconsistentBindings(alt, neu)
  const summaryReconciliation = diffSummaryReconciliation(alt, neu, config.reconciliation, config.bands)

  const uncertainMatches = matchResult.filter(isUncertainKind)

  const byProfileKey = <T extends { alt: ProfileIdentityRef; neu: ProfileIdentityRef }>(a: T, b: T): number =>
    a.alt.profileId.localeCompare(b.alt.profileId) || a.neu.profileId.localeCompare(b.neu.profileId)
  const byVariantKey = <T extends { alt: VariantIdentityRef; neu: VariantIdentityRef }>(a: T, b: T): number =>
    a.alt.variantId.localeCompare(b.alt.variantId) || a.neu.variantId.localeCompare(b.neu.variantId)

  return {
    componentValueChanges: componentValueChanges.sort(byProfileKey),
    totalChanges: totalChanges.sort(byProfileKey),
    bindingValueImpacts: bindingValueImpacts.sort(byVariantKey),
    added: added.sort((a, b) => a.profileId.localeCompare(b.profileId)),
    removed: removed.sort((a, b) => a.profileId.localeCompare(b.profileId)),
    inconsistentBindings,
    summaryReconciliation,
    uncertainMatches: [...uncertainMatches].sort((a, b) => a.explanation.localeCompare(b.explanation)),
    warnings: [],
  }
}
