// Multi-QAF Container-Level-Vergleich (KAR-937 / Multi-QAF-Programm P2.3,
// Epic KAR-925).
//
// Problem this closes (30-backlog-phasenplan.md P2.3 + Master-Prompt §14):
// "In addition to comparing virtual variants, compare the Multi-QAF
// containers themselves... This comparison must not be reduced to a list of
// cell differences." Every module up to this one (container-assembly.ts
// KAR-935, variant-matcher.ts KAR-936) either PARSES one container or
// MATCHES two containers' variant identities against each other — none of
// them compares the two finished MultiQafContainer STRUCTURES. This module
// is that comparison: diffContainers(alt, neu, matchResult, options) takes
// an ALT and a NEU MultiQafContainer plus an already-computed matchResult
// (variant-matcher.ts's matchVariants/matchVariantsWithOverrides output) and
// produces a MultiQafContainerDiff — separate, evidence-carrying finding
// groups for variants/dimensions/shared-material/profiles/template, never a
// flat cell-diff list.
//
// ── Scope discipline (Master-Prompt §7 "never force an interpretation") ────
//   - VALUE comparisons are explicitly OUT of scope here:
//       * shared-material unit-cost / quantity-factor VALUES        -> P2.4
//       * manufacturing-/setup-cost profile VALUE totals            -> P2.5
//       * formula-vs-cached-value reconciliation per virtual variant -> P2.6
//     This module only ever asks "does X exist / did X's IDENTITY or
//     STRUCTURAL SHAPE change" — never "did X's number change". Every
//     finding type below is named to make that boundary visible at the call
//     site (e.g. SharedMaterialRowChangedFinding has no cost field at all).
//   - A field the container does not actually carry (external-link
//     inventory — MultiQafSourceWorkbookMeta has no such field) is reported
//     as `{ tracked: false, note: '...' }`, never silently treated as
//     "unchanged" — a caller must be able to tell "we checked, no change"
//     apart from "we cannot check this from a MultiQafContainer alone". This
//     module does NOT read the underlying workbook — it only ever compares
//     the two already-assembled MultiQafContainer objects it is given.
//
// ── Fail-closed matching contract (Master-Prompt §9, inherited from
// variant-matcher.ts, not re-implemented here) ──────────────────────────────
// `matchVariants`/`matchVariantsWithOverrides` already guarantee "every
// left/right index is covered EXACTLY ONCE across the returned results" —
// this module trusts that invariant rather than re-deriving it: iterating
// `matchResult` once and branching on `.kind` is by construction incapable
// of reporting the same variant as BOTH e.g. 'added' and part of an
// 'ambiguous' finding, because matchVariants never emits two result entries
// for the same left/right index. `matchResult` is expected to have been
// computed with LEFT drawn from `alt`'s own variants and RIGHT drawn from
// `neu`'s own variants (typically the full active+inactive sets — see
// `allMultiQafContainerVariants` below) — every `leftId`/`rightId` string
// the matcher emits is resolved by this module via a Map keyed on
// `stableInternalId` against `alt`/`neu`'s OWN full variant sets (not
// against whatever subset array the caller happened to pass into
// matchVariants), so a caller that pre-filtered its matcher input still
// gets correct identity refs here as long as the ids resolve — an id that
// does NOT resolve throws (fail-closed: a mismatched matchResult/container
// pairing is a caller bug, never silently ignored).
//
// ── Determinism ─────────────────────────────────────────────────────────
// Every finding array below is sorted on a stable key (variant id / profile
// id / canonical component identity / sheet name, all plain string sorts) —
// same inputs always produce a byte-identical (JSON.stringify-equal)
// MultiQafContainerDiff. No wall-clock/random data is ever included.
//
// ── What each Master-Prompt §14 bullet maps onto below ──────────────────
//   added/removed/renamed/reordered variants      -> variants.added/removed/
//                                                     renamed/reordered
//   uncertain matches                              -> variants.uncertainMatches
//   new/removed variant dimensions                 -> dimensions.added/removed
//   changed shared material rows                   -> sharedMaterial.*
//   changed shared profiles                        -> profiles.added/removed +
//                                                      profiles.volumeBandThresholdChanges
//   changed profile bindings                       -> profiles.bindingChanges
//   changed matrix structure                       -> sharedMaterial.structure
//   changed template version                       -> template.family
//   added/removed helper sheets, hidden sheets     -> template.sheetSet
//   changed external links                         -> template.externalLinks
//                                                      (not_tracked — see above)
//   active vs. inactive column changes             -> variants.activeStateChanges
//   orphaned variants /
//   variants in detail sheets absent from summary /
//   variants in summary missing detail support     -> variants.detailLinkage*
//     (derived from the container's OWN structural signal for "which
//     variant ids does the shared material matrix actually reference" —
//     VariantMatrixRow.quantityFactorByVariant/effectiveCostByVariant keys,
//     themselves populated via material-matrix-parser.ts's formula-lineage-
//     driven SUMPRODUCT/SUMIF-band tracing, KAR-931/KAR-933 — the closest
//     thing a finished MultiQafContainer exposes to "formula lineage data"
//     without re-reading the workbook; see `detailLinkageStatus` below for
//     the exact tri-state derivation and its honest 'unknown' branch when
//     the matrix was never populated at all, plus
//     `material_matrix_variant_never_referenced` warnings (container-
//     assembly.ts) folded in as supporting evidence text).
//
// tdd-guard: covered by __tests__/container-differ.test.ts (synthetic, one
// scenario per finding group + determinism) and
// __tests__/container-differ.real-files.test.ts (env-gated: self-diff of
// each of the 4 real files against an identical copy of itself -> zero
// findings in every group, plus one real cross-file diff).

import type {
  MultiQafCellRef,
  MultiQafContainer,
  MultiQafFamilyClassification,
  MultiQafTemplateFamily,
  SharedCostProfileKind,
  VariantActiveState,
  VariantDefinition,
  VariantMatrixRowValidationStatus,
} from './types'
import type {
  VariantAmbiguousResult,
  VariantMatchResult,
  VariantMergeSuspectedResult,
  VariantSplitSuspectedResult,
} from './variant-matcher'
import { reviewStatusForMultiQafContainer } from './container-assembly'

// ── Variant identity ref (shared shape for every finding below) ────────────

/** Human-displayable identity snapshot of one side of a variant finding —
 * never just the bare stableInternalId, so a caller can render a finding
 * without a second lookup back into the source container. */
export interface VariantIdentityRef {
  variantId: string
  originalColumn: string
  originalVariantNumber: string | null
  labels: readonly string[]
  sourceReferences: readonly MultiQafCellRef[]
}

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

/** Convenience helper for building the `matchResult` input this module
 * expects — the full active+inactive (i.e. including inactive/reserved)
 * variant set of one container, in Master-Prompt §14's own scope ("active
 * versus inactive column changes" implies both buckets must be matchable).
 * Not required (see module header — id resolution works against a
 * caller-filtered matcher input too), but the straightforward default for a
 * P2.3-compare-flow caller. */
