// Multi-QAF variant header-block parser (KAR-930 / Multi-QAF-Programm P1.2,
// Epic KAR-925).
//
// Reads a Multi-QAF workbook's Summary-sheet (or, called again with a
// different grid, a BOM/detail sheet — this module is sheet-agnostic, see
// HeaderParserGridInput.sheet) variant header block: a Multi-Row-Header-Block
// of dimension rows, an optional numbered Slot-Index row, and separate
// volume rows — and produces VariantDefinition[] (types.ts, this PR's
// domain model) plus a full ColumnClassification[] scan report (Master-
// Prompt §10). Pure — no ExcelJS import at the top level; the ExcelJS bridge
// at the bottom mirrors workbook-adapter.ts's own "pure core / thin adapter"
// split (qaf-type-detector.ts is the closest sibling in this codebase).
//
// PRECONDITION (task instruction, NOT enforced by this module): a caller
// only invokes this parser once qaf-type-detector.ts's detectMultiQaf has
// already returned 'confirmed_multi_qaf' or 'probable_multi_qaf' for the
// same workbook. This module has no opinion on that — it is a pure function
// over whatever grid it is handed, wiring it to the detector's verdict is
// P2's job (module header of types.ts: "no ingest wiring" for this PR).
//
// ── Real-file evidence for the algorithm below ──────────────────────────
//
// Slot-index-row detection (10-analyse-mx.md C): Row 2 carries a fortlaufend
// (with GAPS in column position, never in VALUE) index 1..25 across the
// variant columns — except column AB, which also carries a numeric Row-2
// value (1) but is NOT a continuation of the sequence (it appears AFTER
// column Z's value 10, spatially, while itself being smaller) — a manually
// inserted benchmark pseudo-slot, not a 26th real slot. locateSlotIndexRow
// below finds the row with the LONGEST strictly-increasing left-to-right
// run of positive-integer cell values (a simple greedy scan: accept a value
// only if it is greater than the last ACCEPTED value) — this single rule
// naturally reproduces both real findings at once: AB's out-of-order '1'
// fails the "greater than last accepted (10)" test and is correctly
// demoted to an outlier, while every genuinely ascending slot (including
// across the real column gaps at AA and AC–AE, which simply carry no value
// at all and are skipped) is accepted.
//
// Multi-row dimension block (10-analyse-ncar.md D, 10-analyse-clarwe-eu.md
// C.1): variant dimensions spread across up to 9 physical header rows, each
// carrying a row-label (Fahrzeug/Motor/Antrieb/Lenkung/...) — this module
// reads the row-label from the LAST non-empty cell strictly left of the
// scanned column band (covers both the "column A/B" convention and
// 10-analyse-mx.md C's "column P immediately left of the Q.. band" finding
// in one rule) and maps it to a KNOWN_VARIANT_DIMENSION_KEYS entry via a
// DE/EN synonym table, falling back to a slugified version of the raw label
// (or `row<N>` when no label at all) for anything unrecognized — never a
// closed vocabulary (types.ts KNOWN_VARIANT_DIMENSION_KEYS doc: "NOT a
// closed union").
//
// 10-analyse-ncar.md D's file has NO slot-index row at all — its 8 variants
// are identified purely from the dimension-row block + a per-column
// "BMW Variant ID" row. locateSlotIndexRow correctly returns null for that // allow-customer-string
// shape (no row reaches MIN_SLOT_INDEX_RUN), and this module's variant
// assembly falls back to identity-only detection for every column, exactly
// the same code path 10-analyse-clarwe-eu.md C.3's two orphaned-but-real
// BOM columns (AN/AO, full identity + volume, NO index value of any kind)
// also take.
//
// Volume rows (10-analyse-nafta.md C "Stückzahl lifetime/Ø Jahr/peak",
// 10-analyse-ncar.md D rows 14-16): located the same way as dimension rows
// (row-label match), but via a DEDICATED DE/EN synonym table per volume
// field, kept separate from the generic dimension-key table so a volume row
// is never accidentally folded into VariantDefinition.dimensions.
//
// Column-classification LABEL (facts.label, column-classifier.ts): read
// from a DIFFERENT source than the identity dimension rows on purpose — the
// first non-empty, non-numeric text cell found for that column in the
// UNCLAIMED body rows (rows not already consumed by the index/dimension/
// volume rows above, same bounded row window). This is a deliberate design
// choice, not a real-file position (10-analyse-mx.md D never states which
// exact row the analysts read the benchmark column's descriptive label
// from) — keeping the classification
// label signal structurally separate from the identity-dimension signal is
// what prevents a benchmark/comment annotation from ever being
// misinterpreted as variant identity (a benchmark column with a text
// annotation must never grant it identity through this path). The identity
// verdict itself (hasIdentityByColumn, computed once in
// parseVariantHeaderBlock) is the SINGLE source of truth shared by both
// assembleVariant (VariantDefinition.activeState/confidence) and
// classifyColumn (facts.hasIdentity) — KAR-930 review F2 fix: these two
// used to be computed independently (assembleVariant re-derived its own
// verdict via identity.ts's hasVariantIdentity over dimensions AND
// originalLabels, which — unlike hasIdentityByColumn — counts the
// variant-code row's value too), so a reserved slot pre-populated with only
// a placeholder code could get materialized as an active/inactive
// VariantDefinition while columnClassifications correctly still called it
// reserved_placeholder.

