// Multi-QAF Varianten-Matching mit Review-Workflow (KAR-936 / Multi-QAF-
// Programm P2.2, Epic KAR-925).
//
// Two call sites this module serves (both share the exact same cascade —
// "one matcher, two callers" rather than two near-duplicate implementations):
//
//   (A) Cross-container matching (the Master-Prompt §9 core case): matching
//       the variants of TWO MultiQafContainers (ALT vs. NEU) for a future
//       P2.3 container-level compare. `left`/`right` below map onto ALT/NEU.
//   (B) Cross-sheet matching WITHIN one container — the gap container-
//       assembly.ts's own module header names explicitly ("Known scope
//       limits... only the P2.2 fuzzy matcher can [recover those files'
//       material totals]"): container-assembly.ts's matchMaterialVariantsToContainer
//       matches the Material sheet's own independently re-parsed variants
//       against the Summary-sourced container variants. Its exact-RAW-key
//       first pass stays untouched; this module is wired in as a FALLBACK
//       second pass for whatever that exact pass could not resolve (see
//       that function's own updated doc comment for exactly how). `left`/
//       `right` there map onto container-variants/material-variants.
//
// ── Matching cascade (Master-Prompt §9's tolerance list, matcher.ts's
// 5-stage ALT/NEU cascade is the direct architectural precedent — same
// "each stage catches what the previous stage could not, in strictly
// decreasing confidence order" discipline) ─────────────────────────────────
//
//   1. raw_exact       — identical RAW (pre-disambiguation) composite-
//                         canonical-key (identity.ts buildCompositeCanonicalKey)
//                         on ALL of a variant's own dimensions. Confidence 1.
//                         Tolerates: reorder, rename-that-normalizes-equal,
//                         column move (the key never encodes column/position
//                         — identity.ts's own stability guarantee).
//   2. core_subset_exact — the dimension keys BOTH sides actually populated
//                         (their intersection — "Kern-Dimensionen") agree
//                         EXACTLY; dimension keys present on only one side are
//                         ignored rather than forcing a mismatch. This is the
//                         real-file fix (KAR-935 module-header "Known scope
//                         limits": Material-sheet re-parses pick up DIFFERENT
//                         extra/unknown dimension labels than Summary's own
//                         re-parse for 3 of 4 real files, so their RAW keys
//                         never agree byte-for-byte even though the STABLE
//                         dimensions do). Confidence scales with how much of
//                         each side's own dimension set the shared subset
//                         actually covers (`coverage`) — never a flat constant,
//                         so a match resting on 1 of 5 dimensions is visibly
//                         less certain than one resting on 4 of 5.
//   3. fuzzy_label_similarity — same core-dimension-subset scope as stage 2,
//                         but each dimension VALUE is compared via
//                         normalizeDimensionValue (already applied at
//                         VariantDimensionValue construction) + a small DE/EN
//                         value-synonym table (see below) + conservative
//                         Levenshtein-based similarity (lib/duplicates/fuzzy.ts
//                         — reused, not reinvented) for genuine typos. A
//                         per-dimension comparison below the similarity floor
//                         counts as a hard 0, dragging the pair's aggregate
//                         score down rather than being silently ignored —
//                         tolerates "additional descriptive row"/spelling
//                         differences/aliases without conflating them with an
//                         actually-different variant.
//   4. Structural anchor (originalVariantNumber/slot equality, else relative
//      closeness of whichever volume field is populated) — NEVER a stage of
//      its own (never creates a candidate pair on its own: Master-Prompt §9
//      "produce a match confidence and explanation" implies the confidence
//      must come from IDENTITY evidence, and volume is payload/business data
//      that legitimately differs between an ALT/NEU snapshot of the same
//      variant — using it as identity would make matches unstable across a
//      routine volume update). Used ONLY to break a genuine tie between two
//      otherwise-equal-ranked candidates competing for the same slot in the
//      greedy assignment below — see matchVariants' own doc comment.
//
// ── Result classes (Master-Prompt §9: "Do not automatically match ambiguous
// variants for commercial aggregation... allow authorized users to review and
// override uncertain mappings") ─────────────────────────────────────────────
//   matched          — unique winner, one stage + confidence + evidence[].
//   ambiguous        — 2+ candidates tied even after the structural-anchor
//                       tiebreak (a genuine "cannot tell them apart from
//                       identity evidence alone" case, incl. duplicate-
//                       looking variants — see detectSplits/detectMerges' own
//                       doc comment for why a duplicate never gets mistaken
//                       for a split) — reviewRelevant, NEVER auto-aggregated.
//   split_suspected  — one LEFT variant's identity plausibly spread across
//                       2+ RIGHT variants (1→n) — reviewRelevant.
//   merge_suspected  — 2+ LEFT variants plausibly collapsed into one RIGHT
//                       variant (n→1) — reviewRelevant.
//   unmatched_left   — no right-side candidate at all ("removed").
//   unmatched_right  — no left-side candidate at all ("added").
//
// ── Review-workflow types (VariantMatchOverride, matchVariantsWithOverrides)
// ────────────────────────────────────────────────────────────────────────
// Architecturally identical to field-mapping-override.ts's
// FieldMappingOverride/carryForwardFieldMappingOverrides pair, applied to
// variant PAIRS instead of a single row's field value: an override is
// re-identified by the variant's own compositeCanonicalKey + a dimensions
// snapshot (NEVER by column position/index — Master-Prompt §9's own
// identity doctrine applied to the override mechanism itself, the same
// "never re-point at an unrelated row by position alone" caution field-
// mapping-override.ts's own module header documents for its rowKey guard).
// An override whose snapshot no longer matches any current variant (a
// genuine identity drift — a re-parse changed the dimension set enough that
// buildCompositeCanonicalKey now produces something else) is DROPPED, not
// silently re-applied to a different, unrelated variant — reported back via
// `droppedOverrides` so a caller can surface it the same way
// fieldMappingOverrideDroppedIssues does. A winning override always beats
// whatever the automatic cascade would have produced for that variant.
//
// No dedicated serialize/deserialize pair is needed for either
// VariantMatchOverride or VariantMatchResult (unlike serialization.ts's
// MultiQafContainer functions): both are already plain JSON-safe data (no
// Map/Set/Date — same "additive, JSON-serializable" discipline types.ts's
// own module header establishes for VariantDimensions), so a caller
// persists them with a plain JSON.stringify/JSON.parse round-trip, exactly
// like field-mapping-override.ts's FieldMappingOverride is stored directly
// in qaf_file.g60_meta.fieldMappingOverrides with no wrapper function.
//
// Persistence WIRING to the database (a comparison-scoped
// qaf_file.g60_meta.matchOverrides bag, analogous to fieldMappingOverrides)
// is explicitly NOT this module's job — task brief: "Persistenz-Wiring an
// die DB ist NICHT dieses PR (kommt mit P2.3-Compare-Flow)". This module
// only defines the pure logic + types + (JSON-trivial) serialization shape
// a P2.3 caller wires up.
//
// tdd-guard: covered by __tests__/variant-matcher.test.ts (synthetic, every
// Master-Prompt §9 tolerance scenario) and
// __tests__/variant-matcher.real-files.test.ts (env-gated: self-match +
// the container-assembly.ts cross-sheet fallback wiring).

import { normalizedSimilarity } from '@/lib/duplicates/fuzzy'
import type { VariantDefinition, VariantDimensions, VariantDimensionValue } from './types'

function round3(n: number): number {
  return Number(n.toFixed(3))
}

