// Multi-QAF variant identity: composite canonical key + active-state
// derivation (KAR-929 / Multi-QAF-Programm P1.1).
//
// Master-Prompt §9: "A Multi-QAF variant cannot be identified only by its
// column number or labels such as V1. Create a canonical variant identity
// from the available normalized dimensions... Matching must support: same
// variant in a different column, renamed variant, reordered variants,
// ... aliases, spelling differences, German and English labels..."
//
// This module builds that identity (buildCompositeCanonicalKey) and derives
// the active/inactive/reserved classification (deriveActiveState) — both
// pure functions over already-extracted VariantDimensions/volume data, no
// ExcelJS/workbook access (that is P1.2's job).
//
// Reuses the existing normalizer rather than reinventing umlaut-folding/
// case-folding (same discipline canonical-model.ts's normalizeCanonicalLabel
// already established for QAF field labels): normalizeProcessName lowercases,
// folds ä/ö/ü/ß, and collapses whitespace — exactly what makes "Rechtslenker"
// and "rechtslenker" (or "Links-/Rechtslenker" spacing variants) compare
// equal, and what makes a German dimension VALUE and its English
// counterpart compare equal ONLY when they are literally the same token
// (e.g. both files spell a drive type "AWD") — cross-language SYNONYMS for
// variant-dimension VALUES (as opposed to field LABELS, which canonical-
// model.ts's registry already covers) are not attempted here: the 4 real
// files never showed a DE/EN-translated dimension VALUE pair (only DE/EN
// field-LABEL pairs, e.g. "Antrieb"/"Drive type"), so there is no evidence
// base yet to build a dimension-value synonym table against — a future
// P2.2 (Varianten-Identität + Matching) item once real cross-language
// variant-value evidence exists, not invented speculatively here.

import { normalizeProcessName } from '../normalizer'
import {
  KNOWN_VARIANT_DIMENSION_KEYS,
  type MultiQafCellRef,
  type MultiQafWarning,
  type VariantActiveState,
  type VariantDefinition,
  type VariantDimensions,
  type VariantDimensionValue,
} from './types'

/** Normalize a raw dimension value string for identity comparison — same
 * primitive canonical-model.ts's normalizeCanonicalLabel builds on
 * (normalizeProcessName), used directly here since dimension values (short
 * codes like "AWD", "LL", "STD_1.1") have none of the footnote/numbering
 * noise that field labels do — stripping those would be a no-op here, not
 * a missing feature. */
export function normalizeDimensionValue(raw: string): string {
  return normalizeProcessName(raw)
}

/** Build one VariantDimensionValue from a raw header/cell string. */
export function makeDimensionValue(raw: string, sourceCell: MultiQafCellRef | null = null): VariantDimensionValue {
  return { raw, normalized: normalizeDimensionValue(raw), sourceCell }
}

/**
 * Deterministic composite canonical key from a variant's dimension map.
 * Stability guarantees this must uphold (Master-Prompt §9's matching list,
 * verified by __tests__/identity.test.ts's reorder/rename scenarios):
 *   - Column-reorder-invariant: the key depends only on dimension VALUES,
 *     never on originalColumn/originalColumnIndex/originalVariantNumber —
 *     moving a variant from column Q to column AF (or renumbering its
 *     slot index) does not change its key.
 *   - Deterministic ordering: known dimensions are emitted in
 *     KNOWN_VARIANT_DIMENSION_KEYS order first (so two variants with the
 *     same dimension VALUES always serialize identically regardless of the
 *     Record's own key insertion order — object key order is not something
 *     a P1.2 parser should have to guarantee), then any additional/unknown
 *     keys alphabetically.
 *   - Renamed-but-equivalent labels collapse to the same key when they
 *     normalize equal (normalizeDimensionValue), e.g. trailing-space or
 *     case differences.
 *   - Empty/blank dimension values are dropped, not encoded as empty
 *     segments (so "region=" noise never appears, and two variants that
 *     both lack a dimension don't spuriously "agree" on an empty value for
 *     it — they simply omit it from the key on both sides, same outcome
 *     either way, but the representation used is neutral rather than
 *     asserting a fact).
 *
 * `fallback` (typically the variant's raw code/label, e.g. "Q71.BL2K" or
 * "V201_LC1") is used ONLY when every dimension is empty — a variant with
 * literally no structured dimensions extracted yet still needs a stable,
 * non-empty key rather than an empty string that would collide with every
 * other undimensioned variant. Real-file precedent: 10-analyse-clarwe-eu.md
 * C.1 footnote — Zusammenfassung columns AE-AH have NO reliable "Fahrzeug"
 * header value (a Header-Merge gap), so a parser reading only that sheet may
 * legitimately have to fall back to the variant code read elsewhere (BOM
 * formula-lineage) for those columns.
 */