export function allMultiQafContainerVariants(container: MultiQafContainer): readonly VariantDefinition[] {
  return [...container.activeVariants, ...container.inactiveVariants]
}

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(
      `diffContainers: 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
}

// ── Variant dimension value change (per matched pair) ──────────────────────

export interface VariantDimensionValueChange {
  key: string
  altRaw: string | null
  neuRaw: string | null
}

function nonEmptyDimensionEntries(v: VariantDefinition): ReadonlyMap<string, string> {
  const out = new Map<string, string>()
  for (const [k, dv] of Object.entries(v.dimensions)) {
    if (dv.normalized !== '') out.set(k, dv.normalized)
  }
  return out
}

function dimensionValueChanges(alt: VariantDefinition, neu: VariantDefinition): VariantDimensionValueChange[] {
  const altDims = nonEmptyDimensionEntries(alt)
  const neuDims = nonEmptyDimensionEntries(neu)
  const keys = new Set<string>([...altDims.keys(), ...neuDims.keys()])
  const out: VariantDimensionValueChange[] = []
  for (const key of keys) {
    const altNorm = altDims.get(key)
    const neuNorm = neuDims.get(key)
    if (altNorm === neuNorm) continue
    out.push({ key, altRaw: alt.dimensions[key]?.raw ?? null, neuRaw: neu.dimensions[key]?.raw ?? null })
  }
  return out.sort((a, b) => a.key.localeCompare(b.key))
}

function labelsDiffer(alt: VariantDefinition, neu: VariantDefinition): boolean {
  const a = alt.normalizedLabels.filter((l) => l.trim() !== '')
  const b = neu.normalizedLabels.filter((l) => l.trim() !== '')
  if (a.length !== b.length) return true
  return a.some((l, i) => l !== b[i])
}

// ── Container-wide dimension-key-rename correlation (KAR-937 F4) ───────────
//
// `dimensionValueChanges` above unions altDims/neuDims keys per matched
// variant pair with no correlation logic — a container-wide dimension
// descriptor-KEY rename ('Farbe' -> 'Farbe_Code', same value on both sides)
// therefore produces TWO independent non-cancelling entries (oldKey removed,
// newKey added) for EVERY matched variant that carries that dimension, and
// the caller below used to push every one of those variants into
// `renamed` — "N variants renamed" for what is really one cosmetic
// dimension-key rename event that changed no variant's own identity or
// dimension VALUE. This section detects that correlation once, container-
// wide, so the per-variant loop can suppress the (oldKey,newKey) entry pair
// wherever it fires and `dimensions.renamedKeys` can report it exactly once.
//
// Threshold rationale: 80% of ALL matched variant pairs (not merely those
// that carry the dimension at all) is a deliberately high bar — Master-
// Prompt §7 "never force an interpretation" applies here too: a rename that
// only affects a MINORITY of matched variants is more likely a genuine
// per-variant dimension change than a container-wide key rename, and is
// left exactly as-is (reported per-variant, not correlated/suppressed).
const DIMENSION_RENAME_CORRELATION_THRESHOLD = 0.8

interface DimensionRenameCandidate {
  oldEntry: VariantDimensionValueChange
  newEntry: VariantDimensionValueChange
  oldKey: string
  newKey: string
}

/** Every (removed-key, added-key) pair within ONE matched variant pair's own
 * `dimChanges` whose values are IDENTICAL — i.e. "this variant's value
 * merely moved from one key to another", the per-variant half of the
 * container-wide correlation check. A variant can appear in more than one
 * candidate here (ambiguous local case, e.g. two added keys coincidentally
 * carrying the same value as one removed key) — resolved by the container-
 * wide aggregation below only ever promoting a pair with enough
 * cross-variant support, never a single coincidental local match. */
function dimensionRenameCandidatesForPair(dimChanges: readonly VariantDimensionValueChange[]): DimensionRenameCandidate[] {
  const removedEntries = dimChanges.filter((e) => e.altRaw !== null && e.neuRaw === null)
  const addedEntries = dimChanges.filter((e) => e.altRaw === null && e.neuRaw !== null)
  const out: DimensionRenameCandidate[] = []
  for (const oldEntry of removedEntries) {
    for (const newEntry of addedEntries) {
      if (oldEntry.altRaw === newEntry.neuRaw) out.push({ oldEntry, newEntry, oldKey: oldEntry.key, newKey: newEntry.key })
    }
  }
  return out
}

function dimensionRenameToken(oldKey: string, newKey: string): string {
  // '::' separated — dimension keys are open (caller/header-derived)
  // identifier-shaped strings (e.g. 'region', 'driveType') that never
  // legitimately contain '::'.
  return `${oldKey}::${newKey}`
}

/** Container-wide detection: counts, across ALL matched variant pairs, how
 * often each (oldKey,newKey) pair from `dimensionRenameCandidatesForPair`
 * recurs, and promotes only the pairs clearing
 * `DIMENSION_RENAME_CORRELATION_THRESHOLD`. */
function detectDimensionKeyRenames(
  alt: MultiQafContainer,
  neu: MultiQafContainer,
  matchResult: readonly VariantMatchResult[],
): readonly DimensionKeyRenamedFinding[] {
  const altIndex = variantIndex(alt)
  const neuIndex = variantIndex(neu)
  let totalMatched = 0
  const counts = new Map<string, number>()
  for (const r of matchResult) {
    if (r.kind !== 'matched') continue
    totalMatched++
    const av = requireVariant(altIndex, r.leftId, 'alt')
    const nv = requireVariant(neuIndex, r.rightId, 'neu')
    const seenTokens = new Set<string>()
    for (const cand of dimensionRenameCandidatesForPair(dimensionValueChanges(av, nv))) {
      seenTokens.add(dimensionRenameToken(cand.oldKey, cand.newKey))
    }
    for (const token of seenTokens) counts.set(token, (counts.get(token) ?? 0) + 1)
  }
  if (totalMatched === 0) return []
  const out: DimensionKeyRenamedFinding[] = []
  for (const [token, count] of counts) {
    const share = count / totalMatched
    if (share < DIMENSION_RENAME_CORRELATION_THRESHOLD) continue
    const [oldKey, newKey] = token.split('::') as [string, string]
    out.push({ oldKey, newKey, correlatedVariantCount: count, correlatedVariantShare: share })
  }
  return out.sort((a, b) => a.oldKey.localeCompare(b.oldKey) || a.newKey.localeCompare(b.newKey))
}

/** Suppresses the (oldKey,newKey) entry pair from ONE matched variant pair's
 * `dimChanges` for every qualifying container-wide rename in `renamedTokens`
 * — applied per-variant (not blanket by key) so a variant whose value did
 * NOT correlate for this specific rename (a genuine rename+value-change
 * combo) keeps its dimensionChanges entries intact and visible. */
function suppressCorrelatedDimensionRenames(
  dimChanges: readonly VariantDimensionValueChange[],
  renamedTokens: ReadonlySet<string>,
): VariantDimensionValueChange[] {
  if (renamedTokens.size === 0) return [...dimChanges]
  const toSuppress = new Set<VariantDimensionValueChange>()
  for (const cand of dimensionRenameCandidatesForPair(dimChanges)) {
    if (!renamedTokens.has(dimensionRenameToken(cand.oldKey, cand.newKey))) continue
    toSuppress.add(cand.oldEntry)
    toSuppress.add(cand.newEntry)
  }
  return dimChanges.filter((e) => !toSuppress.has(e))
}

// ── Variants: added / removed / renamed / reordered / active-state ─────────

export interface VariantAddedFinding {
  neu: VariantIdentityRef
}

export interface VariantRemovedFinding {
  alt: VariantIdentityRef
}

export interface VariantRenamedFinding {
  alt: VariantIdentityRef
  neu: VariantIdentityRef
  labelsChanged: boolean
  dimensionChanges: readonly VariantDimensionValueChange[]
}

export interface VariantReorderedFinding {
  alt: VariantIdentityRef
  neu: VariantIdentityRef
}

export interface VariantActiveStateChangeFinding {
  alt: VariantIdentityRef
  neu: VariantIdentityRef
  altState: VariantActiveState
  neuState: VariantActiveState
}

// ── Variants: detail (material-matrix) <-> summary linkage ─────────────────

export type DetailLinkageStatus = 'linked' | 'orphaned' | 'not_applicable' | 'unknown'

export interface DetailLinkageEvidence {
  code: string
  message: string
  sourceReferences: readonly MultiQafCellRef[]
}

/**
 * A variant's detail-linkage status, derived STRUCTURALLY from the
 * container's own shared material matrix (see module header for why this is
 * the container's formula-lineage-derived proxy):
 *   - 'not_applicable': a reserved (no-identity) slot — container-
 *     assembly.ts's own `material_matrix_variant_never_referenced` producer
 *     excludes these for the same reason (having zero material rows is
 *     their normal, expected shape).
 *   - 'unknown': the container's shared material matrix has zero rows at
 *     all (no MATERIAL/BOM sheet found, or none extractable) — genuinely
 *     undecidable from this container, NOT the same claim as 'orphaned'
 *     (Master-Prompt §7: never turn "we could not check" into a negative
 *     finding).
 *   - 'linked': at least one shared-material row references this variant id
 *     in `quantityFactorByVariant` or `effectiveCostByVariant`.
 *   - 'orphaned': the matrix is non-trivially populated, but no row
 *     references this variant id at all.
 */
export function detailLinkageStatus(container: MultiQafContainer, variantId: string, activeState: VariantActiveState): DetailLinkageStatus {
  if (activeState === 'reserved') return 'not_applicable'
  if (container.sharedMaterialMaster.length === 0) return 'unknown'
  const linked = container.sharedMaterialMaster.some(
    (row) => variantId in row.quantityFactorByVariant || variantId in row.effectiveCostByVariant,
  )
  return linked ? 'linked' : 'orphaned'
}

function detailLinkageEvidenceFor(container: MultiQafContainer, variantId: string): DetailLinkageEvidence[] {
  return container.warnings
    .filter((w) => w.code === 'material_matrix_variant_never_referenced' && w.variantIds?.includes(variantId))
    .map((w) => ({ code: w.code, message: w.message, sourceReferences: w.sourceReferences }))
}

export interface VariantDetailLinkageTransition {
  alt: VariantIdentityRef
  neu: VariantIdentityRef
  altStatus: DetailLinkageStatus
  neuStatus: DetailLinkageStatus
  direction: 'gained_detail_link' | 'lost_detail_link'
  altEvidence: readonly DetailLinkageEvidence[]
  neuEvidence: readonly DetailLinkageEvidence[]
}

/**
 * `material_variant_unmatched_to_summary` warnings (container-assembly.ts)
 * — detail(Material)-sheet columns that could not be matched to ANY
 * container variant identity at all, genuinely un-attributable to a
 * variantId (that IS the finding). Diffed as a SET keyed STRUCTURALLY on
 * the producer's own `variantIds[0]` (the material-side column's own
 * dimension-VALUE-derived `stableInternalId` — position-independent, same
 * convention as every other identity in this module), never on the
 * warning's rendered message text: two genuinely DIFFERENT columns can
 * produce the identical message text after a column shift (a fixed column
 * and an independently newly-broken column landing on the same rendered
 * column letter with the same recurring boilerplate label) — KAR-937
 * adversarial review F2. Keying by message text would let such a pair
 * cancel out of the diff (both "unchanged"), silently losing both the fix
 * and the regression. A self-diff (ALT and NEU built from the identical
 * workbook) — which produces the identical warning set with the identical
 * structural ids on both sides — correctly comes out EMPTY here via
 * structural-key equality, not merely because the text happens to match. */
export interface DetailWithoutSummarySetDiff {
  /** Present in NEU's unattributed-column warnings, not in ALT's. */
  added: readonly DetailLinkageEvidence[]
  /** Present in ALT's unattributed-column warnings, not in NEU's. */
  removed: readonly DetailLinkageEvidence[]
}

/**
 * A variant that exists on only ONE side (i.e. already reported in
 * `added`/`removed` above) AND whose own-side `detailLinkageStatus` is
 * 'orphaned' — Master-Prompt §14 "variants present in the summary but
 * missing detail support", scoped to variants the diff already knows are
 * new/gone (a MATCHED variant's own orphaned-status CHANGE is reported by
 * `detailLinkageTransitions` above instead; an unchanging orphaned status on
 * a variant present on both sides is not itself a diff finding — nothing
 * about it differs between ALT and NEU). This scoping is also what keeps a
 * self-diff (0 added/removed variants) correctly empty here. */
export interface SummaryWithoutDetailFinding {
  side: 'alt' | 'neu'
  variant: VariantIdentityRef
  evidence: readonly DetailLinkageEvidence[]
}

interface UnattributedDetailColumnEntry {
  /** Structural dedup key — see DetailWithoutSummarySetDiff doc comment.
   * `id:${variantIds[0]}` when the producer set it (the normal case as of
   * KAR-937 F2); `msg:${message}` as a defensive fallback for a warning
   * that (against the field's own doc contract) never set `variantIds` —
   * degraded to the pre-fix text-keyed behavior for that entry only, never
   * for the whole set. */
  key: string
  evidence: DetailLinkageEvidence
}

function unattributedDetailColumnEntries(container: MultiQafContainer): UnattributedDetailColumnEntry[] {
  return container.warnings
    .filter((w) => w.code === 'material_variant_unmatched_to_summary')
    .map((w) => {
      const structuralId = w.variantIds?.[0]
      const key = structuralId !== undefined ? `id:${structuralId}` : `msg:${w.message}`
      return { key, evidence: { code: w.code, message: w.message, sourceReferences: w.sourceReferences } }
    })
}

function detailWithoutSummarySetDiff(alt: MultiQafContainer, neu: MultiQafContainer): DetailWithoutSummarySetDiff {
  const altList = unattributedDetailColumnEntries(alt)
  const neuList = unattributedDetailColumnEntries(neu)
  const altKeys = new Set(altList.map((e) => e.key))
  const neuKeys = new Set(neuList.map((e) => e.key))
  return {
    added: neuList
      .filter((e) => !altKeys.has(e.key))
      .map((e) => e.evidence)
      .sort((a, b) => a.message.localeCompare(b.message)),
    removed: altList
      .filter((e) => !neuKeys.has(e.key))
      .map((e) => e.evidence)
      .sort((a, b) => a.message.localeCompare(b.message)),
  }
}

export interface MultiQafVariantContainerDiff {
  added: readonly VariantAddedFinding[]
  removed: readonly VariantRemovedFinding[]
  renamed: readonly VariantRenamedFinding[]
  reordered: readonly VariantReorderedFinding[]
  /** ambiguous/split_suspected/merge_suspected results, passed through from
   * the caller's matchResult verbatim (never re-derived, never resolved
   * into a guessed matched/added/removed pair) — Master-Prompt §9 "never
   * automatically match... for commercial aggregation". */
  uncertainMatches: readonly (VariantAmbiguousResult | VariantSplitSuspectedResult | VariantMergeSuspectedResult)[]
  activeStateChanges: readonly VariantActiveStateChangeFinding[]
  detailLinkageTransitions: readonly VariantDetailLinkageTransition[]
  detailWithoutSummary: DetailWithoutSummarySetDiff
  summaryWithoutDetail: readonly SummaryWithoutDetailFinding[]
}

// ── Dimensions (container-wide) ─────────────────────────────────────────────

/** A dimension descriptor key that was renamed container-wide (e.g. 'Farbe'
 * -> 'Farbe_Code') — KAR-937 adversarial review F4. `oldKey`/`newKey` are
 * both still also present in `added`/`removed` (this finding never removes
 * them from there, only adds the correlation on top). Detected when the
 * SAME per-variant value moves from `oldKey` to `newKey` (a removed-key
 * entry and an added-key entry with an identical raw value, for the same
 * matched variant pair) for at least `correlatedVariantShare` of ALL
 * matched variant pairs — see `DIMENSION_RENAME_CORRELATION_THRESHOLD`. */
export interface DimensionKeyRenamedFinding {
  oldKey: string
  newKey: string
  /** How many matched variant pairs exhibited the correlated
   * removed-oldKey/added-newKey-with-identical-value transition. */
  correlatedVariantCount: number
  /** `correlatedVariantCount` / total matched variant pairs, 0..1. */
  correlatedVariantShare: number
}

export interface VariantDimensionKeyDiff {
  /** Dimension descriptor keys present in `neu.variantDimensions` but not
   * `alt.variantDimensions`. */
  added: readonly string[]
  /** Dimension descriptor keys present in `alt.variantDimensions` but not
   * `neu.variantDimensions`. */
  removed: readonly string[]
  /** Correlated added/removed key PAIRS that are really one rename event,
   * not two independent dimension changes — see `DimensionKeyRenamedFinding`.
   * A caller that wants "1 rename" instead of "1 added + 1 removed key" uses
   * this; `added`/`removed` above stay unfiltered for a caller that wants
   * the raw signal. */
  renamedKeys: readonly DimensionKeyRenamedFinding[]
}

// ── Shared material (identity/structure only — values are P2.4) ────────────

export interface SharedMaterialRowIdentityRef {
  canonicalComponentIdentity: string
  sourceRow: number
  sourceCells: readonly MultiQafCellRef[]
}

export interface SharedMaterialRowChangedFinding {
  canonicalComponentIdentity: string
  alt: SharedMaterialRowIdentityRef
  neu: SharedMaterialRowIdentityRef
  validationStatusChanged: boolean
  altValidationStatus: VariantMatrixRowValidationStatus
  neuValidationStatus: VariantMatrixRowValidationStatus
  /** Structural change of WHICH variant ids this row applies to (its
   * `quantityFactorByVariant`/`effectiveCostByVariant` key set) — never the
   * factor/cost VALUES themselves (P2.4). */
  referencedVariantIdsAdded: readonly string[]
  referencedVariantIdsRemoved: readonly string[]
}

export interface SharedMaterialStructureDiff {
  altRowCount: number
  neuRowCount: number
  /** Union, across all rows, of every variant id referenced anywhere in the
   * matrix's own `quantityFactorByVariant`/`effectiveCostByVariant` key
   * sets — the matrix's own "column set" (Master-Prompt §14 "changed matrix
   * structure"), independent of any single row. */
  altColumnVariantIds: readonly string[]
  neuColumnVariantIds: readonly string[]
  addedColumnVariantIds: readonly string[]
  removedColumnVariantIds: readonly string[]
}

export interface MultiQafSharedMaterialContainerDiff {
  structure: SharedMaterialStructureDiff
  addedRows: readonly SharedMaterialRowIdentityRef[]
  removedRows: readonly SharedMaterialRowIdentityRef[]
  changedRows: readonly SharedMaterialRowChangedFinding[]
}

// ── Shared profiles + bindings ──────────────────────────────────────────────

export interface ProfileIdentityRef {
  profileId: string
  kind: SharedCostProfileKind
  label: string | null
  sheet: string
  sourceReferences: readonly MultiQafCellRef[]
}

export interface ProfileValueMetadataChange {
  key: string
  /** `undefined` means the key is absent on that side (not merely `null`). */
  altValue: number | string | null | undefined
  neuValue: number | string | null | undefined
}

export interface VolumeBandThresholdChangeFinding {
  /** The ALT side's own raw profileId — display/provenance only (see `alt`/
   * `neu` for each side's own). Matching is done on structural profile
   * identity, not this field (KAR-937 F5, `profilesByStructuralIdentity`) —
   * `alt.profileId`/`neu.profileId` can legitimately differ across a row
   * shift while still being the SAME matched profile. */
  profileId: string
  alt: ProfileIdentityRef
  neu: ProfileIdentityRef
  /** ONLY threshold-defining keys (`threshold`, `band_N_upperBound`,
   * `band_N_lotCount` — see `isVolumeBandStructuralKey`) — never the raw
   * cost-component literals `componentValuesOnRow` (profile-parser.ts) also
   * folds into `SharedCostProfile.values` for every profile kind, volumeBand
   * included. KAR-937 adversarial review F3: this module promises VALUE
   * comparisons are out of scope (see module header) — diffing the full
   * `values` record here used to leak raw supplier cost figures through a
   * finding group named after (and scoped to) volume-band THRESHOLDS. */
  changes: readonly ProfileValueMetadataChange[]
}

export interface ProfileBindingChangeFinding {
  alt: VariantIdentityRef
  neu: VariantIdentityRef
  kind: SharedCostProfileKind
  altProfileId: string | null
  neuProfileId: string | null
  direction: 'bound' | 'unbound' | 'rebound'
}

export interface MultiQafProfileContainerDiff {
  /** Keyed on structural profile identity (kind + label + ordinal-within-
   * kind — `profilesByStructuralIdentity`), NOT the raw `profileId` cell
   * reference — KAR-937 F5. A profile whose Total cell merely shifted rows
   * (unrelated row insert/delete elsewhere on the sheet) is never reported
   * here; only a genuinely new/gone profile is. */
  added: readonly ProfileIdentityRef[]
  removed: readonly ProfileIdentityRef[]
  volumeBandThresholdChanges: readonly VolumeBandThresholdChangeFinding[]
  bindingChanges: readonly ProfileBindingChangeFinding[]
}

// ── Template (family/fingerprint/sheet-set/external-links) ─────────────────

export interface TemplateFamilyDiff {
  changed: boolean
  altFamily: MultiQafTemplateFamily
  neuFamily: MultiQafTemplateFamily
  altClassification: MultiQafFamilyClassification | null
  neuClassification: MultiQafFamilyClassification | null
  altStructuralHash: string | null
  neuStructuralHash: string | null
  structuralHashChanged: boolean
}

export interface SheetSetDiff {
  added: readonly string[]
  removed: readonly string[]
  /** Sheets present on BOTH sides that transitioned hidden-status — a sheet
   * that was added/removed entirely is reported once, in `added`/`removed`
   * above, never ALSO here. */
  becameHidden: readonly string[]
  becameVisible: readonly string[]
}

export interface NotTrackedFact {
  tracked: false
  note: string
}

export interface MultiQafTemplateContainerDiff {
  family: TemplateFamilyDiff
  sheetSet: SheetSetDiff
  externalLinks: NotTrackedFact
}

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

export interface MultiQafContainerDiffOptions {
  /** Additively FORCES `reviewRequired` to `true` on top of the always-
   * computed built-in signal (`reviewStatusForMultiQafContainer` on both
   * containers + presence of uncertain matches) — e.g. a caller that has
   * folded a persisted match override into its own decision and wants that
   * reflected too. `undefined`/absent (default) leaves the built-in
   * computation untouched.
   *
   * Deliberately NOT a plain `boolean`: KAR-937 adversarial review F1 found
   * that a `reviewRequired: false` override used to REPLACE the computed
   * signal wholesale, silently discarding a real
   * `uncertain_variant_matches_present` condition even though
   * `variants.uncertainMatches` was still populated — contradicting
   * Master-Prompt §9 ("never silently aggregate/hide uncertain matches").
   * The built-in review-required signal is a floor, never overridable
   * downward: this option can only ever ADD `true` on top of it, never
   * suppress it. `reviewRequiredReasons` always carries both the computed
   * reasons and (when present) `'caller_override'` side by side, so a
   * caller can tell the two apart. */
  forceReviewRequired?: true
}

export interface MultiQafContainerDiff {
  variants: MultiQafVariantContainerDiff
  dimensions: VariantDimensionKeyDiff
  sharedMaterial: MultiQafSharedMaterialContainerDiff
  profiles: MultiQafProfileContainerDiff
  template: MultiQafTemplateContainerDiff
  reviewRequired: boolean
  reviewRequiredReasons: readonly ('alt_container_review_required' | 'neu_container_review_required' | 'uncertain_variant_matches_present' | 'caller_override')[]
}

// ── diffContainers ──────────────────────────────────────────────────────────

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

function diffVariants(
  alt: MultiQafContainer,
  neu: MultiQafContainer,
  matchResult: readonly VariantMatchResult[],
  dimensionKeyRenames: readonly DimensionKeyRenamedFinding[],
): MultiQafVariantContainerDiff {
  const altIndex = variantIndex(alt)
  const neuIndex = variantIndex(neu)
  const renamedDimensionTokens = new Set(dimensionKeyRenames.map((r) => dimensionRenameToken(r.oldKey, r.newKey)))

  const added: VariantAddedFinding[] = []
  const removed: VariantRemovedFinding[] = []
  const renamed: VariantRenamedFinding[] = []
  const reordered: VariantReorderedFinding[] = []
  const uncertainMatches: (VariantAmbiguousResult | VariantSplitSuspectedResult | VariantMergeSuspectedResult)[] = []
  const activeStateChanges: VariantActiveStateChangeFinding[] = []
  const detailLinkageTransitions: VariantDetailLinkageTransition[] = []
  const summaryWithoutDetail: SummaryWithoutDetailFinding[] = []

  for (const r of matchResult) {
    if (isUncertainKind(r)) {
      uncertainMatches.push(r)
      continue
    }
    switch (r.kind) {
      case 'unmatched_left': {
        const av = requireVariant(altIndex, r.leftId, 'alt')
        removed.push({ alt: toIdentityRef(av) })
        if (detailLinkageStatus(alt, av.stableInternalId, av.activeState) === 'orphaned') {
          summaryWithoutDetail.push({ side: 'alt', variant: toIdentityRef(av), evidence: detailLinkageEvidenceFor(alt, av.stableInternalId) })
        }
        break
      }
      case 'unmatched_right': {
        const nv = requireVariant(neuIndex, r.rightId, 'neu')
        added.push({ neu: toIdentityRef(nv) })
        if (detailLinkageStatus(neu, nv.stableInternalId, nv.activeState) === 'orphaned') {
          summaryWithoutDetail.push({ side: 'neu', variant: toIdentityRef(nv), evidence: detailLinkageEvidenceFor(neu, nv.stableInternalId) })
        }
        break
      }
      case 'matched': {
        const av = requireVariant(altIndex, r.leftId, 'alt')
        const nv = requireVariant(neuIndex, r.rightId, 'neu')
        const altRef = toIdentityRef(av)
        const neuRef = toIdentityRef(nv)

        // KAR-937 F4: suppress the (oldKey,newKey) entry pair for any
        // container-wide dimension-key rename detected across ALL matched
        // pairs (see detectDimensionKeyRenames/dimensions.renamedKeys) — a
        // variant whose value did NOT correlate for this specific rename
        // (a genuine rename+value-change combo) keeps its own entries.
        const dimChanges = suppressCorrelatedDimensionRenames(dimensionValueChanges(av, nv), renamedDimensionTokens)
        const labelsChanged = labelsDiffer(av, nv)
        if (labelsChanged || dimChanges.length > 0) {
          renamed.push({ alt: altRef, neu: neuRef, labelsChanged, dimensionChanges: dimChanges })
        }

        if (av.originalColumn !== nv.originalColumn || av.originalVariantNumber !== nv.originalVariantNumber) {
          reordered.push({ alt: altRef, neu: neuRef })
        }

        if (av.activeState !== nv.activeState) {
          activeStateChanges.push({ alt: altRef, neu: neuRef, altState: av.activeState, neuState: nv.activeState })
        }

        const altStatus = detailLinkageStatus(alt, av.stableInternalId, av.activeState)
        const neuStatus = detailLinkageStatus(neu, nv.stableInternalId, nv.activeState)
        if (altStatus !== neuStatus && (altStatus === 'linked' || altStatus === 'orphaned') && (neuStatus === 'linked' || neuStatus === 'orphaned')) {
          detailLinkageTransitions.push({
            alt: altRef,
            neu: neuRef,
            altStatus,
            neuStatus,
            direction: neuStatus === 'linked' ? 'gained_detail_link' : 'lost_detail_link',
            altEvidence: detailLinkageEvidenceFor(alt, av.stableInternalId),
            neuEvidence: detailLinkageEvidenceFor(neu, nv.stableInternalId),
          })
        }
        break
      }
    }
  }

  const byVariantIdAsc = <T extends { alt?: VariantIdentityRef; neu?: VariantIdentityRef }>(a: T, b: T): number =>
    (a.alt?.variantId ?? a.neu?.variantId ?? '').localeCompare(b.alt?.variantId ?? b.neu?.variantId ?? '')

  return {
    added: added.sort((a, b) => a.neu.variantId.localeCompare(b.neu.variantId)),
    removed: removed.sort((a, b) => a.alt.variantId.localeCompare(b.alt.variantId)),
    renamed: renamed.sort(byVariantIdAsc),
    reordered: reordered.sort(byVariantIdAsc),
    uncertainMatches: [...uncertainMatches].sort((a, b) => a.explanation.localeCompare(b.explanation)),
    activeStateChanges: activeStateChanges.sort(byVariantIdAsc),
    detailLinkageTransitions: detailLinkageTransitions.sort(byVariantIdAsc),
    detailWithoutSummary: detailWithoutSummarySetDiff(alt, neu),
    summaryWithoutDetail: summaryWithoutDetail.sort((a, b) => a.side.localeCompare(b.side) || a.variant.variantId.localeCompare(b.variant.variantId)),
  }
}

function diffDimensions(alt: MultiQafContainer, neu: MultiQafContainer): Omit<VariantDimensionKeyDiff, 'renamedKeys'> {
  const altKeys = new Set(alt.variantDimensions.map((d) => d.key))
  const neuKeys = new Set(neu.variantDimensions.map((d) => d.key))
  return {
    added: [...neuKeys].filter((k) => !altKeys.has(k)).sort(),
    removed: [...altKeys].filter((k) => !neuKeys.has(k)).sort(),
  }
}

function referencedVariantIds(row: { quantityFactorByVariant: Readonly<Record<string, number>>; effectiveCostByVariant: Readonly<Record<string, number | null>> }): Set<string> {
  return new Set([...Object.keys(row.quantityFactorByVariant), ...Object.keys(row.effectiveCostByVariant)])
}

function materialStructure(container: MultiQafContainer): { rowCount: number; columnVariantIds: string[] } {
  const ids = new Set<string>()
  for (const row of container.sharedMaterialMaster) for (const id of referencedVariantIds(row)) ids.add(id)
  return { rowCount: container.sharedMaterialMaster.length, columnVariantIds: [...ids].sort() }
}

function toRowIdentityRef(row: { canonicalComponentIdentity: string; sourceRow: number; sourceCells: readonly MultiQafCellRef[] }): SharedMaterialRowIdentityRef {
  return { canonicalComponentIdentity: row.canonicalComponentIdentity, sourceRow: row.sourceRow, sourceCells: row.sourceCells }
}

function diffSharedMaterial(alt: MultiQafContainer, neu: MultiQafContainer): MultiQafSharedMaterialContainerDiff {
  const altStruct = materialStructure(alt)
  const neuStruct = materialStructure(neu)
  const altColSet = new Set(altStruct.columnVariantIds)
  const neuColSet = new Set(neuStruct.columnVariantIds)

  // Last-parsed row wins per canonicalComponentIdentity — a duplicate
  // identity within one side is a known, separately-flagged parser
  // condition (material-matrix-parser.ts's own
  // 'material_matrix_row_identity_collision' warning); resolving that is
  // out of scope for this structural container-diff (P2.4 territory once
  // it touches VALUES at all).
  const altRows = new Map(alt.sharedMaterialMaster.map((r) => [r.canonicalComponentIdentity, r] as const))
  const neuRows = new Map(neu.sharedMaterialMaster.map((r) => [r.canonicalComponentIdentity, r] as const))

  const addedRows: SharedMaterialRowIdentityRef[] = []
  const removedRows: SharedMaterialRowIdentityRef[] = []
  const changedRows: SharedMaterialRowChangedFinding[] = []

  for (const [identity, neuRow] of neuRows) {
    const altRow = altRows.get(identity)
    if (!altRow) {
      addedRows.push(toRowIdentityRef(neuRow))
      continue
    }
    const altIds = referencedVariantIds(altRow)
    const neuIds = referencedVariantIds(neuRow)
    const refAdded = [...neuIds].filter((id) => !altIds.has(id)).sort()
    const refRemoved = [...altIds].filter((id) => !neuIds.has(id)).sort()
    const validationStatusChanged = altRow.validationStatus !== neuRow.validationStatus
    if (validationStatusChanged || refAdded.length > 0 || refRemoved.length > 0) {
      changedRows.push({
        canonicalComponentIdentity: identity,
        alt: toRowIdentityRef(altRow),
        neu: toRowIdentityRef(neuRow),
        validationStatusChanged,
        altValidationStatus: altRow.validationStatus,
        neuValidationStatus: neuRow.validationStatus,
        referencedVariantIdsAdded: refAdded,
        referencedVariantIdsRemoved: refRemoved,
      })
    }
  }
  for (const [identity, altRow] of altRows) {
    if (!neuRows.has(identity)) removedRows.push(toRowIdentityRef(altRow))
  }

  return {
    structure: {
      altRowCount: altStruct.rowCount,
      neuRowCount: neuStruct.rowCount,
      altColumnVariantIds: altStruct.columnVariantIds,
      neuColumnVariantIds: neuStruct.columnVariantIds,
      addedColumnVariantIds: [...neuColSet].filter((id) => !altColSet.has(id)).sort(),
      removedColumnVariantIds: [...altColSet].filter((id) => !neuColSet.has(id)).sort(),
    },
    addedRows: addedRows.sort((a, b) => a.canonicalComponentIdentity.localeCompare(b.canonicalComponentIdentity)),
    removedRows: removedRows.sort((a, b) => a.canonicalComponentIdentity.localeCompare(b.canonicalComponentIdentity)),
    changedRows: changedRows.sort((a, b) => a.canonicalComponentIdentity.localeCompare(b.canonicalComponentIdentity)),
  }
}

function allProfiles(container: MultiQafContainer) {
  return [...container.sharedManufacturingProfiles, ...container.setupCostProfiles, ...container.sharedToolingData]
}

function toProfileIdentityRef(p: { profileId: string; kind: SharedCostProfileKind; label: string | null; sheet: string; sourceReferences: readonly MultiQafCellRef[] }): ProfileIdentityRef {
  return { profileId: p.profileId, kind: p.kind, label: p.label, sheet: p.sheet, sourceReferences: p.sourceReferences }
}

function diffProfileValues(
  altValues: Readonly<Record<string, number | string | null>>,
  neuValues: Readonly<Record<string, number | string | null>>,
): ProfileValueMetadataChange[] {
  const keys = new Set([...Object.keys(altValues), ...Object.keys(neuValues)])
  const out: ProfileValueMetadataChange[] = []
  for (const key of keys) {
    const altHas = key in altValues
    const neuHas = key in neuValues
    const altValue = altHas ? altValues[key] : undefined
    const neuValue = neuHas ? neuValues[key] : undefined
    if (altValue === neuValue) continue
    out.push({ key, altValue, neuValue })
  }
  return out.sort((a, b) => a.key.localeCompare(b.key))
}

// ── VolumeBandThresholdChangeFinding whitelist (KAR-937 F3) ────────────────
//
// The only keys `SharedCostProfile.values` ever carries for kind ===
// 'volumeBand' that actually DEFINE the band threshold structure itself:
//   - 'threshold'              (profile-parser.ts buildProfileFromCandidate,
//                                a single Total-row-shaped volumeBand block)
//   - 'band_<n>_upperBound' /
//     'band_<n>_lotCount'      (profile-parser.ts locateVolumeBandLookupTable,
//                                a lookup-table-shaped volumeBand block)
// Every OTHER key `componentValuesOnRow` (profile-parser.ts) folds into the
// SAME `values` record — for every profile kind, volumeBand included — is a
// raw cost-component literal read off a neighbouring cell (arbitrary
// column-letter keys like 'U', 'AB', or 'totalPerUnit'), i.e. exactly the
// VALUE data this module's header promises stays out of scope until P2.5.
const VOLUME_BAND_STRUCTURAL_KEY_RE = /^band_\d+_(upperBound|lotCount)$/

/** Exported for reuse by profile-differ.ts (KAR-939/P2.5) — that module's
 * own §12-item-1/2 value-level `values`-Record diff must exclude exactly
 * these same threshold-defining keys, so a volume-band's threshold change
 * is reported ONCE (here, as `volumeBandThresholdChanges`) and never a
 * second time as a generic "component value changed" finding over there —
 * see profile-differ.ts's own module header for the full boundary writeup. */
export function isVolumeBandStructuralKey(key: string): boolean {
  return key === 'threshold' || VOLUME_BAND_STRUCTURAL_KEY_RE.test(key)
}

function volumeBandStructuralValues(values: Readonly<Record<string, number | string | null>>): Record<string, number | string | null> {
  const out: Record<string, number | string | null> = {}
  for (const [key, value] of Object.entries(values)) {
    if (isVolumeBandStructuralKey(key)) out[key] = value
  }
  return out
}

// ── Profile structural identity (KAR-937 adversarial review F5) ────────────
//
// profile-parser.ts's own `profileId` is a literal `${sheet}!${cell}` Total-
// cell reference (buildProfileFromCandidate/locateVolumeBandLookupTable) —
// unlike sharedMaterialMaster's `canonicalComponentIdentity` (material-
// matrix-parser.ts), it is NOT row-shift-invariant: a single row inserted or
// deleted above a manufacturing-profile block shifts every profile's own
// Total cell downward, and therefore every profile's profileId, even though
// none of them structurally changed. Keying `diffProfiles`'s added/removed/
// volumeBandThresholdChanges on the raw profileId (as this module did before
// this fix) turns that purely cosmetic layout edit into a wall of
// removed-then-added noise for the ENTIRE profile set.
//
// Two-stage identity instead: primarily (kind, normalized label slug); the
// raw profileId/sourceReferences are carried along ONLY as display metadata
// on `ProfileIdentityRef`, never as the matching key. An ordinal (this
// profile's occurrence count within the SAME kind, in this container's own
// natural parse order — itself unaffected by a mere row shift, since a
// shift moves every profile in the block uniformly and neither reorders nor
// adds/removes profiles) disambiguates two genuinely DISTINCT profiles of
// the same kind that happen to carry the identical (or absent) label —
// "collision of the structural identity falls back to the ordinal", per the
// same discipline material-matrix-parser.ts uses for a duplicate
// canonicalComponentIdentity within one side.
//
// Known residual scope: `profileBindingsByKind`/`bindingChanges` below still
// compare RAW profileId strings (a variant's binding evidence references a
// profile by its own raw profileId, same as the container stores it) — a
// row-shift could in principle also perturb a binding comparison. Not fixed
// here: F5's own finding scope is `diffProfiles`'s added/removed only, and
// widening `bindingChanges` to structural identity needs
// `VariantProfileBinding` itself to carry (or resolve to) the same
// structural key, which is a larger, separately-scoped change.
/** Exported for reuse by profile-differ.ts (KAR-939/P2.5) — the VALUE-level
 * sibling of this module's own structural diff needs the IDENTICAL
 * row-shift-invariant profile identity so the two modules never disagree on
 * "is this the same profile" (same discipline material-differ.ts's `pairRows`
 * doc already established for shared-material row pairing: reuse the
 * identity algorithm verbatim, never re-derive a second one that could
 * silently diverge from this one on a real file). */
export function profileLabelSlug(label: string | null): string {
  if (label === null) return '∅'
  const slug = label.trim().toLowerCase().replace(/\s+/g, ' ')
  return slug === '' ? '∅' : slug
}

/** Exported for reuse by profile-differ.ts — see `profileLabelSlug` doc
 * comment immediately above for why this specific function (and no other
 * helper in this module) is shared rather than duplicated. */
export function profilesByStructuralIdentity(container: MultiQafContainer): Map<string, ReturnType<typeof allProfiles>[number]> {
  const ordinalByKind = new Map<SharedCostProfileKind, number>()
  const out = new Map<string, ReturnType<typeof allProfiles>[number]>()
  for (const p of allProfiles(container)) {
    const ordinal = ordinalByKind.get(p.kind) ?? 0
    ordinalByKind.set(p.kind, ordinal + 1)
    out.set(`${p.kind}::${profileLabelSlug(p.label)}::${ordinal}`, p)
  }
  return out
}

/**
 * Invariant this function relies on (KAR-937 adversarial review F6,
 * PLAUSIBLE/currently-unreachable): `container.variantProfileBindings`
 * carries AT MOST ONE binding per (variantId, resolved kind) — today's sole
 * producer (profile-parser.ts's `resolveVariantProfileBindings`) guarantees
 * this. A silent `Map.set` last-write-wins here would, if that invariant
 * were ever violated (e.g. a future producer bug emitting a stale duplicate
 * binding), silently drop a real add/remove of a same-kind binding from
 * `diffProfiles`' `bindingChanges` — contradicting this module's own claim
 * that binding changes are always reported. Asserted defensively below
 * instead of guessed at: a genuine violation throws rather than silently
 * picking one of the two bindings.
 */
function profileBindingsByKind(container: MultiQafContainer, variantId: string): Map<SharedCostProfileKind, string> {
  const kindById = new Map(allProfiles(container).map((p) => [p.profileId, p.kind] as const))
  const out = new Map<SharedCostProfileKind, string>()
  for (const b of container.variantProfileBindings) {
    if (b.variantId !== variantId) continue
    const kind = kindById.get(b.profileId)
    if (kind === undefined) continue // profileId not resolvable in either profile pool — nothing to attribute a kind to, skip defensively rather than fabricate one.
    const existing = out.get(kind)
    if (existing !== undefined && existing !== b.profileId) {
      throw new Error(
        `diffContainers: variant "${variantId}" has more than one profile binding resolving to kind "${kind}" (profileIds "${existing}" and "${b.profileId}") — profileBindingsByKind assumes at most one binding per (variantId, kind); this container violates that invariant, see profile-parser.ts's resolveVariantProfileBindings contract.`,
      )
    }
    out.set(kind, b.profileId)
  }
  return out
}