// ── DE/EN dimension-VALUE synonym table (Stufe 3) ───────────────────────────
// identity.ts's own module header explicitly deferred this exact gap ("no
// real cross-language dimension-VALUE evidence exists yet... a future P2.2
// item") — this is that item. Deliberately small and evidence-conservative:
// common, unambiguous automotive DE/EN word pairs a supplier might plausibly
// free-type into a dimension cell (steering side, drive/powertrain type,
// ratio-constancy) — NOT a general-purpose translation dictionary. Compared
// on already-normalized (normalizeDimensionValue: lowercase, umlaut-folded,
// whitespace-collapsed, punctuation preserved — "con."/"var." stay intact,
// matching 10-analyse-nafta.md's own literal ratioConstancy values) tokens.
// Extend only with real evidence from a newly analyzed file, same discipline
// identity.ts's own header applies to KNOWN_VARIANT_DIMENSION_KEYS.
const DIMENSION_VALUE_SYNONYM_GROUPS: readonly (readonly string[])[] = [
  ['links', 'left', 'll', 'lhd'],
  ['rechts', 'right', 'rl', 'rhd'],
  ['vorne', 'vorn', 'front', 'fwd'],
  ['hinten', 'heck', 'rear', 'rwd'],
  ['allrad', 'awd', '4wd'],
  ['konstant', 'constant', 'con.', 'con'],
  ['variabel', 'variable', 'var.', 'var'],
  ['standard', 'std'],
  ['benzin', 'petrol', 'gasoline', 'otto'],
  ['elektrisch', 'electric', 'ev'],
]

const DIMENSION_VALUE_SYNONYM_LOOKUP: ReadonlyMap<string, number> = (() => {
  const map = new Map<string, number>()
  DIMENSION_VALUE_SYNONYM_GROUPS.forEach((group, idx) => {
    for (const token of group) map.set(token, idx)
  })
  return map
})()

function synonymGroup(normalized: string): number | null {
  return DIMENSION_VALUE_SYNONYM_LOOKUP.get(normalized) ?? null
}

// ── Configuration (matcher.ts's MatchConfig is the architectural precedent
// for making cascade thresholds an explicit, overridable input rather than
// buried constants) ─────────────────────────────────────────────────────────

export interface VariantMatchConfig {
  /** Stage-2 (core_subset_exact) confidence floor, before the coverage bonus. */
  coreSubsetBaseConfidence: number
  /** Stage-2 confidence added per unit of `coverage` (coreKeys / unionKeys). */
  coreSubsetCoverageWeight: number
  /** Stage-2 confidence ceiling — deliberately below 1 (raw_exact's reserved
   * value), since ignoring extra dimensions is inherently less certain than
   * an exact full-key match. */
  coreSubsetMaxConfidence: number
  /** Per-dimension Levenshtein-similarity floor (lib/duplicates/fuzzy.ts
   * normalizedSimilarity, 0..1) below which a stage-3 dimension comparison
   * counts as a hard mismatch (0) rather than a partial-credit typo. */
  fuzzyMinPerDimensionSimilarity: number
  /** Minimum stage-3 aggregate (average per-dimension similarity across the
   * core-dimension subset) for a pair to be considered a candidate at all. */
  fuzzyMinAggregateConfidence: number
  /** Stage-3 confidence ceiling — below stage-2's, for the same "inherently
   * less certain" reason. */
  fuzzyMaxConfidence: number
  /** Stage-3 confidence-formula base weight (adversarial-review-of-#308 F7
   * fix — was a buried literal `0.5`; named + overridable for the same
   * reason stage-2's coreSubsetBaseConfidence/coreSubsetCoverageWeight
   * already are, per this module's own "matcher.ts's MatchConfig is the
   * architectural precedent for making cascade thresholds an explicit,
   * overridable input" doctrine (module header) and CLAUDE.md's "No magic
   * numbers — extract to named constants" rule. */
  fuzzyBaseWeight: number
  /** Stage-3 confidence-formula per-unit-of-`aggregate*coverage` weight
   * (adversarial-review-of-#308 F7 fix — was a buried literal `0.35`). */
  fuzzyCoverageWeight: number
  /** Absolute floor: a computed score below this is never emitted as a
   * candidate pair, regardless of stage. */
  candidateMinScore: number
  /** Minimum score for a candidate to participate in split/merge-suspicion
   * detection — deliberately higher than `candidateMinScore` (a low-coverage
   * fuzzy candidate is too weak evidence to assert a structural split/merge
   * finding, even though it is still a valid plain match/ambiguous candidate). */
  splitMergeMinScore: number
}

export const DEFAULT_VARIANT_MATCH_CONFIG: VariantMatchConfig = {
  coreSubsetBaseConfidence: 0.6,
  coreSubsetCoverageWeight: 0.3,
  coreSubsetMaxConfidence: 0.92,
  fuzzyMinPerDimensionSimilarity: 0.8,
  fuzzyMinAggregateConfidence: 0.72,
  fuzzyMaxConfidence: 0.8,
  fuzzyBaseWeight: 0.5,
  fuzzyCoverageWeight: 0.35,
  candidateMinScore: 0.45,
  splitMergeMinScore: 0.6,
}

// ── Public result shapes ─────────────────────────────────────────────────

export interface VariantMatchEvidence {
  signal: string
  detail: string
}

export type VariantMatchStage = 'raw_exact' | 'core_subset_exact' | 'fuzzy_label_similarity' | 'manual_override'

/** One candidate pairing surfaced inside an ambiguous/split/merge finding —
 * NOT itself a result (a caller reviewing an ambiguous group sees these to
 * decide, then records the decision as a VariantMatchOverride). */
export interface VariantMatchCandidateInfo {
  leftIndex: number
  leftId: string
  rightIndex: number
  rightId: string
  stage: VariantMatchStage
  score: number
  evidence: readonly VariantMatchEvidence[]
  /** Count of informative (non-empty on BOTH sides) dimension keys this
   * candidate's identity evidence actually rests on (adversarial-review-of-
   * #308 F1 fix support) — e.g. 1 for a single-dimension match like
   * `steeringSide=links` alone. A caller translating a 'matched' result into
   * a financial/commercial aggregation should never treat a low count here
   * as sufficient identity evidence on its own — see container-assembly.ts's
   * fuzzy-fallback aggregation gate. */
  matchedDimensionCount: number
}

export interface VariantMatchedResult {
  kind: 'matched'
  leftIndex: number
  leftId: string
  rightIndex: number
  rightId: string
  stage: VariantMatchStage
  confidence: number
  evidence: readonly VariantMatchEvidence[]
  explanation: string
  /** See VariantMatchCandidateInfo.matchedDimensionCount — same field,
   * carried onto the final 'matched' result so a caller does not have to
   * re-derive it from `evidence`'s free-text detail strings. */
  matchedDimensionCount: number
}

/** Master-Prompt §9 "never automatically match for commercial aggregation" —
 * covers both a genuine score tie AND duplicate-looking variants (which
 * present as the same shape: 2+ candidates on one side scoring identically
 * against the other). `reviewRelevant` is always `true` by construction
 * (mirrors MultiQafWarning.reviewRelevant's own doc comment — this IS the
 * "ambiguous, needs a judgment call" case that field exists for). */
export interface VariantAmbiguousResult {
  kind: 'ambiguous'
  leftIndices: readonly number[]
  leftIds: readonly string[]
  rightIndices: readonly number[]
  rightIds: readonly string[]
  candidates: readonly VariantMatchCandidateInfo[]
  reviewRelevant: true
  explanation: string
}

/** One LEFT variant's identity plausibly spread across 2+ RIGHT variants
 * (1→n) — see detectSplits below for the exact detection shape. */
export interface VariantSplitSuspectedResult {
  kind: 'split_suspected'
  leftIndex: number
  leftId: string
  rightIndices: readonly number[]
  rightIds: readonly string[]
  candidates: readonly VariantMatchCandidateInfo[]
  reviewRelevant: true
  explanation: string
}

/** 2+ LEFT variants plausibly collapsed into one RIGHT variant (n→1) —
 * symmetric to split_suspected, see detectMerges below. */
export interface VariantMergeSuspectedResult {
  kind: 'merge_suspected'
  rightIndex: number
  rightId: string
  leftIndices: readonly number[]
  leftIds: readonly string[]
  candidates: readonly VariantMatchCandidateInfo[]
  reviewRelevant: true
  explanation: string
}

export interface VariantUnmatchedLeftResult {
  kind: 'unmatched_left'
  leftIndex: number
  leftId: string
  reason: 'removed'
}

export interface VariantUnmatchedRightResult {
  kind: 'unmatched_right'
  rightIndex: number
  rightId: string
  reason: 'added'
}

export type VariantMatchResult =
  | VariantMatchedResult
  | VariantAmbiguousResult
  | VariantSplitSuspectedResult
  | VariantMergeSuspectedResult
  | VariantUnmatchedLeftResult
  | VariantUnmatchedRightResult