import type { Worksheet } from 'exceljs'
import { worksheetToGrid, worksheetToFormulaGrid } from '../workbook-adapter'
import { a1, cellToString } from '../summary-parser'
import { parseLocaleNumber, normalizeProcessName } from '../normalizer'
import { SHARED_FORMULA_UNRESOLVED } from '../formula-engine'
import { AW_SCAN_TO } from '../summary-metrics'
import { VARIANT_BAND_COL_TO } from '../qaf-type-detector'
import { buildCompositeCanonicalKey, deriveActiveState, makeDimensionValue } from './identity'
import { columnIndexToLetter } from './types'
import type { ColumnClassification, MultiQafCellRef, VariantDefinition, VariantDimensionDescriptor, VariantDimensions } from './types'
import { classifyColumn, type ColumnClassificationContext, type ColumnClassificationFacts } from './column-classifier'

type FormulaCell = string | null | typeof SHARED_FORMULA_UNRESOLVED

// ── Pure input/options ──────────────────────────────────────────────────────

export interface HeaderParserGridInput {
  /** Sheet this grid was read from — carried into every MultiQafCellRef this
   * module produces. This module is sheet-agnostic: call it once per sheet
   * (Summary, then separately a BOM/detail sheet) — cross-sheet linking is
   * out of scope (P1.5 Formel-Lineage-Modul). */
  sheet: string
  /** 0-based row-major grid of already-resolved cell values — merged-cell
   * propagation (cell.master) MUST already have happened upstream (see
   * workbook-adapter.ts resolveCell/worksheetToGrid); this module never
   * touches ExcelJS Cell objects directly at its pure core. */
  grid: readonly unknown[][]
  /** Parallel formula-text grid (workbook-adapter.ts worksheetToFormulaGrid),
   * same 0-based indexing. Optional — omitted entirely, every column's
   * `formulas` fact stays empty and formula-lineage-based classification
   * signals (delta/percentage/total) never fire (never guessed from values
   * alone). */
  formulaGrid?: readonly FormulaCell[][]
}

export interface HeaderParserOptions {
  /** 0-based inclusive column scan bounds. Defaults derive from existing
   * shared constants per this PR's task instruction ("teile Konstanten...
   * statt sie zu duplizieren") — see DEFAULT_COL_FROM/DEFAULT_COL_TO below
   * for exactly how. */
  colFrom?: number
  colTo?: number
  /** 1-based inclusive row scan bounds for the header/volume/body region. */
  rowFrom?: number
  rowTo?: number
}

// ── Constants ────────────────────────────────────────────────────────────

/**
 * 0-based start column of the header-block scan. DERIVED from AW_SCAN_TO
 * (summary-metrics.ts), not a same-value magic number (same discipline
 * qaf-type-detector.ts's own VARIANT_BAND_COL_FROM already established for
 * ITS scan-start derivation) — but derived differently, and deliberately
 * NOT reusing qaf-type-detector.ts's VARIANT_BAND_COL_FROM value directly,
 * because that constant is intentionally placed ONE COLUMN PAST AW_SCAN_TO
 * to keep the DETECTOR's population-counting signal (S2/S4) structurally
 * incapable of colliding with a standard QAF's own AW1-3 header band — a
 * detector false-positive concern that does not apply here (module header
 * PRECONDITION: this parser only ever runs on an already-confirmed Multi-QAF
 * workbook). All 4 real Multi-QAF files' variant bands independently start
 * at column Q (10-analyse-mx.md C, 10-analyse-clarwe-eu.md C.1,
 * 10-analyse-nafta.md C, 10-analyse-ncar.md D) — column Q is exactly
 * AW_SCAN_TO - 1 (AW_SCAN_TO=17=column R 0-based; Q=16). Purely additive:
 * no change to AW_SCAN_TO itself or any of its other callers.
 */
export const DEFAULT_COL_FROM = AW_SCAN_TO - 1 // column Q

/** Generous upper bound reused verbatim from qaf-type-detector.ts (already
 * calibrated: "generous headroom past every real sample's last variant
 * column in the corpus"). */
export const DEFAULT_COL_TO = VARIANT_BAND_COL_TO

export const DEFAULT_ROW_FROM = 1

/**
 * Generous lower bound covering every documented real header-row range
 * (10-analyse-ncar.md D: dimension rows 4-12 + volume rows 14-16;
 * 10-analyse-mx.md C/D: slot-index row 2, benchmark/delta evidence at row
 * 15-17) with headroom — deliberately NOT the full 55-81 manual-helper-zone
 * depth 10-analyse-mx.md D also documents (that zone is a hand-built,
 * one-off analyst worksheet extension, not a structural pattern this PR's
 * fixtures reproduce or a P1.2 classification target).
 */
export const DEFAULT_ROW_TO = 40

const MIN_SLOT_INDEX_RUN = 3
const MIN_DIMENSION_ROW_POPULATION = 2
/** See locateDimensionRows' "banner row" guard below. */
const MIN_DIMENSION_ROW_DISTINCT_VALUES = 2
/** See locateDimensionRows' "banner row" guard below. */
const DOMINANT_VALUE_FRACTION_LIMIT = 0.8

/** See locateDimensionRows' "banner row" guard below (KAR-930 review F1/F5
 * fix): a value this LONG, or matching a title/version/footer vocabulary, is
 * shaped like workbook-chrome text (a merge-propagated banner/footer), never
 * a genuine short dimension-row token (real examples across all 4 files:
 * "AWD", "LL", "STD_1.1" — none anywhere near this length). */
const BANNER_VALUE_MAX_PLAUSIBLE_LENGTH = 24
const BANNER_VALUE_HINT_RE = /version|copyright|©|vertraulich|confidential|entwurf|draft|vorlage|template/i

function looksLikeBannerValue(raw: string): boolean {
  return raw.length > BANNER_VALUE_MAX_PLAUSIBLE_LENGTH || BANNER_VALUE_HINT_RE.test(raw)
}