function diffProfiles(alt: MultiQafContainer, neu: MultiQafContainer, matchResult: readonly VariantMatchResult[]): MultiQafProfileContainerDiff {
  const altProfiles = profilesByStructuralIdentity(alt)
  const neuProfiles = profilesByStructuralIdentity(neu)

  const added = [...neuProfiles.entries()].filter(([key]) => !altProfiles.has(key)).map(([, p]) => toProfileIdentityRef(p))
  const removed = [...altProfiles.entries()].filter(([key]) => !neuProfiles.has(key)).map(([, p]) => toProfileIdentityRef(p))

  const volumeBandThresholdChanges: VolumeBandThresholdChangeFinding[] = []
  for (const [key, altP] of altProfiles) {
    const neuP = neuProfiles.get(key)
    if (!neuP || altP.kind !== 'volumeBand' || neuP.kind !== 'volumeBand') continue
    const changes = diffProfileValues(volumeBandStructuralValues(altP.values), volumeBandStructuralValues(neuP.values))
    if (changes.length > 0) volumeBandThresholdChanges.push({ profileId: altP.profileId, alt: toProfileIdentityRef(altP), neu: toProfileIdentityRef(neuP), changes })
  }

  const altVariantIndex = variantIndex(alt)
  const neuVariantIndex = variantIndex(neu)
  const bindingChanges: ProfileBindingChangeFinding[] = []
  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 altBindings = profileBindingsByKind(alt, av.stableInternalId)
    const neuBindings = profileBindingsByKind(neu, nv.stableInternalId)
    const kinds = new Set<SharedCostProfileKind>([...altBindings.keys(), ...neuBindings.keys()])
    for (const kind of kinds) {
      const altProfileId = altBindings.get(kind) ?? null
      const neuProfileId = neuBindings.get(kind) ?? null
      if (altProfileId === neuProfileId) continue
      const direction: ProfileBindingChangeFinding['direction'] = altProfileId === null ? 'bound' : neuProfileId === null ? 'unbound' : 'rebound'
      bindingChanges.push({ alt: toIdentityRef(av), neu: toIdentityRef(nv), kind, altProfileId, neuProfileId, direction })
    }
  }

  return {
    added: added.sort((a, b) => a.profileId.localeCompare(b.profileId)),
    removed: removed.sort((a, b) => a.profileId.localeCompare(b.profileId)),
    volumeBandThresholdChanges: volumeBandThresholdChanges.sort((a, b) => a.profileId.localeCompare(b.profileId)),
    bindingChanges: bindingChanges.sort((a, b) => a.alt.variantId.localeCompare(b.alt.variantId) || a.kind.localeCompare(b.kind)),
  }
}