// ── Stage 1-3 pairwise evaluation ───────────────────────────────────────────

interface DimensionKeyCompare {
  key: string
  similarity: number
  matchKind: 'exact' | 'synonym' | 'fuzzy' | 'mismatch'
}

function nonEmptyDimensionKeys(dims: VariantDimensions): string[] {
  return Object.keys(dims).filter((k) => dims[k]!.normalized !== '')
}

/** Dimension keys populated (non-empty normalized value) on BOTH sides —
 * "Kern-Dimensionen". Sorted alphabetically for deterministic evidence/
 * evaluation order (object key insertion order is not something a P1.2
 * parser guarantees — same discipline identity.ts's own
 * buildCompositeCanonicalKey applies to its own key ordering). */
function coreDimensionKeys(a: VariantDimensions, b: VariantDimensions): string[] {
  const keysB = new Set(nonEmptyDimensionKeys(b))
  return nonEmptyDimensionKeys(a)
    .filter((k) => keysB.has(k))
    .sort()
}

/** Size of the UNION of both sides' non-empty dimension keys — the
 * denominator `coverage` (coreDimensionKeys.length / this) measures how much
 * of each side's own identity the core-subset match actually verified. */
function unionDimensionKeyCount(a: VariantDimensions, b: VariantDimensions): number {
  const set = new Set<string>([...nonEmptyDimensionKeys(a), ...nonEmptyDimensionKeys(b)])
  return set.size
}

function compareDimensionValue(key: string, a: VariantDimensionValue, b: VariantDimensionValue, config: VariantMatchConfig): DimensionKeyCompare {
  if (a.normalized === b.normalized) return { key, similarity: 1, matchKind: 'exact' }
  const ga = synonymGroup(a.normalized)
  const gb = synonymGroup(b.normalized)
  if (ga !== null && ga === gb) return { key, similarity: 1, matchKind: 'synonym' }
  const sim = normalizedSimilarity(a.normalized, b.normalized)
  if (sim >= config.fuzzyMinPerDimensionSimilarity) return { key, similarity: sim, matchKind: 'fuzzy' }
  return { key, similarity: 0, matchKind: 'mismatch' }
}

interface PairEvaluation {
  stage: VariantMatchStage
  score: number
  evidence: VariantMatchEvidence[]
  /** See VariantMatchCandidateInfo.matchedDimensionCount. */
  coreDimensionCount: number
}

/** Runs the 3-stage cascade for one (left, right) pair. Returns `null` when
 * no stage produces a candidate at or above its own floor — this pair is
 * simply never considered (not a 0-confidence "match", genuinely absent from
 * the candidate pool matchVariants below builds). */
function evaluatePair(a: VariantDefinition, b: VariantDefinition, config: VariantMatchConfig): PairEvaluation | null {
  // Computed unconditionally (even for the stage-1 raw_exact branch below) —
  // adversarial-review-of-#308 F1 support: a raw_exact match's own
  // compositeCanonicalKey equality IMPLIES both sides carry the exact same
  // non-empty dimension set/values (identity.ts's own determinism
  // guarantee), so `coreKeys.length` here doubles as "how many informative
  // dimensions this identity match actually rests on" for every stage,
  // including stage 1 — never re-derived differently per stage.
  const coreKeys = coreDimensionKeys(a.dimensions, b.dimensions)

  // Stage 1: raw exact composite-canonical-key. Empty ('') keys are never
  // accepted as identity evidence (identity.ts doctrine: an empty key means
  // "no identity", not "matches every other empty-key variant").
  if (a.compositeCanonicalKey !== '' && a.compositeCanonicalKey === b.compositeCanonicalKey) {
    return {
      stage: 'raw_exact',
      score: 1,
      coreDimensionCount: coreKeys.length,
      evidence: [{ signal: 'raw_composite_key_exact', detail: `Identischer RAW-compositeCanonicalKey "${a.compositeCanonicalKey}".` }],
    }
  }

  if (coreKeys.length === 0) return null
  const coverage = coreKeys.length / unionDimensionKeyCount(a.dimensions, b.dimensions)

  // Stage 2: core-dimension-subset exact match.
  const allExact = coreKeys.every((k) => a.dimensions[k]!.normalized === b.dimensions[k]!.normalized)
  if (allExact) {
    const score = round3(Math.min(config.coreSubsetMaxConfidence, config.coreSubsetBaseConfidence + config.coreSubsetCoverageWeight * coverage))
    return {
      stage: 'core_subset_exact',
      score,
      coreDimensionCount: coreKeys.length,
      evidence: [
        {
          signal: 'core_dimension_subset_exact',
          detail: `${coreKeys.length} gemeinsame Dimension(en) exakt gleich: ${coreKeys.join(', ')} (Abdeckung ${Math.round(coverage * 100)}%).`,
        },
      ],
    }
  }

  // Stage 3: normalized/synonym/fuzzy label similarity on the same core subset.
  const compares = coreKeys.map((k) => compareDimensionValue(k, a.dimensions[k]!, b.dimensions[k]!, config))
  const aggregate = compares.reduce((sum, c) => sum + c.similarity, 0) / compares.length
  if (aggregate < config.fuzzyMinAggregateConfidence) return null
  const score = round3(Math.min(config.fuzzyMaxConfidence, aggregate * (config.fuzzyBaseWeight + config.fuzzyCoverageWeight * coverage)))
  if (score < config.candidateMinScore) return null
  return {
    stage: 'fuzzy_label_similarity',
    score,
    coreDimensionCount: coreKeys.length,
    evidence: compares.map((c) => ({
      signal: `dimension_${c.matchKind}`,
      detail: `${c.key}: "${a.dimensions[c.key]!.raw}" ~ "${b.dimensions[c.key]!.raw}" (Ähnlichkeit ${Math.round(c.similarity * 100)}%, ${c.matchKind}).`,
    })),
  }
}

// ── Stage 4: structural anchor (tiebreak-only, never a candidate source) ───

/** Volume fields compared by structuralAnchorScore below — adversarial-
 * review-of-#308 F4 fix: each field is only ever compared against the SAME
 * field on the other side (annual↔annual, peak↔peak, lifetime↔lifetime),
 * never across semantically different volume kinds (the original
 * `a.annualVolume ?? a.peakVolume ?? a.lifetimeVolume` vs. an INDEPENDENT
 * `b.annualVolume ?? b.peakVolume ?? b.lifetimeVolume` fallback chain could
 * silently compare e.g. side A's annualVolume against side B's peakVolume —
 * two different quantities that coincidentally being numerically close means
 * nothing about identity). */
const STRUCTURAL_ANCHOR_VOLUME_FIELDS = ['annualVolume', 'peakVolume', 'lifetimeVolume'] as const

function structuralAnchorScore(a: VariantDefinition, b: VariantDefinition): number {
  if (a.originalVariantNumber !== null && a.originalVariantNumber === b.originalVariantNumber) return 1
  let best = 0
  for (const field of STRUCTURAL_ANCHOR_VOLUME_FIELDS) {
    const va = a[field]
    const vb = b[field]
    if (va === null || vb === null || va === 0 || vb === 0) continue
    const diff = Math.abs(va - vb) / Math.max(Math.abs(va), Math.abs(vb))
    best = Math.max(best, 1 - diff)
  }
  return best
}

// ── Candidate pool ───────────────────────────────────────────────────────

interface InternalCandidate {
  leftIndex: number
  rightIndex: number
  stage: VariantMatchStage
  score: number
  evidence: VariantMatchEvidence[]
  anchor: number
  coreDimensionCount: number
}

const STAGE_RANK: Record<VariantMatchStage, number> = {
  raw_exact: 1,
  core_subset_exact: 2,
  fuzzy_label_similarity: 3,
  manual_override: 0,
}

function buildCandidates(left: readonly VariantDefinition[], right: readonly VariantDefinition[], config: VariantMatchConfig): InternalCandidate[] {
  const out: InternalCandidate[] = []
  for (let li = 0; li < left.length; li++) {
    for (let ri = 0; ri < right.length; ri++) {
      const ev = evaluatePair(left[li]!, right[ri]!, config)
      if (!ev) continue
      out.push({
        leftIndex: li,
        rightIndex: ri,
        stage: ev.stage,
        score: ev.score,
        evidence: ev.evidence,
        anchor: structuralAnchorScore(left[li]!, right[ri]!),
        coreDimensionCount: ev.coreDimensionCount,
      })
    }
  }
  return out
}