// ── Label synonym tables (DE/EN, same discipline as summary-metrics.ts
// SYNONYMS) ──────────────────────────────────────────────────────────────

const VARIANT_CODE_LABEL_KEY = '__variantCode__'

function normalizeLabel(raw: string): string {
  return normalizeProcessName(raw)
}

/** Builds a normalized-key lookup table from [rawLabel, value] pairs — the
 * table is authored with the RAW (unfolded) DE/EN label text for
 * readability, then normalized once at module-load time through the exact
 * same normalizeLabel() every row-label lookup uses, so a synonym entry can
 * never silently drift out of sync with the umlaut-folding rules (KAR-930
 * review fix: hand-folding "ü"->"ue" etc. inline in the table source is
 * exactly the kind of duplicated-logic bug this avoids). */
function buildSynonymTable<T extends string>(entries: ReadonlyArray<readonly [string, T]>): Record<string, T> {
  const table: Record<string, T> = {}
  for (const [rawLabel, value] of entries) table[normalizeLabel(rawLabel)] = value
  return table
}

const DIMENSION_LABEL_SYNONYMS: Record<string, string> = buildSynonymTable([
  ['Fahrzeug', 'vehicle'],
  ['Fahrzeug/Projekt', 'vehicle'],
  ['Vehicle', 'vehicle'],
  ['Vehicle/Project', 'vehicle'],
  ['Plattform', 'platform'],
  ['Platform', 'platform'],
  ['Antrieb', 'driveType'],
  ['Drive type', 'driveType'],
  ['LL/RL', 'steeringSide'],
  ['Lenkseite', 'steeringSide'],
  ['Links-/Rechtslenker', 'steeringSide'],
  ['Steering side', 'steeringSide'],
  ['Rack-Typ', 'rackType'],
  ['Rack type', 'rackType'],
  ['SCU', 'scu'],
  ['EPS-Typ', 'ecuType'],
  ['ECU-Typ', 'ecuType'],
  ['ECU type', 'ecuType'],
  ['Motor', 'motor'],
  ['Engine', 'motor'],
  ['Achscode', 'axleCode'],
  ['Axle code', 'axleCode'],
  ['FIT-Klasse', 'fitClass'],
  ['FIT-Rate', 'fitClass'],
  ['FIT class', 'fitClass'],
  ['FIT rate', 'fitClass'],
  ['ASIL', 'asilClass'],
  ['ASIL-Klasse', 'asilClass'],
  ['ASIL class', 'asilClass'],
  ['Übersetzung (ivar/iconst)', 'ratioConstancy'],
  ['Ubersetzung (ivar/iconst)', 'ratioConstancy'],
  ['Übersetzung', 'transmissionRatio'],
  ['Ubersetzung', 'transmissionRatio'],
  ['Ratio', 'transmissionRatio'],
  ['Gear ratio', 'transmissionRatio'],
  ['Local-Content-Stufe', 'localContentLevel'],
  ['Local content level', 'localContentLevel'],
  // Variant-code/identity rows — NOT folded into `dimensions`, used to
  // populate VariantDefinition.originalVariantNumber/originalLabels
  // directly (see assembleVariant below).
  ['Variantencode', VARIANT_CODE_LABEL_KEY],
  ['Sachnummer/Variantencode', VARIANT_CODE_LABEL_KEY],
  ['Sachnummer', VARIANT_CODE_LABEL_KEY],
  ['BMW Variant', VARIANT_CODE_LABEL_KEY], // allow-customer-string
  ['BMW Variant ID', VARIANT_CODE_LABEL_KEY], // allow-customer-string
  ['Variant code', VARIANT_CODE_LABEL_KEY],
  ['Variant ID', VARIANT_CODE_LABEL_KEY],
  ['Variante', VARIANT_CODE_LABEL_KEY],
])

type VolumeKind = 'annualVolume' | 'peakVolume' | 'lifetimeVolume'

const VOLUME_LABEL_SYNONYMS: Record<string, VolumeKind> = buildSynonymTable<VolumeKind>([
  ['Stückzahl Ø/Jahr', 'annualVolume'],
  ['Durchschn. Jahresstückzahl', 'annualVolume'],
  ['Volumen Ø/a', 'annualVolume'],
  ['Jahresstückzahl', 'annualVolume'],
  ['Annual volume', 'annualVolume'],
  ['Average volume/year', 'annualVolume'],
  ['Stückzahl peak', 'peakVolume'],
  ['Volumen Peakjahr', 'peakVolume'],
  ['Peak volume', 'peakVolume'],
  ['Peak year volume', 'peakVolume'],
  ['Stückzahl lifetime', 'lifetimeVolume'],
  ['Volumen Lifetime', 'lifetimeVolume'],
  ['Lifetime volume', 'lifetimeVolume'],
  ['Lifetime quantity', 'lifetimeVolume'],
])

function slugifyLabel(raw: string): string {
  const normalized = normalizeLabel(raw)
  const slug = normalized.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
  return slug === '' ? '' : `dim_${slug}`
}

// ── Grid access helpers ──────────────────────────────────────────────────

function cellAt(grid: readonly unknown[][], row1: number, col0: number): unknown {
  return grid[row1 - 1]?.[col0]
}

function formulaAt(formulaGrid: readonly FormulaCell[][] | undefined, row1: number, col0: number): FormulaCell {
  if (!formulaGrid) return null
  return formulaGrid[row1 - 1]?.[col0] ?? null
}

function cellRef(sheet: string, row1: number, col0: number): MultiQafCellRef {
  return { sheet, cell: a1(row1 - 1, col0), row: row1 - 1, column: col0 + 1 }
}