export function buildCompositeCanonicalKey(dimensions: VariantDimensions, fallback?: string | null): string {
  const parts: string[] = []
  const seen = new Set<string>()

  for (const key of KNOWN_VARIANT_DIMENSION_KEYS) {
    const v = dimensions[key]
    if (v && v.normalized !== '') {
      parts.push(`${key}=${v.normalized}`)
      seen.add(key)
    }
  }

  const extraKeys = Object.keys(dimensions)
    .filter((k) => !seen.has(k))
    .sort()
  for (const key of extraKeys) {
    const v = dimensions[key]
    if (v && v.normalized !== '') parts.push(`${key}=${v.normalized}`)
  }

  if (parts.length === 0) {
    const normalizedFallback = fallback ? normalizeDimensionValue(fallback) : ''
    return normalizedFallback === '' ? '' : `fallback=${normalizedFallback}`
  }
  return parts.join('|')
}

/**
 * True when a variant slot carries ANY identity signal at all — at least one
 * non-empty dimension, or at least one non-empty original label. Distinct
 * from `activeState`: a slot can have identity but zero volume (inactive),
 * or no identity at all (reserved) — see deriveActiveState below.
 */
export function hasVariantIdentity(dimensions: VariantDimensions, originalLabels: readonly string[]): boolean {
  const hasDimension = Object.values(dimensions).some((v) => v.normalized !== '')
  if (hasDimension) return true
  return originalLabels.some((l) => l.trim() !== '')
}

/**
 * Master-Prompt §10 active/inactive/reserved derivation, calibrated against
 * the MX empirical pattern (see VariantActiveState doc in types.ts for the
 * exact 25/12/10 slot breakdown this mirrors):
 *   - no identity at all               -> 'reserved'
 *   - identity, no positive volume     -> 'inactive'
 *   - identity AND any volume > 0      -> 'active'
 *
 * `volumes` takes all three volume fields (annual/peak/lifetime) rather than
 * a single number because a real file can populate any subset of them
 * independently (10-analyse-mx.md C: slots 4/8 have a header but volume
 * fields all read 0) — "any positive" is intentionally permissive: a
 * variant with e.g. only a peak volume filled in is still meaningfully
 * active, not a data-quality problem this function should mask as
 * 'inactive'.
 */
export function deriveActiveState(
  hasIdentity: boolean,
  volumes: {
    annualVolume: number | null
    peakVolume: number | null
    lifetimeVolume: number | null
  }
): VariantActiveState {
  if (!hasIdentity) return 'reserved'
  const hasPositiveVolume = [volumes.annualVolume, volumes.peakVolume, volumes.lifetimeVolume].some(
    (v) => v !== null && v > 0
  )
  return hasPositiveVolume ? 'active' : 'inactive'
}

// ── Canonical-key collision detection + disambiguation (KAR-929 adversarial
// review F2) ──────────────────────────────────────────────────────────────
//
// buildCompositeCanonicalKey above is deliberately single-variant/pure — it
// has no visibility into sibling variants, so it cannot itself detect that
// two variants collided on the same key. That is a CONTAINER-level concern
// (this section), kept as its own pair of pure functions rather than folded
// into buildCompositeCanonicalKey, for the same reason deriveActiveState is
// its own function: each function does exactly one thing over exactly the
// inputs it needs.
//
// Two real collision shapes motivated this (KAR-929 review F2):
//   - Two REAL variants share every dimension value (e.g. two volume
//     allocations of the same underlying spec) — bridge.ts stamps
//     compositeCanonicalKey onto QafSummary.partNumber, so an undetected
//     collision here means two genuinely different variants silently look
//     like "the same part" downstream, which is exactly the kind of
//     ambiguous match §9's doctrine forbids matching silently.
//   - Two Reserved slots with NO identity at all both produce the empty
//     fallback key ('') — see buildCompositeCanonicalKey's own doc comment
//     ("empty/blank dimension values are dropped... two variants that both
//     lack a dimension don't spuriously 'agree'"): the KEY comparison
//     doesn't distinguish them, but bridge.ts must still never let '' pass
//     through as a partNumber (see bridge.ts's own empty-key guard).

type CollisionCandidate = Pick<VariantDefinition, 'stableInternalId' | 'compositeCanonicalKey'>