function toCandidateInfo(c: InternalCandidate, left: readonly VariantDefinition[], right: readonly VariantDefinition[]): VariantMatchCandidateInfo {
  return {
    leftIndex: c.leftIndex,
    leftId: left[c.leftIndex]!.stableInternalId,
    rightIndex: c.rightIndex,
    rightId: right[c.rightIndex]!.stableInternalId,
    stage: c.stage,
    score: c.score,
    evidence: c.evidence,
    matchedDimensionCount: c.coreDimensionCount,
  }
}

// ── Split/merge suspicion (pre-pass, before greedy assignment) ─────────────
//
// A split/merge finding requires TWO things, not just "2+ candidates above a
// score floor":
//   1. Each qualifying candidate must be the OTHER side's own best-ranked
//      claimant too (isTopLeftForRight/isTopRightForLeft below) — otherwise
//      one of the candidates more plausibly belongs to a genuinely different
//      variant on the other side, and this is not a split/merge at all.
//   2. The qualifying candidates must show SCORE VARIATION (hasScoreVariation
//      below) — i.e. they must NOT all be tied at the identical (stage,
//      score). This is what tells a genuine split ("L's identity plausibly
//      spread across two DIFFERENT-quality fragments R1/R2") apart from
//      duplicate-looking variants or a real score tie ("R1 and R2 are
//      EQUALLY plausible, we cannot tell them apart at all") — the latter is
//      exactly what the ambiguous-result greedy tie-detection below already
//      handles correctly, and reporting it as a split/merge instead would be
//      a strictly WORSE, more confident-sounding claim than the evidence
//      supports. A raw_exact duplicate pair (both candidates score exactly
//      1, stage raw_exact) always fails this variation check by construction
//      — it can never be miscategorized as a split.
interface SplitGroup {
  leftIndex: number
  rightIndices: number[]
  candidates: InternalCandidate[]
}

interface MergeGroup {
  rightIndex: number
  leftIndices: number[]
  candidates: InternalCandidate[]
}

/** A genuine multi-way conflict (adversarial-review-of-#308 F3 fix): 2+
 * independently-qualifying split/merge groups that ALL top-claim the SAME
 * secondary-side index. Reported as one `ambiguous` finding spanning every
 * primary+secondary index involved, never as N separate split_suspected/
 * merge_suspected results that each silently assert exclusive ownership of
 * a right/left index another equally-plausible group also claims. */
interface ConflictCluster {
  primaryIndices: number[]
  secondaryIndices: number[]
  candidates: InternalCandidate[]
}

interface ProvisionalGroup {
  /** leftIndex for a split-detection call, rightIndex for a merge-detection
   * call — the side detectSplits/detectMerges iterates by. */
  primaryIndex: number
  /** rightIndices for split, leftIndices for merge — the side each
   * provisional group's qualifying candidates point at. */
  secondaryIndices: number[]
  candidates: InternalCandidate[]
}

/** Clusters provisional split/merge groups that share a top-claimed
 * secondary-side index (transitively, via union-find) into either a lone
 * (unambiguous — becomes a real split/merge) group, or a merged
 * ConflictCluster spanning every group in the cluster (adversarial-review-
 * of-#308 F3 fix — see isTopLeftForRight's own doc comment for why this can
 * happen: it accepts EVERY tied-best claimant, so two different primary
 * indices can each independently pass the "top claimant" check for the same
 * secondary index). */
function clusterProvisionalGroups(groups: readonly ProvisionalGroup[]): { resolved: ProvisionalGroup[]; conflicts: ConflictCluster[] } {
  if (groups.length === 0) return { resolved: [], conflicts: [] }

  const parent = new Map<number, number>()
  const find = (x: number): number => {
    if (!parent.has(x)) parent.set(x, x)
    let root = x
    while (parent.get(root) !== root) root = parent.get(root)!
    while (parent.get(x) !== root) {
      const next = parent.get(x)!
      parent.set(x, root)
      x = next
    }
    return root
  }
  const union = (a: number, b: number): void => {
    const ra = find(a)
    const rb = find(b)
    if (ra !== rb) parent.set(ra, rb)
  }

  for (const g of groups) find(g.primaryIndex)
  const secondaryToPrimary = new Map<number, number[]>()
  for (const g of groups) {
    for (const s of g.secondaryIndices) {
      const list = secondaryToPrimary.get(s)
      if (list) list.push(g.primaryIndex)
      else secondaryToPrimary.set(s, [g.primaryIndex])
    }
  }
  for (const primaries of secondaryToPrimary.values()) {
    for (let i = 1; i < primaries.length; i++) union(primaries[0]!, primaries[i]!)
  }

  const clusters = new Map<number, ProvisionalGroup[]>()
  for (const g of groups) {
    const root = find(g.primaryIndex)
    const list = clusters.get(root)
    if (list) list.push(g)
    else clusters.set(root, [g])
  }

  const resolved: ProvisionalGroup[] = []
  const conflicts: ConflictCluster[] = []
  for (const clusterGroups of clusters.values()) {
    if (clusterGroups.length === 1) {
      resolved.push(clusterGroups[0]!)
    } else {
      conflicts.push({
        primaryIndices: [...new Set(clusterGroups.map((g) => g.primaryIndex))].sort((a, b) => a - b),
        secondaryIndices: [...new Set(clusterGroups.flatMap((g) => g.secondaryIndices))].sort((a, b) => a - b),
        candidates: clusterGroups.flatMap((g) => g.candidates),
      })
    }
  }
  return { resolved, conflicts }
}

function hasScoreVariation(qualifying: readonly InternalCandidate[]): boolean {
  const keys = new Set(qualifying.map((c) => `${c.stage}:${c.score}`))
  return keys.size > 1
}

/** True when `leftIndex` is (one of) `rightIndex`'s own best-ranked
 * candidate(s) among ALL candidates referencing that right — i.e. no OTHER
 * left has a strictly better (lower stage rank, or same rank + higher score)
 * claim on this right. */
function isTopLeftForRight(rightIndex: number, leftIndex: number, candidates: readonly InternalCandidate[]): boolean {
  const cands = candidates.filter((c) => c.rightIndex === rightIndex)
  if (cands.length === 0) return false
  const bestRank = Math.min(...cands.map((c) => STAGE_RANK[c.stage]))
  const atBestRank = cands.filter((c) => STAGE_RANK[c.stage] === bestRank)
  const bestScore = Math.max(...atBestRank.map((c) => c.score))
  return atBestRank.some((c) => c.score === bestScore && c.leftIndex === leftIndex)
}

function isTopRightForLeft(leftIndex: number, rightIndex: number, candidates: readonly InternalCandidate[]): boolean {
  const cands = candidates.filter((c) => c.leftIndex === leftIndex)
  if (cands.length === 0) return false
  const bestRank = Math.min(...cands.map((c) => STAGE_RANK[c.stage]))
  const atBestRank = cands.filter((c) => STAGE_RANK[c.stage] === bestRank)
  const bestScore = Math.max(...atBestRank.map((c) => c.score))
  return atBestRank.some((c) => c.score === bestScore && c.rightIndex === rightIndex)
}

function detectSplits(
  candidates: readonly InternalCandidate[],
  leftCount: number,
  config: VariantMatchConfig,
): { splits: SplitGroup[]; conflicts: ConflictCluster[] } {
  const provisional: ProvisionalGroup[] = []
  for (let li = 0; li < leftCount; li++) {
    const qualifying = candidates.filter((c) => c.leftIndex === li && c.score >= config.splitMergeMinScore)
    if (qualifying.length < 2 || !hasScoreVariation(qualifying)) continue
    if (qualifying.every((c) => isTopLeftForRight(c.rightIndex, li, candidates))) {
      provisional.push({ primaryIndex: li, secondaryIndices: qualifying.map((c) => c.rightIndex).sort((x, y) => x - y), candidates: qualifying })
    }
  }
  const { resolved, conflicts } = clusterProvisionalGroups(provisional)
  return { splits: resolved.map((g) => ({ leftIndex: g.primaryIndex, rightIndices: g.secondaryIndices, candidates: g.candidates })), conflicts }
}