/** Row-label text for a header row — the LAST non-empty string cell strictly
 * left of colFrom (covers both the "column A/B" and "column immediately
 * left of the variant band" real conventions, see module header). */
function rowLabel(grid: readonly unknown[][], row1: number, colFrom: number): string | null {
  for (let c = colFrom - 1; c >= 0; c--) {
    const s = cellToString(cellAt(grid, row1, c))
    if (s !== null && s !== '') return s
  }
  return null
}

/** True when the raw cell value parses as a clean positive integer — the
 * slot-index-row value shape (never a money amount, never a fractional
 * quantity factor). */
function positiveIntegerValue(raw: unknown): number | null {
  if (typeof raw === 'number') {
    return Number.isInteger(raw) && raw >= 1 ? raw : null
  }
  if (typeof raw === 'string') {
    const trimmed = raw.trim()
    if (!/^\d+$/.test(trimmed)) return null
    const n = Number(trimmed)
    return Number.isInteger(n) && n >= 1 ? n : null
  }
  return null
}

/** True when the cell holds non-empty TEXT that is not itself a clean
 * number (dimension-row value shape) — distinguishes a dimension row's
 * string content from a slot-index/volume row's numeric content, without
 * requiring the caller to already know which row kind it is scanning. */
function textValue(raw: unknown): string | null {
  const s = cellToString(raw)
  if (s === null || s === '') return null
  if (positiveIntegerValue(raw) !== null) return null
  if (parseLocaleNumber(raw) !== null && /^-?[\d.,\s]+$/.test(s)) return null
  return s
}

// ── Slot-index-row detection ────────────────────────────────────────────

export interface SlotIndexRowResult {
  /** 1-based row. */
  row: number
  /** 0-based column -> accepted slot number (the primary strictly-increasing
   * run). */
  primary: ReadonlyMap<number, number>
  /** 0-based column -> the numeric index-row value that did NOT extend the
   * primary run (10-analyse-mx.md C's "AB=1 Ausreißer"). */
  outliers: ReadonlyMap<number, number>
}

/**
 * Finds the header row with the longest strictly-increasing (left-to-right)
 * run of positive-integer values in [colFrom, colTo] — see module header for
 * why this single greedy rule handles both the real gap pattern and the
 * real outlier pattern. Rows in `excludedRows` (already claimed as a volume
 * row) are skipped. Returns null when no row reaches MIN_SLOT_INDEX_RUN.
 */
export function locateSlotIndexRow(
  input: HeaderParserGridInput,
  colFrom: number,
  colTo: number,
  rowFrom: number,
  rowTo: number,
  excludedRows: ReadonlySet<number> = new Set(),
): SlotIndexRowResult | null {
  let best: SlotIndexRowResult | null = null
  for (let r = rowFrom; r <= rowTo; r++) {
    if (excludedRows.has(r)) continue
    const primary = new Map<number, number>()
    const outliers = new Map<number, number>()
    let lastAccepted = 0
    for (let c = colFrom; c <= colTo; c++) {
      const value = positiveIntegerValue(cellAt(input.grid, r, c))
      if (value === null) continue
      if (value > lastAccepted) {
        primary.set(c, value)
        lastAccepted = value
      } else {
        outliers.set(c, value)
      }
    }
    if (primary.size >= MIN_SLOT_INDEX_RUN && (best === null || primary.size > best.primary.size)) {
      best = { row: r, primary, outliers }
    }
  }
  return best
}

// ── Volume-row detection ────────────────────────────────────────────────

export interface VolumeRowMatch {
  row: number
  values: ReadonlyMap<number, number>
}

/** One located dimension row (kept private to this module — see
 * locateDimensionRows). */
interface DimensionRowMatch {
  row: number
  /** Known key (KNOWN_VARIANT_DIMENSION_KEYS entry, a slugified fallback
   * key, or VARIANT_CODE_LABEL_KEY for an identity/variant-code row). */
  key: string
  label: string | null
  values: ReadonlyMap<number, string>
  /** True when this row passed the banner guard only because it is a
   * genuine (labeled) but SKEWED extensible dimension — kept rather than
   * dropped (KAR-930 review F1 fix), but flagged so assembleVariant can
   * apply a confidence penalty instead of asserting the same certainty a
   * clean/balanced dimension row would get. */
  ambiguousDominant: boolean
}

/** Locates up to 3 volume rows (annual/peak/lifetime) by row-label match —
 * first matching row per kind wins (top-down scan order). */
export function locateVolumeRows(
  input: HeaderParserGridInput,
  colFrom: number,
  colTo: number,
  rowFrom: number,
  rowTo: number,
): Partial<Record<VolumeKind, VolumeRowMatch>> {
  const result: Partial<Record<VolumeKind, VolumeRowMatch>> = {}
  for (let r = rowFrom; r <= rowTo; r++) {
    const label = rowLabel(input.grid, r, colFrom)
    if (label === null) continue
    const kind = VOLUME_LABEL_SYNONYMS[normalizeLabel(label)]
    if (kind === undefined || result[kind] !== undefined) continue
    const values = new Map<number, number>()
    for (let c = colFrom; c <= colTo; c++) {
      const n = parseLocaleNumber(cellAt(input.grid, r, c))
      if (n !== null) values.set(c, n)
    }
    result[kind] = { row: r, values }
  }
  return result
}

/** Locates every dimension row in the scan window (excluding rows already
 * claimed by the slot-index row or a volume row) — either a KNOWN synonym
 * label, or (Master-Prompt §8 "must remain extensible") any row with at
 * least MIN_DIMENSION_ROW_POPULATION non-empty TEXT cells, keyed by a
 * slugified version of its own label (or `row<N>` when unlabeled). */