function diffTemplate(alt: MultiQafContainer, neu: MultiQafContainer): MultiQafTemplateContainerDiff {
  const altFp = alt.templateFingerprint
  const neuFp = neu.templateFingerprint

  const altSheets = new Set(alt.sourceWorkbook.sheetNames)
  const neuSheets = new Set(neu.sourceWorkbook.sheetNames)
  const sheetAdded = [...neuSheets].filter((s) => !altSheets.has(s)).sort()
  const sheetRemoved = [...altSheets].filter((s) => !neuSheets.has(s)).sort()

  const altHidden = new Set(alt.sourceWorkbook.hiddenSheetNames)
  const neuHidden = new Set(neu.sourceWorkbook.hiddenSheetNames)
  // Only sheets present on BOTH sides can meaningfully "transition" hidden
  // status — a sheet added/removed entirely already surfaces in
  // sheetAdded/sheetRemoved above and must never ALSO appear here.
  const commonSheets = [...altSheets].filter((s) => neuSheets.has(s))
  const becameHidden = commonSheets.filter((s) => !altHidden.has(s) && neuHidden.has(s)).sort()
  const becameVisible = commonSheets.filter((s) => altHidden.has(s) && !neuHidden.has(s)).sort()

  return {
    family: {
      changed: altFp.family !== neuFp.family,
      altFamily: altFp.family,
      neuFamily: neuFp.family,
      altClassification: altFp.classification,
      neuClassification: neuFp.classification,
      altStructuralHash: altFp.structuralHash,
      neuStructuralHash: neuFp.structuralHash,
      structuralHashChanged: altFp.structuralHash !== neuFp.structuralHash,
    },
    sheetSet: { added: sheetAdded, removed: sheetRemoved, becameHidden, becameVisible },
    externalLinks: {
      tracked: false,
      note:
        'MultiQafContainer carries no external-link inventory (MultiQafSourceWorkbookMeta has no such field) — this Master-Prompt §14 bullet cannot be answered from two MultiQafContainer objects alone; reported as not_tracked, never as "unchanged".',
    },
  }
}