function detectMerges(
  candidates: readonly InternalCandidate[],
  rightCount: number,
  config: VariantMatchConfig,
): { merges: MergeGroup[]; conflicts: ConflictCluster[] } {
  const provisional: ProvisionalGroup[] = []
  for (let ri = 0; ri < rightCount; ri++) {
    const qualifying = candidates.filter((c) => c.rightIndex === ri && c.score >= config.splitMergeMinScore)
    if (qualifying.length < 2 || !hasScoreVariation(qualifying)) continue
    if (qualifying.every((c) => isTopRightForLeft(c.leftIndex, ri, candidates))) {
      provisional.push({ primaryIndex: ri, secondaryIndices: qualifying.map((c) => c.leftIndex).sort((x, y) => x - y), candidates: qualifying })
    }
  }
  const { resolved, conflicts } = clusterProvisionalGroups(provisional)
  return { merges: resolved.map((g) => ({ rightIndex: g.primaryIndex, leftIndices: g.secondaryIndices, candidates: g.candidates })), conflicts }
}

// ── Greedy, tie-aware, stage-4-anchored assignment ──────────────────────────

/** Groups a same-rank/score/anchor "top tier" of candidates into connected
 * components by shared left/right index — two candidates that do NOT share a
 * left or right index are independent (both can win simultaneously), while
 * two that DO share one are genuinely competing for the same slot. */
function connectedComponents(edges: readonly InternalCandidate[]): InternalCandidate[][] {
  const leftAdj = new Map<number, InternalCandidate[]>()
  const rightAdj = new Map<number, InternalCandidate[]>()
  for (const e of edges) {
    ;(leftAdj.get(e.leftIndex) ?? leftAdj.set(e.leftIndex, []).get(e.leftIndex)!).push(e)
    ;(rightAdj.get(e.rightIndex) ?? rightAdj.set(e.rightIndex, []).get(e.rightIndex)!).push(e)
  }
  const visited = new Set<InternalCandidate>()
  const components: InternalCandidate[][] = []
  for (const start of edges) {
    if (visited.has(start)) continue
    const comp: InternalCandidate[] = []
    const stack: InternalCandidate[] = [start]
    visited.add(start)
    while (stack.length > 0) {
      const e = stack.pop()!
      comp.push(e)
      for (const n of leftAdj.get(e.leftIndex) ?? []) {
        if (!visited.has(n)) {
          visited.add(n)
          stack.push(n)
        }
      }
      for (const n of rightAdj.get(e.rightIndex) ?? []) {
        if (!visited.has(n)) {
          visited.add(n)
          stack.push(n)
        }
      }
    }
    components.push(comp)
  }
  return components
}

/**
 * Adversarial-review-of-#308 F2 fix. A same-tier component with 2+ edges is
 * a genuine tie — the structural anchor (stage 4) may resolve it ONLY when
 * doing so cannot silently strand an equally-qualified competitor.
 *
 * The anchor's own best (maxAnchor) score may be shared by 2+ NON-competing
 * edges at once — e.g. a real-file "2 left x 2 right" square where BOTH
 * diagonal pairs (the two genuinely correct pairings) tie at anchor=1 via
 * `originalVariantNumber`, while the two cross pairs sit at a lower anchor
 * (mismatched originalVariantNumber, falls back to a real but weaker volume
 * comparison). Grouping the top-anchor edges into their OWN connected
 * components handles this: each SINGLETON sub-component there is an
 * independent, non-conflicting winner (both may be accepted simultaneously);
 * a sub-component that is still >1 edge means the anchor itself did not
 * disambiguate that specific slice of the tie, and the WHOLE component must
 * fall back to `ambiguous` (never a partial pick — module docs) since there
 * is no signal left to trust for it.
 *
 * Once every top-anchor winner is chosen, a remaining ("losing") edge is
 * safe to silently drop only if EVERY endpoint of it that is NOT already
 * covered by a winner has some OTHER candidate elsewhere in the full
 * remaining pool (module docs: "der Verlierer wird anderweitig gematcht
 * oder von einer anderen Seite beansprucht"). An endpoint ALREADY covered by
 * a (different) winner is not "stranded" by this loser's removal — it was
 * never available to this loser to begin with. An endpoint untouched by any
 * winner (neither its left nor right shared with a winner) is unaffected by
 * this decision at all and handled independently in a later pool iteration.
 *
 * Returns every safe winner, or `null` when no anchor-based resolution is
 * safe for this component at all (the whole component must go to
 * `ambiguous`).
 */
function resolveComponentViaAnchor(comp: readonly InternalCandidate[], pool: readonly InternalCandidate[]): InternalCandidate[] | null {
  const maxAnchor = Math.max(...comp.map((c) => c.anchor))
  const topAnchorEdges = comp.filter((c) => c.anchor === maxAnchor)
  const topAnchorSubComponents = connectedComponents(topAnchorEdges)
  if (topAnchorSubComponents.some((sc) => sc.length !== 1)) return null
  const winners = topAnchorSubComponents.map((sc) => sc[0]!)
  const winnerLefts = new Set(winners.map((w) => w.leftIndex))
  const winnerRights = new Set(winners.map((w) => w.rightIndex))
  const losers = comp.filter((c) => !winners.includes(c))

  const allSafe = losers.every((loser) => {
    const leftCovered = winnerLefts.has(loser.leftIndex)
    const rightCovered = winnerRights.has(loser.rightIndex)
    if (leftCovered && rightCovered) return true // fully absorbed by 2 (possibly different) winners — nothing orphaned.
    if (!leftCovered && !rightCovered) return true // untouched by any winner's own removal — handled independently later.
    if (!leftCovered) {
      // rightIndex IS covered (removed once winners consume it) — but
      // loser's OWN leftIndex is not covered by any winner and needs an
      // alternative route not already claimed by a winner's rightIndex.
      return pool.some((c) => c !== loser && c.leftIndex === loser.leftIndex && !winnerRights.has(c.rightIndex))
    }
    return pool.some((c) => c !== loser && c.rightIndex === loser.rightIndex && !winnerLefts.has(c.leftIndex))
  })
  return allSafe ? winners : null
}

function explanationForStage(stage: VariantMatchStage): string {
  switch (stage) {
    case 'raw_exact':
      return 'Identischer RAW-Dimensions-Schlüssel — eindeutige Übereinstimmung.'
    case 'core_subset_exact':
      return 'Alle gemeinsamen Dimensionen exakt gleich (abweichende/zusätzliche Dimensionen einer Seite ignoriert).'
    case 'fuzzy_label_similarity':
      return 'Gemeinsame Dimensionen stimmen nach Normalisierung/Synonym-Abgleich/Tippfehler-Toleranz hinreichend überein.'
    case 'manual_override':
      return 'Manuell bestätigt/überschrieben.'
  }
}

function primaryLeftIndex(r: VariantMatchResult): number {
  switch (r.kind) {
    case 'matched':
    case 'split_suspected':
    case 'unmatched_left':
      return r.leftIndex
    case 'ambiguous':
      return r.leftIndices[0] ?? Number.MAX_SAFE_INTEGER
    case 'merge_suspected':
      return r.leftIndices[0] ?? Number.MAX_SAFE_INTEGER
    case 'unmatched_right':
      return Number.MAX_SAFE_INTEGER
  }
}

function primaryRightIndex(r: VariantMatchResult): number {
  switch (r.kind) {
    case 'matched':
    case 'merge_suspected':
    case 'unmatched_right':
      return r.rightIndex
    case 'ambiguous':
      return r.rightIndices[0] ?? Number.MAX_SAFE_INTEGER
    case 'split_suspected':
      return r.rightIndices[0] ?? Number.MAX_SAFE_INTEGER
    case 'unmatched_left':
      return Number.MAX_SAFE_INTEGER
  }
}