function locateDimensionRows(
  input: HeaderParserGridInput,
  colFrom: number,
  colTo: number,
  rowFrom: number,
  rowTo: number,
  claimedRows: ReadonlySet<number>,
): DimensionRowMatch[] {
  const rows: DimensionRowMatch[] = []
  for (let r = rowFrom; r <= rowTo; r++) {
    if (claimedRows.has(r)) continue
    const label = rowLabel(input.grid, r, colFrom)
    const values = new Map<number, string>()
    for (let c = colFrom; c <= colTo; c++) {
      const v = textValue(cellAt(input.grid, r, c))
      if (v !== null) values.set(c, v)
    }
    if (values.size === 0) continue
    const knownKey = label !== null ? DIMENSION_LABEL_SYNONYMS[normalizeLabel(label)] : undefined
    let ambiguousDominant = false
    if (knownKey === undefined) {
      if (values.size < MIN_DIMENSION_ROW_POPULATION) continue
      // Real-file guard (10-analyse-mx.md D): a workbook TITLE/BANNER cell
      // merged across most of the variant column band (M-QAF version
      // marker, "© BMW AG" template footer, ...) propagates the SAME text // allow-customer-string
      // to every member column after merge-resolution (resolveCell) and
      // would otherwise look exactly like a real per-column dimension row.
      // A genuine (if unrecognized/extensible) dimension row varies its
      // value BY COLUMN; a banner does not — but a SKEWED-but-real
      // dimension (e.g. 8 variants share one value of an unrecognized
      // dimension, 2 carry another) also varies by column and must NOT be
      // silently dropped wholesale (KAR-930 review F1: dropping the whole
      // row erases that dimension for EVERY variant, which can collide two
      // genuinely different variants onto the same compositeCanonicalKey —
      // strictly worse than keeping a noisier key string). This guard is
      // therefore now purely about BANNER SHAPE, never about an unequal
      // value split on its own:
      //   - a single value spanning the ENTIRE row (distinct===1) is an
      //     unambiguous merge-propagated banner — always dropped;
      //   - a skewed-but-multi-valued row is dropped ONLY when it also
      //     lacks a row-label (banners have no per-row semantic label to
      //     their left) OR its dominant value is itself banner-shaped
      //     (long / a title-version-footer pattern) — see
      //     looksLikeBannerValue;
      //   - a labeled, non-banner-shaped skewed row is KEPT (a genuine
      //     extensible dimension), but flagged `ambiguousDominant` so
      //     assembleVariant can apply a confidence penalty rather than
      //     asserting undue certainty (fail-closed for IDENTITY means
      //     "prefer one noise dimension too many" here, not "silently drop
      //     one real dimension");
      //   - a row with NO row-label at all whose every present value is
      //     itself banner-shaped is dropped even without a dominant value
      //     — the real-file counterpart is a title/footer banner MERGED IN
      //     TWO DIFFERENT-WORDED RANGES (e.g. "M-QAF Version 2.0" / // allow-customer-string
      //     "DRAFT" split ~60/40), which would otherwise defeat both
      //     checks above at once (2 distinct values, no single dominant
      //     one) — KAR-930 review F5.
      const normalizedValues = [...values.values()].map((v) => normalizeLabel(v))
      const distinctValues = new Set(normalizedValues)
      if (distinctValues.size < MIN_DIMENSION_ROW_DISTINCT_VALUES) continue // true single-value banner.

      const rawValues = [...values.values()]
      if (label === null && rawValues.every((v) => looksLikeBannerValue(v))) continue // unlabeled multi-range banner split.

      const counts = new Map<string, number>()
      for (const v of normalizedValues) counts.set(v, (counts.get(v) ?? 0) + 1)
      const dominantCount = Math.max(...counts.values())
      if (dominantCount / normalizedValues.length >= DOMINANT_VALUE_FRACTION_LIMIT) {
        const [dominantNormalized] = [...counts.entries()].find(([, n]) => n === dominantCount)!
        const dominantRawSample = rawValues.find((v) => normalizeLabel(v) === dominantNormalized) ?? ''
        if (label === null || looksLikeBannerValue(dominantRawSample)) continue // banner-shaped: drop entirely.
        ambiguousDominant = true // labeled, real-looking value despite the skew: keep, flag for a confidence penalty.
      }
    }
    const fallbackKey = (label !== null ? slugifyLabel(label) : '') || `row${r}`
    const key = knownKey ?? fallbackKey
    rows.push({ row: r, key, label, values, ambiguousDominant })
  }
  return rows
}

// ── Body scan (classification-only signals: formulas, literal numbers, a
// standalone text label — deliberately separate from identity, see module
// header) ────────────────────────────────────────────────────────────────

interface ColumnBodySignals {
  formulas: string[]
  hasLiteralNumericContent: boolean
  label: string | null
}

function collectBodySignals(
  input: HeaderParserGridInput,
  colFrom: number,
  colTo: number,
  rowFrom: number,
  rowTo: number,
  claimedRows: ReadonlySet<number>,
): Map<number, ColumnBodySignals> {
  const byColumn = new Map<number, ColumnBodySignals>()
  const get = (c: number): ColumnBodySignals => {
    let s = byColumn.get(c)
    if (!s) {
      s = { formulas: [], hasLiteralNumericContent: false, label: null }
      byColumn.set(c, s)
    }
    return s
  }
  for (let r = rowFrom; r <= rowTo; r++) {
    if (claimedRows.has(r)) continue
    for (let c = colFrom; c <= colTo; c++) {
      const formula = formulaAt(input.formulaGrid, r, c)
      if (typeof formula === 'string' && formula !== '') {
        get(c).formulas.push(formula)
        continue
      }
      const raw = cellAt(input.grid, r, c)
      if (raw === null || raw === undefined) continue
      const n = parseLocaleNumber(raw)
      if (n !== null) {
        get(c).hasLiteralNumericContent = true
        continue
      }
      const text = textValue(raw)
      if (text !== null) {
        const s = get(c)
        if (s.label === null) s.label = text
      }
    }
  }
  return byColumn
}