/**
 * Pure, container-level pass: groups variants by compositeCanonicalKey and
 * returns one warning per key shared by 2+ variants — including the empty
 * key ('', e.g. two reserved slots with zero dimensions). Never resolves or
 * merges anything itself (§9 doctrine: ambiguous variants are never
 * silently matched for aggregation) — only detects and reports.
 * disambiguateCanonicalKeys below is the separate, opt-in resolution step.
 */
export function detectCanonicalKeyCollisions(variants: readonly CollisionCandidate[]): MultiQafWarning[] {
  const byKey = new Map<string, string[]>()
  for (const v of variants) {
    const existing = byKey.get(v.compositeCanonicalKey)
    if (existing) existing.push(v.stableInternalId)
    else byKey.set(v.compositeCanonicalKey, [v.stableInternalId])
  }

  const warnings: MultiQafWarning[] = []
  for (const [key, ids] of byKey) {
    if (ids.length < 2) continue
    warnings.push({
      code: 'canonical_key_collision',
      severity: 'warning',
      message:
        key === ''
          ? `${ids.length} Varianten ohne jede Dimension/Label teilen sich den leeren Fallback-Schlüssel und sind über ihre Identität allein nicht unterscheidbar: ${ids.join(', ')}.`
          : `${ids.length} Varianten teilen sich denselben kompositen Schlüssel "${key}" (identische Dimensionswerte) und würden ohne Disambiguierung bei der Aggregation stillschweigend zusammengeführt: ${ids.join(', ')}.`,
      messageEn:
        key === ''
          ? `${ids.length} variants with no dimension/label at all share the empty fallback key and cannot be distinguished by identity alone: ${ids.join(', ')}.`
          : `${ids.length} variants share the same composite key "${key}" (identical dimension values) and would be silently merged during aggregation without disambiguation: ${ids.join(', ')}.`,
      sourceReferences: [],
      // KAR-935 adversarial-review F5 fix: this message never followed the
      // "Variante ${id}: ..." DE-prefix convention (it names MULTIPLE
      // variants at once), so the old string-prefix-only
      // `variantWarningsFor` (container-assembly.ts) could never attach a
      // genuine identity collision — this package's own most safety-critical
      // warning class — back onto either colliding VirtualQafVariant.
      // `variantIds` is the structural fix; every colliding id is listed so
      // BOTH sides of the collision surface it in their own `.warnings`.
      variantIds: ids,
      // KAR-935 adversarial-review F4 fix: a genuine canonical-key collision
      // is exactly the kind of ambiguous-identity finding a human should
      // look at before the container's aggregation is trusted (see
      // MultiQafWarning.reviewRelevant's own doc comment for why this is a
      // separate axis from `severity`).
      reviewRelevant: true,
    })
  }
  return warnings
}

type DisambiguationCandidate = Pick<
  VariantDefinition,
  'stableInternalId' | 'compositeCanonicalKey' | 'originalVariantNumber' | 'originalColumn'
>

/**
 * Deterministic disambiguation for a genuine canonical-key collision:
 * every member of a collision group (2+ variants sharing a key) gets a
 * suffix appended, derived from a stable STRUCTURAL source — the printed
 * `originalVariantNumber` when present, else `originalColumn` — NEVER
 * volume (volume is payload/business data that can legitimately differ
 * between an ALT/NEU snapshot of the same variant; using it as an identity
 * component would make the key unstable across a simple volume update).
 *
 * Every colliding member is suffixed (not "first keeps the bare key, rest
 * get suffixed") specifically so the result is independent of array
 * iteration order — the same input variants, passed in a different array
 * order (e.g. a re-parse that emitted them in a different sequence),
 * produce the exact same disambiguated key per variant every time. This is
 * a narrower guarantee than full column-reorder invariance (originalColumn
 * itself changes on a real reorder, and originalVariantNumber MAY be
 * reassigned too — see VariantDefinition.originalVariantNumber's own doc
 * comment) — it is the best determinism achievable once dimension VALUES
 * alone are provably insufficient to distinguish two variants, which this
 * function only ever runs for.
 */
export function disambiguateCanonicalKeys<T extends DisambiguationCandidate>(variants: readonly T[]): readonly T[] {
  const counts = new Map<string, number>()
  for (const v of variants) counts.set(v.compositeCanonicalKey, (counts.get(v.compositeCanonicalKey) ?? 0) + 1)

  return variants.map((v) => {
    if ((counts.get(v.compositeCanonicalKey) ?? 0) < 2) return v
    const suffixSource = v.originalVariantNumber ?? v.originalColumn
    const suffix = normalizeDimensionValue(suffixSource)
    return { ...v, compositeCanonicalKey: `${v.compositeCanonicalKey}#dup-${suffix}` }
  })
}