/**
 * Matches `left` variants (e.g. an ALT container's, or a container's own
 * Summary-sourced set) against `right` variants (NEU / Material-sourced) via
 * the 3-stage cascade + stage-4 structural-anchor tiebreak documented at the
 * top of this module. Every left and right index is covered exactly once
 * across the returned results (mirrors matcher.ts's matchSteps contract).
 *
 * Assignment algorithm: repeatedly picks the single best-ranked remaining
 * "top tier" (lowest stage rank, then highest score — the anchor is NO
 * LONGER part of this tier selection, adversarial-review-of-#308 F2 fix,
 * see resolveComponentViaAnchor's own doc comment) from the remaining
 * candidate pool. Within that top tier, candidates that do NOT compete for
 * the same left/right (connectedComponents) are all assigned as `matched`
 * simultaneously; candidates that DO compete are resolved via the stage-4
 * structural anchor ONLY when that resolution cannot silently strand an
 * equally-qualified competitor (resolveComponentViaAnchor) — otherwise
 * the whole tied group becomes one `ambiguous` finding covering every left/
 * right the tie touches. Split/merge suspicion is detected BEFORE this
 * assignment loop runs, on the full unfiltered candidate pool, and those
 * indices are removed from the pool so they are never also reported as
 * matched/ambiguous; a genuine multi-way top-claimant conflict between 2+
 * split/merge groups (adversarial-review-of-#308 F3 fix) is folded into an
 * `ambiguous` finding instead of being reported as multiple contradictory
 * split_suspected/merge_suspected results.
 */
export function matchVariants(
  left: readonly VariantDefinition[],
  right: readonly VariantDefinition[],
  config: VariantMatchConfig = DEFAULT_VARIANT_MATCH_CONFIG,
): VariantMatchResult[] {
  const candidates = buildCandidates(left, right, config)

  const { splits: splitGroups, conflicts: splitConflicts } = detectSplits(candidates, left.length, config)
  const splitLeftIdx = new Set([...splitGroups.map((g) => g.leftIndex), ...splitConflicts.flatMap((c) => c.primaryIndices)])
  const splitRightIdx = new Set([...splitGroups.flatMap((g) => g.rightIndices), ...splitConflicts.flatMap((c) => c.secondaryIndices)])
  const afterSplit = candidates.filter((c) => !splitLeftIdx.has(c.leftIndex) && !splitRightIdx.has(c.rightIndex))

  const { merges: mergeGroups, conflicts: mergeConflicts } = detectMerges(afterSplit, right.length, config)
  const mergeLeftIdx = new Set([...mergeGroups.flatMap((g) => g.leftIndices), ...mergeConflicts.flatMap((c) => c.secondaryIndices)])
  const mergeRightIdx = new Set([...mergeGroups.map((g) => g.rightIndex), ...mergeConflicts.flatMap((c) => c.primaryIndices)])
  let pool = afterSplit.filter((c) => !mergeLeftIdx.has(c.leftIndex) && !mergeRightIdx.has(c.rightIndex))

  const usedLeft = new Set<number>([...splitLeftIdx, ...mergeLeftIdx])
  const usedRight = new Set<number>([...splitRightIdx, ...mergeRightIdx])

  const results: VariantMatchResult[] = []

  const pushMatched = (c: InternalCandidate): void => {
    results.push({
      kind: 'matched',
      leftIndex: c.leftIndex,
      leftId: left[c.leftIndex]!.stableInternalId,
      rightIndex: c.rightIndex,
      rightId: right[c.rightIndex]!.stableInternalId,
      stage: c.stage,
      confidence: c.score,
      evidence: c.evidence,
      explanation: explanationForStage(c.stage),
      matchedDimensionCount: c.coreDimensionCount,
    })
    usedLeft.add(c.leftIndex)
    usedRight.add(c.rightIndex)
  }

  const pushAmbiguous = (comp: readonly InternalCandidate[], explanation: string): void => {
    const leftIndices = [...new Set(comp.map((c) => c.leftIndex))].sort((a, b) => a - b)
    const rightIndices = [...new Set(comp.map((c) => c.rightIndex))].sort((a, b) => a - b)
    results.push({
      kind: 'ambiguous',
      leftIndices,
      leftIds: leftIndices.map((i) => left[i]!.stableInternalId),
      rightIndices,
      rightIds: rightIndices.map((i) => right[i]!.stableInternalId),
      candidates: comp.map((c) => toCandidateInfo(c, left, right)),
      reviewRelevant: true,
      explanation,
    })
    for (const i of leftIndices) usedLeft.add(i)
    for (const i of rightIndices) usedRight.add(i)
  }

  for (const g of splitGroups) {
    results.push({
      kind: 'split_suspected',
      leftIndex: g.leftIndex,
      leftId: left[g.leftIndex]!.stableInternalId,
      rightIndices: g.rightIndices,
      rightIds: g.rightIndices.map((i) => right[i]!.stableInternalId),
      candidates: g.candidates.map((c) => toCandidateInfo(c, left, right)),
      reviewRelevant: true,
      explanation: `Variante "${left[g.leftIndex]!.stableInternalId}" hat ${g.rightIndices.length} plausible, unterschiedlich starke Gegenstücke auf der anderen Seite — möglicher Split, keine automatische Zuordnung.`,
    })
  }
  for (const c of splitConflicts) {
    pushAmbiguous(
      c.candidates,
      `${c.primaryIndices.length} linke Varianten beanspruchen (mind.) eine gemeinsame rechte Variante jeweils als eigenen Split-Kandidaten — echter Mehrfach-Konflikt statt getrennter Splits, keine automatische Zuordnung.`,
    )
  }
  for (const g of mergeGroups) {
    results.push({
      kind: 'merge_suspected',
      rightIndex: g.rightIndex,
      rightId: right[g.rightIndex]!.stableInternalId,
      leftIndices: g.leftIndices,
      leftIds: g.leftIndices.map((i) => left[i]!.stableInternalId),
      candidates: g.candidates.map((c) => toCandidateInfo(c, left, right)),
      reviewRelevant: true,
      explanation: `${g.leftIndices.length} unterschiedlich starke Kandidaten der einen Seite sind plausible Vorgänger von "${right[g.rightIndex]!.stableInternalId}" — möglicher Merge, keine automatische Zuordnung.`,
    })
  }
  for (const c of mergeConflicts) {
    pushAmbiguous(
      c.candidates,
      `${c.primaryIndices.length} rechte Varianten beanspruchen (mind.) eine gemeinsame linke Variante jeweils als eigenen Merge-Kandidaten — echter Mehrfach-Konflikt statt getrennter Merges, keine automatische Zuordnung.`,
    )
  }

  while (pool.length > 0) {
    let bestRank = Infinity
    let bestScore = -1
    for (const c of pool) {
      const r = STAGE_RANK[c.stage]
      if (r < bestRank || (r === bestRank && c.score > bestScore)) {
        bestRank = r
        bestScore = c.score
      }
    }
    const top = pool.filter((c) => STAGE_RANK[c.stage] === bestRank && c.score === bestScore)
    const components = connectedComponents(top)

    for (const comp of components) {
      if (comp.length === 1) {
        pushMatched(comp[0]!)
        continue
      }
      const safeWinners = resolveComponentViaAnchor(comp, pool)
      if (safeWinners) {
        for (const w of safeWinners) pushMatched(w)
        continue
      }
      pushAmbiguous(
        comp,
        `${new Set(comp.map((c) => c.leftIndex)).size} gegen ${new Set(comp.map((c) => c.rightIndex)).size} Kandidat(en) mit identischer Konfidenz (Struktur-Tiebreak hätte einen gleich qualifizierten Kandidaten still zu "added"/"removed" degradiert) — keine automatische Zuordnung.`,
      )
    }

    pool = pool.filter((c) => !usedLeft.has(c.leftIndex) && !usedRight.has(c.rightIndex))
  }

  for (let li = 0; li < left.length; li++) {
    if (!usedLeft.has(li)) results.push({ kind: 'unmatched_left', leftIndex: li, leftId: left[li]!.stableInternalId, reason: 'removed' })
  }
  for (let ri = 0; ri < right.length; ri++) {
    if (!usedRight.has(ri)) results.push({ kind: 'unmatched_right', rightIndex: ri, rightId: right[ri]!.stableInternalId, reason: 'added' })
  }

  return results.sort((a, b) => primaryLeftIndex(a) - primaryLeftIndex(b) || primaryRightIndex(a) - primaryRightIndex(b))
}

// ── Review-workflow: persisted match overrides (KAR-912 field-mapping-
// override.ts pattern, applied to variant pairs) ────────────────────────────