/**
 * Structural (never value-level — see module header) comparison of two
 * finished MultiQafContainer objects. `matchResult` must have been computed
 * by `matchVariants`/`matchVariantsWithOverrides` with LEFT drawn from
 * `alt`'s own variants and RIGHT drawn from `neu`'s own variants (see
 * module header's fail-closed contract — an unresolvable id throws).
 */
export function diffContainers(
  alt: MultiQafContainer,
  neu: MultiQafContainer,
  matchResult: readonly VariantMatchResult[],
  options: MultiQafContainerDiffOptions = {},
): MultiQafContainerDiff {
  const dimensionKeyRenames = detectDimensionKeyRenames(alt, neu, matchResult)
  const variants = diffVariants(alt, neu, matchResult, dimensionKeyRenames)
  const dimensions = { ...diffDimensions(alt, neu), renamedKeys: dimensionKeyRenames }
  const sharedMaterial = diffSharedMaterial(alt, neu)
  const profiles = diffProfiles(alt, neu, matchResult)
  const template = diffTemplate(alt, neu)

  // KAR-937 adversarial review F1: the computed signal below (in particular
  // 'uncertain_variant_matches_present') is a floor that options.
  // forceReviewRequired can only ADD 'true' on top of, never replace or
  // suppress — see MultiQafContainerDiffOptions.forceReviewRequired doc.
  const altReviewRequired = reviewStatusForMultiQafContainer(alt) === 'review_required'
  const neuReviewRequired = reviewStatusForMultiQafContainer(neu) === 'review_required'
  const uncertainPresent = variants.uncertainMatches.length > 0
  const reasons: MultiQafContainerDiff['reviewRequiredReasons'][number][] = []
  if (altReviewRequired) reasons.push('alt_container_review_required')
  if (neuReviewRequired) reasons.push('neu_container_review_required')
  if (uncertainPresent) reasons.push('uncertain_variant_matches_present')
  if (options.forceReviewRequired) reasons.push('caller_override')
  const reviewRequired = altReviewRequired || neuReviewRequired || uncertainPresent || options.forceReviewRequired === true
  const reviewRequiredReasons: MultiQafContainerDiff['reviewRequiredReasons'] = reasons

  return { variants, dimensions, sharedMaterial, profiles, template, reviewRequired, reviewRequiredReasons }
}