// ── Variant assembly ─────────────────────────────────────────────────────

function volumeAt(row: VolumeRowMatch | undefined, col: number): number | null {
  return row?.values.get(col) ?? null
}

/** Confidence ceiling applied when a variant's identity depends on an
 * `ambiguousDominant` dimension row (KAR-930 review F1 fix) — the row is
 * KEPT (not silently dropped, see locateDimensionRows), but a variant that
 * leans on a skewed/unusual dimension split for its identity should not
 * report the same confidence a clean dimension row would earn. */
const AMBIGUOUS_DIMENSION_CONFIDENCE_CEILING = 0.5

function assembleVariant(
  sheet: string,
  col: number,
  slotIndex: number | null,
  dimensionRows: readonly DimensionRowMatch[],
  volumeRows: Partial<Record<VolumeKind, VolumeRowMatch>>,
  currency: string | null,
  /** The SAME identity verdict classifyColumn receives for this column
   * (header-parser.ts's hasIdentityByColumn map) — KAR-930 review F2 fix:
   * this used to be recomputed here via identity.ts's hasVariantIdentity
   * over (dimensions, originalLabels), which — unlike hasIdentityByColumn —
   * counts the variant-code row's value through `originalLabels`, so a
   * reserved slot pre-populated with only a placeholder code diverged from
   * the classifier's (correctly stricter) reserved_placeholder verdict for
   * the very same column. There must be exactly one identity truth per
   * column, shared by both outputs. */
  hasIdentity: boolean,
): VariantDefinition {
  const dims: Record<string, ReturnType<typeof makeDimensionValue>> = {}
  const originalLabels: string[] = []
  let originalVariantNumber: string | null = slotIndex !== null ? String(slotIndex) : null
  const sourceReferences: MultiQafCellRef[] = []
  let usedAmbiguousDimension = false

  for (const dr of dimensionRows) {
    const value = dr.values.get(col)
    if (value === undefined) continue
    const source = cellRef(sheet, dr.row, col)
    if (dr.key === VARIANT_CODE_LABEL_KEY) {
      originalVariantNumber = value
      originalLabels.push(value)
      sourceReferences.push(source)
      continue
    }
    dims[dr.key] = makeDimensionValue(value, source)
    originalLabels.push(value)
    sourceReferences.push(source)
    if (dr.ambiguousDominant) usedAmbiguousDimension = true
  }

  const annualVolume = volumeAt(volumeRows.annualVolume, col)
  const peakVolume = volumeAt(volumeRows.peakVolume, col)
  const lifetimeVolume = volumeAt(volumeRows.lifetimeVolume, col)
  if (volumeRows.annualVolume?.values.has(col)) sourceReferences.push(cellRef(sheet, volumeRows.annualVolume.row, col))
  if (volumeRows.peakVolume?.values.has(col)) sourceReferences.push(cellRef(sheet, volumeRows.peakVolume.row, col))
  if (volumeRows.lifetimeVolume?.values.has(col)) sourceReferences.push(cellRef(sheet, volumeRows.lifetimeVolume.row, col))

  const dimensions: VariantDimensions = dims
  const compositeCanonicalKey = buildCompositeCanonicalKey(dimensions, originalVariantNumber)
  const activeState = deriveActiveState(hasIdentity, { annualVolume, peakVolume, lifetimeVolume })

  const baseConfidence = hasIdentity ? (slotIndex !== null ? 0.85 : 0.7) : 0.3
  const confidence = hasIdentity && usedAmbiguousDimension ? Math.min(baseConfidence, AMBIGUOUS_DIMENSION_CONFIDENCE_CEILING) : baseConfidence

  const columnIndex1 = col + 1
  return {
    stableInternalId: compositeCanonicalKey !== '' ? compositeCanonicalKey : `slot-${columnIndexToLetter(columnIndex1)}`,
    originalColumn: columnIndexToLetter(columnIndex1),
    originalColumnIndex: columnIndex1,
    originalVariantNumber,
    originalLabels,
    normalizedLabels: originalLabels.map((l) => normalizeLabel(l)),
    compositeCanonicalKey,
    dimensions,
    annualVolume,
    peakVolume,
    lifetimeVolume,
    currency,
    activeState,
    sourceReferences,
    confidence,
  }
}

// ── Orchestration ────────────────────────────────────────────────────────

export interface HeaderParserResult {
  /** Every column that resolved to a variant identity — active, inactive,
   * AND reserved (a reserved slot still gets materialized as a
   * VariantDefinition with activeState:'reserved', same "both
   * representations valid" choice synthetic-fixtures.ts's wideSlot builder
   * already made). Never includes a benchmark/delta/comment/helper/total/
   * unknown column (Master-Prompt §10: "must not include manual workbook
   * comparison columns in the normal variant count"). */
  variants: VariantDefinition[]
  /** Full per-candidate-column classification report — includes every
   * variant column too (kind mirrors its VariantDefinition.activeState),
   * PLUS every benchmark/comparison/delta/percentage/comment/helper/total/
   * unknown column. A column with zero signal anywhere in the scan window
   * is not a "candidate" at all and gets no entry (never a manufactured
   * 'unknown' for genuinely empty columns). */
  columnClassifications: ColumnClassification[]
  /** 1-based row used as the primary slot-index row, or null when none was
   * found (10-analyse-ncar.md D's shape — pure dimension-row identification,
   * see module header). */
  slotIndexRow: number | null
  /** Every dimension row actually found, container-shaped
   * (MultiQafContainer.variantDimensions) for a caller that wants to persist
   * it directly. */
  dimensionDescriptors: VariantDimensionDescriptor[]
}