/** Re-identification snapshot for one side of an override — compositeCanonicalKey
 * + the dimensions it was built from, so a later re-parse can be verified to
 * still describe "the same variant" before the override is trusted again
 * (never re-applied by column position — see module header). */
export interface VariantMatchOverrideIdentitySnapshot {
  compositeCanonicalKey: string
  dimensions: VariantDimensions
}

/** One user-authored match decision. `right: null` means "confirmed: this
 * left variant has no counterpart" (mirrors matcher.ts's ManualPin.neuIndex
 * === null convention for "explicitly marked as removed"). */
export interface VariantMatchOverride {
  left: VariantMatchOverrideIdentitySnapshot
  right: VariantMatchOverrideIdentitySnapshot | null
  decision: 'matched' | 'unmatched'
  /** Free-text rationale the reviewing user typed, e.g. why an ambiguous or
   * split/merge finding was resolved the way it was. */
  note: string | null
  setBy: string
  setAt: string
  /** 'active' (default/omitted), 'dropped_on_drift' (the snapshot no longer
   * identifies ANY current variant on the side it drifted), or
   * 'dropped_ambiguous_identity' (the snapshot identifies 2+ current
   * variants at once — a genuine no-identity collision, see
   * `findAllByIdentitySnapshot`'s own doc comment — never resolved to the
   * first/best-guess match) — all three set by `matchVariantsWithOverrides`.
   * A dropped override is PRESERVED (not deleted) for audit history,
   * mirroring FieldMappingOverride.status's own 'dropped_on_replace'
   * discipline — never re-evaluated once dropped. */
  status?: 'active' | 'dropped_on_drift' | 'dropped_ambiguous_identity'
}

/** True when two identity snapshots are EXACTLY the same identity —
 * compositeCanonicalKey AND every non-empty dimension key/value, never just
 * the key alone (adversarial-review-of-#317 F1/F2/F4 fix: a bare
 * `compositeCanonicalKey ===` compare treats every no-identity variant, i.e.
 * `compositeCanonicalKey === ''`, as equal to every OTHER no-identity
 * variant, which is exactly backwards — dimensions is what actually
 * distinguishes them). The ONE equality rule every override
 * re-identification path in this module (and actions.ts's setter/upsert/
 * clear callers) shares — see module header "hängen zusammen" doctrine. */
export function identitySnapshotsEqual(a: VariantMatchOverrideIdentitySnapshot, b: VariantMatchOverrideIdentitySnapshot): boolean {
  return a.compositeCanonicalKey === b.compositeCanonicalKey && dimensionsEqual(a.dimensions, b.dimensions)
}

/** Builds the re-identification snapshot for one live variant — the exact
 * shape `VariantMatchOverride.left`/`.right` persists, so a caller never has
 * to duplicate `{ compositeCanonicalKey, dimensions }` construction (KAR-947
 * actions.ts's own `overrideSnapshotFor` now delegates here). */
export function identitySnapshotFor(v: VariantDefinition): VariantMatchOverrideIdentitySnapshot {
  return { compositeCanonicalKey: v.compositeCanonicalKey, dimensions: v.dimensions }
}

function dimensionsEqual(a: VariantDimensions, b: VariantDimensions): boolean {
  const keysA = nonEmptyDimensionKeys(a).sort()
  const keysB = nonEmptyDimensionKeys(b).sort()
  if (keysA.length !== keysB.length) return false
  for (let i = 0; i < keysA.length; i++) {
    if (keysA[i] !== keysB[i]) return false
    if (a[keysA[i]!]!.normalized !== b[keysA[i]!]!.normalized) return false
  }
  return true
}

/** Every index whose variant's OWN identity (compositeCanonicalKey +
 * dimensions, via `identitySnapshotsEqual`) matches `snapshot` exactly — 0
 * results means genuine drift (no current variant carries this identity any
 * more), 2+ results means the snapshot is intrinsically AMBIGUOUS (two or
 * more current variants — almost always both no-identity,
 * `compositeCanonicalKey === ''` with empty `dimensions` — are indistinguishable
 * from each other by identity evidence alone). Both are honest, distinct
 * "cannot resolve" outcomes — never collapsed into a single silent
 * first-match pick (adversarial-review-of-#317 F4 fix; see
 * `matchVariantsWithOverrides` below for how each case is reported, and
 * actions.ts's setter/upsert/clear callers for the same rule applied to
 * override persistence). */
export function findAllByIdentitySnapshot(variants: readonly VariantDefinition[], snapshot: VariantMatchOverrideIdentitySnapshot): number[] {
  const out: number[] = []
  for (let i = 0; i < variants.length; i++) {
    if (identitySnapshotsEqual(identitySnapshotFor(variants[i]!), snapshot)) out.push(i)
  }
  return out
}

/** Replaces any existing override(s) whose `left` identity matches
 * `altSnapshot` exactly (via `identitySnapshotsEqual`, regardless of their
 * own `status` — a re-decision supersedes even an already-dropped one) with
 * `next` — kept to at most one persisted override per ALT-variant identity,
 * a resubmitted decision supersedes the previous one rather than
 * accumulating as a stale duplicate. actions.ts's `upsertVariantMatchOverride`
 * (KAR-947 P4 setter) delegates here.
 *
 * adversarial-review-of-#317 F2 fix: the previous caller-local
 * implementation compared bare `compositeCanonicalKey` strings and SKIPPED
 * this replace step entirely for no-identity (`compositeCanonicalKey ===
 * ''`) variants, so re-deciding a no-identity variant's match silently kept
 * BOTH the old and the new override on the persisted list —
 * `matchVariantsWithOverrides`' first-match-wins semantics then let the OLD
 * (first) decision keep winning forever, even though the reviewer
 * explicitly re-decided. Dimensions distinguish genuinely different
 * no-identity variants from each other, so `identitySnapshotsEqual` resolves
 * this correctly in the overwhelming majority of cases; the remaining edge
 * case — two CURRENT variants sharing the exact same identity — is refused
 * upfront by the caller's own ambiguity guard (variantIdentityIsAmbiguous,
 * actions.ts), so this function never actually has to choose between two
 * indistinguishable targets. */
export function replaceOverrideForVariant(
  existing: readonly VariantMatchOverride[],
  next: VariantMatchOverride,
  altSnapshot: VariantMatchOverrideIdentitySnapshot,
): VariantMatchOverride[] {
  return [...existing.filter((o) => !identitySnapshotsEqual(o.left, altSnapshot)), next]
}

/** Removes every ACTIVE (not already `'dropped_on_drift'`/
 * `'dropped_ambiguous_identity'`) override whose `left` identity matches
 * `altSnapshot` exactly (via `identitySnapshotsEqual`) — the pure logic
 * behind actions.ts's `clearVariantMatchOverride` (KAR-947 P4 "undo"
 * action). Returns both the filtered list and how many entries were
 * actually removed, so a caller can tell an honest no-op ("nothing was
 * active for this variant") apart from a real removal — only the latter
 * should ever be reported back to a user/audit log as "cleared".
 *
 * adversarial-review-of-#317 F1 fix: the previous caller-local
 * implementation skipped removal ENTIRELY for no-identity
 * (`compositeCanonicalKey === ''`) variants (a bare key compare cannot tell
 * them apart), yet the caller still returned success and wrote an audit
 * entry claiming the override had been cleared — the override stayed
 * active while the caller was told otherwise. Removing every match (rather
 * than picking one) is safe even for legacy duplicate entries: every
 * matched override shares this exact variant's identity by construction, so
 * none of them can actually belong to a DIFFERENT variant. */
export function removeActiveOverridesForVariant(
  existing: readonly VariantMatchOverride[],
  altSnapshot: VariantMatchOverrideIdentitySnapshot,
): { next: VariantMatchOverride[]; removedCount: number } {
  const isActiveForVariant = (o: VariantMatchOverride): boolean =>
    o.status !== 'dropped_on_drift' && o.status !== 'dropped_ambiguous_identity' && identitySnapshotsEqual(o.left, altSnapshot)
  const removedCount = existing.filter(isActiveForVariant).length
  return { next: existing.filter((o) => !isActiveForVariant(o)), removedCount }
}