/**
 * Parse ONE sheet's variant header block. Pure — see module header for the
 * full algorithm writeup and its real-file evidence.
 */
export function parseVariantHeaderBlock(input: HeaderParserGridInput, options: HeaderParserOptions = {}): HeaderParserResult {
  const colFrom = options.colFrom ?? DEFAULT_COL_FROM
  const colTo = options.colTo ?? DEFAULT_COL_TO
  const rowFrom = options.rowFrom ?? DEFAULT_ROW_FROM
  const rowTo = options.rowTo ?? DEFAULT_ROW_TO

  const volumeRows = locateVolumeRows(input, colFrom, colTo, rowFrom, rowTo)
  const claimedByVolume = new Set(
    Object.values(volumeRows)
      .map((v) => v?.row)
      .filter((r): r is number => r !== undefined),
  )

  const slotIndexResult = locateSlotIndexRow(input, colFrom, colTo, rowFrom, rowTo, claimedByVolume)

  const claimedRows = new Set(claimedByVolume)
  if (slotIndexResult) claimedRows.add(slotIndexResult.row)

  const dimensionRows = locateDimensionRows(input, colFrom, colTo, rowFrom, rowTo, claimedRows)
  for (const dr of dimensionRows) claimedRows.add(dr.row)

  const bodySignals = collectBodySignals(input, colFrom, colTo, rowFrom, rowTo, claimedRows)

  // ── Candidate column set: any column with ANY signal at all. ───────────
  const candidateColumns = new Set<number>()
  if (slotIndexResult) {
    for (const c of slotIndexResult.primary.keys()) candidateColumns.add(c)
    for (const c of slotIndexResult.outliers.keys()) candidateColumns.add(c)
  }
  for (const dr of dimensionRows) for (const c of dr.values.keys()) candidateColumns.add(c)
  for (const vr of Object.values(volumeRows)) if (vr) for (const c of vr.values.keys()) candidateColumns.add(c)
  // KAR-930 review F3 fix: a standalone text-label signal (e.g. a genuine
  // free-text "Kommentar" annotation column with no formula and no numeric
  // literal anywhere) must also make the column a candidate — otherwise it
  // never reaches classifyColumn at all, and comment_column (which DOES
  // correctly handle a label-only fact set) becomes unreachable for its own
  // canonical case, contradicting this module's own doc further down
  // (only a column with ZERO signal anywhere gets no entry at all).
  for (const [c, body] of bodySignals) if (body.formulas.length > 0 || body.hasLiteralNumericContent || body.label !== null) candidateColumns.add(c)

  const sortedColumns = [...candidateColumns].sort((a, b) => a - b)

  // ── Per-column identity flag, computed ONCE and reused for both variant
  // assembly and classification facts — deliberately independent of
  // "does a VariantDefinition exist for this column" (a reserved slot DOES
  // get a VariantDefinition, materialized with activeState:'reserved', but
  // must still report hasIdentity:false to the classifier).
  //
  // Deliberately EXCLUDES the variant-code row (VARIANT_CODE_LABEL_KEY) on
  // its own — real-file proof (10-analyse-mx.md D): that file's own
  // per-column "BMW-Variant-Label"-style row carries a human-readable // allow-customer-string
  // descriptive value for the benchmark/comparison columns TOO, not just
  // for genuine slots — a variant-code-row hit alone would
  // therefore wrongly grant identity to exactly the two non-variant columns
  // Master-Prompt §10 names as "the most important regression case". A
  // genuine variant's identity must come from at least one OTHER dimension
  // row (Fahrzeug/Antrieb/Motor/...); the variant-code row only ever
  // supplements an identity that already exists (see assembleVariant, which
  // still reads it for originalVariantNumber/originalLabels once a column
  // qualifies through this check).
  //
  // A column that is part of the primary slot-index run needs only ONE such
  // dimension-row hit — its slot-index membership is already strong,
  // independent structural evidence (10-analyse-ncar.md D: that file's 8
  // variants each carry a full 9-row dimension block anyway). A column
  // OUTSIDE the primary run
  // (an index-outlier, or never indexed at all) needs at least
  // MIN_NONINDEXED_KNOWN_DIMENSION_MATCHES known-label (non-fallback,
  // non-variant-code) hits — real-file proof (10-analyse-mx.md D): the
  // benchmark outlier column AB coincidentally carries a single stray value
  // in what is otherwise a genuine dimension row (real templates are not
  // perfectly clean outside their intended data range), but never
  // accumulates multiple independent dimension-category matches the way a
  // genuine orphaned-but-real variant does (10-analyse-clarwe-eu.md C.3's
  // AN/AO: Fahrzeug AND Antrieb AND LL/RL AND volume, all independently
  // confirming the same column) — requiring >=2 known matches for a
  // non-indexed column keeps that real distinction intact without needing
  // to guess at the exact source of a single coincidental hit. Fallback
  // (unlabeled/extensible) dimension rows never count toward this
  // threshold on their own — they only ever supplement an identity a known
  // label already established (this is what keeps custom/extensible
  // dimensions like a "Shape" row usable for genuine variants without
  // reopening this exact false-positive path for non-indexed columns). ──
  const MIN_NONINDEXED_KNOWN_DIMENSION_MATCHES = 2
  const isKnownLabelRow = (dr: DimensionRowMatch): boolean =>
    dr.key !== VARIANT_CODE_LABEL_KEY && dr.label !== null && DIMENSION_LABEL_SYNONYMS[normalizeLabel(dr.label)] === dr.key

  const hasIdentityByColumn = new Map<number, boolean>()
  for (const c of sortedColumns) {
    const isPrimaryIndexed = slotIndexResult?.primary.has(c) ?? false
    if (isPrimaryIndexed) {
      hasIdentityByColumn.set(
        c,
        dimensionRows.some((dr) => dr.key !== VARIANT_CODE_LABEL_KEY && dr.values.has(c)),
      )
    } else {
      const knownMatches = dimensionRows.filter((dr) => isKnownLabelRow(dr) && dr.values.has(c)).length
      hasIdentityByColumn.set(c, knownMatches >= MIN_NONINDEXED_KNOWN_DIMENSION_MATCHES)
    }
  }
  const hasPositiveVolumeByColumn = new Map<number, boolean>()
  for (const c of sortedColumns) {
    const positive = [volumeAt(volumeRows.annualVolume, c), volumeAt(volumeRows.peakVolume, c), volumeAt(volumeRows.lifetimeVolume, c)].some(
      (v) => v !== null && v > 0,
    )
    hasPositiveVolumeByColumn.set(c, positive)
  }

  // ── Pass 0: build a VariantDefinition for every column that is EITHER
  // part of the primary slot-index run (materializes even a fully-empty
  // reserved slot) OR carries identity via a dimension row (the
  // orphaned-but-real / no-index-row case). ──────────────────────────────
  const variants: VariantDefinition[] = []
  const variantByColumn = new Map<number, VariantDefinition>()
  for (const c of sortedColumns) {
    const slotIndex = slotIndexResult?.primary.get(c) ?? null
    const hasIdentity = hasIdentityByColumn.get(c) ?? false
    if (slotIndex === null && !hasIdentity) continue // benchmark/delta/comment/helper/total/unknown candidate — no VariantDefinition.
    const variant = assembleVariant(input.sheet, c, slotIndex, dimensionRows, volumeRows, null, hasIdentity)
    variants.push(variant)
    variantByColumn.set(c, variant)
  }

  // ── Facts + Pass 1 classification (own-signal only). ────────────────────
  const factsByColumn = new Map<number, ColumnClassificationFacts>()
  for (const c of sortedColumns) {
    const slotIndex = slotIndexResult?.primary.get(c) ?? null
    const isIndexOutlier = slotIndexResult?.outliers.has(c) ?? false
    const body = bodySignals.get(c)
    factsByColumn.set(c, {
      column: columnIndexToLetter(c + 1),
      columnIndex: c + 1,
      slotIndex,
      isIndexOutlier,
      hasIdentity: hasIdentityByColumn.get(c) ?? false,
      hasPositiveVolume: hasPositiveVolumeByColumn.get(c) ?? false,
      label: body?.label ?? null,
      formulas: body?.formulas ?? [],
      hasLiteralNumericContent: body?.hasLiteralNumericContent ?? false,
    })
  }

  const emptyContext: ColumnClassificationContext = { resolvedKinds: new Map(), variantIdByColumn: new Map() }
  const variantIdByLetter = new Map<string, string>()
  for (const [c, v] of variantByColumn) variantIdByLetter.set(columnIndexToLetter(c + 1), v.stableInternalId)

  const pass1 = new Map<number, ColumnClassification>()
  for (const c of sortedColumns) {
    pass1.set(c, classifyColumn(factsByColumn.get(c)!, emptyContext))
  }

  const resolvedKinds = new Map<string, ColumnClassification['kind']>()
  for (const [c, result] of pass1) resolvedKinds.set(columnIndexToLetter(c + 1), result.kind)

  // ── Pass 2: re-resolve delta/percentage columns with cross-column
  // knowledge (see column-classifier.ts classifyColumn two-pass doc). ─────
  const context: ColumnClassificationContext = { resolvedKinds, variantIdByColumn: variantIdByLetter }
  const columnClassifications: ColumnClassification[] = sortedColumns.map((c) => {
    const pass1Result = pass1.get(c)!
    if (pass1Result.kind !== 'delta_column' && pass1Result.kind !== 'percentage_delta_column') return pass1Result
    return classifyColumn(factsByColumn.get(c)!, context)
  })

  const dimensionDescriptors: VariantDimensionDescriptor[] = dimensionRows
    .filter((dr) => dr.key !== VARIANT_CODE_LABEL_KEY)
    .map((dr) => ({ key: dr.key, headerRow: dr.row, label: dr.label, sheet: input.sheet }))

  return { variants, columnClassifications, slotIndexRow: slotIndexResult?.row ?? null, dimensionDescriptors }
}

// ── ExcelJS bridge ───────────────────────────────────────────────────────

/** Convert a worksheet to a HeaderParserGridInput — a thin wrapper around
 * workbook-adapter.ts's own worksheetToGrid/worksheetToFormulaGrid (never
 * reimplemented here) so merged-cell propagation always goes through the
 * single already-reviewed resolveCell/cell.master code path (KAR-928), and
 * formula-text extraction goes through the single already-reviewed
 * cell.formula/shared-formula-slave code path (KAR-900). */
export function headerParserInputFromWorksheet(ws: Worksheet): HeaderParserGridInput {
  return { sheet: ws.name, grid: worksheetToGrid(ws), formulaGrid: worksheetToFormulaGrid(ws) }
}