function remapCandidate(c: VariantMatchCandidateInfo, leftMap: readonly number[], rightMap: readonly number[]): VariantMatchCandidateInfo {
  return { ...c, leftIndex: leftMap[c.leftIndex]!, rightIndex: rightMap[c.rightIndex]! }
}

/** Translates matchVariants' own indices (computed over a REDUCED left/right
 * array — the overridden variants already pulled out, same "run the cascade
 * on the rest" strategy matcher.ts's matchStepsWithPins uses) back onto the
 * original, full-length arrays' indices. */
function remapResultIndices(results: readonly VariantMatchResult[], leftMap: readonly number[], rightMap: readonly number[]): VariantMatchResult[] {
  return results.map((r): VariantMatchResult => {
    switch (r.kind) {
      case 'matched':
        return { ...r, leftIndex: leftMap[r.leftIndex]!, rightIndex: rightMap[r.rightIndex]! }
      case 'ambiguous':
        return {
          ...r,
          leftIndices: r.leftIndices.map((i) => leftMap[i]!),
          rightIndices: r.rightIndices.map((i) => rightMap[i]!),
          candidates: r.candidates.map((c) => remapCandidate(c, leftMap, rightMap)),
        }
      case 'split_suspected':
        return {
          ...r,
          leftIndex: leftMap[r.leftIndex]!,
          rightIndices: r.rightIndices.map((i) => rightMap[i]!),
          candidates: r.candidates.map((c) => remapCandidate(c, leftMap, rightMap)),
        }
      case 'merge_suspected':
        return {
          ...r,
          rightIndex: rightMap[r.rightIndex]!,
          leftIndices: r.leftIndices.map((i) => leftMap[i]!),
          candidates: r.candidates.map((c) => remapCandidate(c, leftMap, rightMap)),
        }
      case 'unmatched_left':
        return { ...r, leftIndex: leftMap[r.leftIndex]! }
      case 'unmatched_right':
        return { ...r, rightIndex: rightMap[r.rightIndex]! }
    }
  })
}

export interface ApplyVariantMatchOverridesResult {
  results: readonly VariantMatchResult[]
  /** Overrides that could not be re-identified this call (drifted, ambiguous,
   * or already dropped coming in — see `VariantMatchOverride.status`'s own
   * doc for the two distinct drop reasons) — informational, same role
   * CarryForwardResult.droppedNow plays for field-mapping overrides. Persist
   * `results`' overrides list (not shown here — a P2.3/P4 caller's job) with
   * every entry's `status` set to whatever this array reports for it. */
  droppedOverrides: readonly VariantMatchOverride[]
}

/**
 * matchVariants, with persisted user decisions applied FIRST and winning
 * over the automatic cascade for the variants they cover. Each override is
 * re-identified via VariantMatchOverrideIdentitySnapshot (compositeCanonicalKey
 * + dimensions, NEVER column position) against the CURRENT `left`/`right`
 * arrays via `findAllByIdentitySnapshot`; one that no longer resolves
 * uniquely on either required side is dropped (reported in
 * `droppedOverrides`, never silently applied to a different variant that
 * happens to occupy the same array slot, and never silently applied to the
 * FIRST of two-or-more equally-identified variants either —
 * adversarial-review-of-#317 F4 fix). Two distinct drop reasons are
 * distinguished: `'dropped_on_drift'` (zero current variants carry this
 * identity any more, or the one that does is already claimed by an earlier
 * override this same call) vs. `'dropped_ambiguous_identity'` (2+ current
 * variants carry the EXACT SAME identity — almost always two no-identity,
 * `compositeCanonicalKey === ''`, variants — so there is no principled way to
 * tell which one this override was actually meant for).
 */
/** Resolves one side of an override's snapshot to a single variant index —
 * the SCOPE of the F4 "ambiguous_identity" classification is deliberately
 * narrow: only a genuinely IDENTITY-LESS snapshot (`compositeCanonicalKey
 * === ''`) that matches 2+ current variants is reported as
 * `'ambiguous_identity'` (finding 4's own wording: "zwei identisch
 * ge-key-ten, IDENTITÄTSLOSEN Varianten"). A non-empty compositeCanonicalKey
 * is derived deterministically from `dimensions` (identity.ts's own
 * determinism guarantee) AND doubles as `stableInternalId` for any
 * REAL-parsed variant (header-parser.ts), so two variants colliding on a
 * non-empty key are already indistinguishable further upstream — this is a
 * pre-existing, accepted "duplicate-looking variant" case (matcher.ts's own
 * manual-override precedent: a reviewer disambiguates a duplicate pair by
 * explicitly choosing ONE via its `stableInternalId` in the UI,
 * `setVariantMatchOverride`) — reusing the historical first-match resolution
 * for that case, never refusing it, keeps that existing, desired workflow
 * intact. */
function resolveOverrideSideIndex(variants: readonly VariantDefinition[], snapshot: VariantMatchOverrideIdentitySnapshot): number | 'ambiguous' {
  const matches = findAllByIdentitySnapshot(variants, snapshot)
  if (matches.length === 1) return matches[0]!
  if (matches.length === 0) return -1
  return snapshot.compositeCanonicalKey === '' ? 'ambiguous' : matches[0]!
}

export function matchVariantsWithOverrides(
  left: readonly VariantDefinition[],
  right: readonly VariantDefinition[],
  overrides: readonly VariantMatchOverride[],
  config: VariantMatchConfig = DEFAULT_VARIANT_MATCH_CONFIG,
): ApplyVariantMatchOverridesResult {
  const droppedOverrides: VariantMatchOverride[] = []
  const usedLeft = new Set<number>()
  const usedRight = new Set<number>()
  const forced: VariantMatchResult[] = []

  for (const o of overrides) {
    if (o.status === 'dropped_on_drift' || o.status === 'dropped_ambiguous_identity') {
      droppedOverrides.push(o)
      continue
    }
    const leftResolved = resolveOverrideSideIndex(left, o.left)
    if (leftResolved === 'ambiguous') {
      droppedOverrides.push({ ...o, status: 'dropped_ambiguous_identity' })
      continue
    }
    const li = leftResolved
    if (li === -1 || usedLeft.has(li)) {
      droppedOverrides.push({ ...o, status: 'dropped_on_drift' })
      continue
    }
    if (o.decision === 'unmatched' || o.right === null) {
      usedLeft.add(li)
      forced.push({ kind: 'unmatched_left', leftIndex: li, leftId: left[li]!.stableInternalId, reason: 'removed' })
      continue
    }
    const rightResolved = resolveOverrideSideIndex(right, o.right)
    if (rightResolved === 'ambiguous') {
      droppedOverrides.push({ ...o, status: 'dropped_ambiguous_identity' })
      continue
    }
    const ri = rightResolved
    if (ri === -1 || usedRight.has(ri)) {
      droppedOverrides.push({ ...o, status: 'dropped_on_drift' })
      continue
    }
    usedLeft.add(li)
    usedRight.add(ri)
    forced.push({
      kind: 'matched',
      leftIndex: li,
      leftId: left[li]!.stableInternalId,
      rightIndex: ri,
      rightId: right[ri]!.stableInternalId,
      stage: 'manual_override',
      confidence: 1,
      evidence: [{ signal: 'manual_override', detail: o.note ?? 'Manuell bestätigte Zuordnung.' }],
      explanation: explanationForStage('manual_override'),
      // A human reviewer confirmed this pairing directly — matchedDimensionCount
      // is informational only here (never gated, unlike the automatic
      // cascade's own fuzzy-fallback translation), computed the same way the
      // cascade itself does for consistency.
      matchedDimensionCount: coreDimensionKeys(left[li]!.dimensions, right[ri]!.dimensions).length,
    })
  }

  const leftRest = left.map((v, i) => ({ v, i })).filter((x) => !usedLeft.has(x.i))
  const rightRest = right.map((v, i) => ({ v, i })).filter((x) => !usedRight.has(x.i))
  const autoRaw = matchVariants(
    leftRest.map((x) => x.v),
    rightRest.map((x) => x.v),
    config,
  )
  const auto = remapResultIndices(
    autoRaw,
    leftRest.map((x) => x.i),
    rightRest.map((x) => x.i),
  )

  return { results: [...forced, ...auto], droppedOverrides }
}
