// SBM-DEVICES-FWZ sheet row parser (KAR-898 / P1.7): standalone parser for
// the QAF SBM-DEVICES-FWZ detail sheet (Sonderbetriebsmittel / Vorrichtungen /
// Folgewerkzeuge, Leitfaden 02-leitfaden-teil2.md [34]-[38]). Built 1:1 from
// the material-parser.ts pattern (KAR-897/P1.6): dynamic import of the
// canonical registry, label-anchor + confidence matching, a controlled
// degradation path on an unusable header (never throws), sourceCells/
// normalized/rawText provenance from day one — see that module's header for
// the tier-1/tier-2 matching rationale, reused verbatim here.
//
// ── Scope discipline (backlog 05-backlog-phasenplan.md [P1.7]) ─────────────
//   - Parses + persists the row shape only. The Betriebsmittelkategorie
//     lookup below (classifySbmToolDeviceType) is REFERENCE DATA attached per
//     row, not a blocking validation — "ist diese Werkzeugart korrekt SBM vs.
//     Vorrichtung klassifiziert" full enforcement is an explicit Phase-2
//     follow-up per the backlog item ("vollstaendige Validierung ... ist ein
//     Business-Rule-Folgeitem fuer Phase 2 falls Kapazitaet").
//   - Positionsnummer <-> MANUFACTURING COSTS matching is explicitly NOT
//     built here (Leitfaden [33]: "Positionsnummern ... sind NICHT
//     zwangslaeufig 1:1 zeilenidentisch, sondern muessen ... manuell/logisch
//     zugeordnet werden", Abbildung 23). This module only captures
//     positionNumber as a plain data field (a future matching-dimension) —
//     no automatic row-index or position-based join to QAFRow is performed,
//     mirroring material-parser.ts's explicit "cross-file matching is out of
//     scope" boundary for its own Positionsnummer field.
//   - Cross-file matching/diffing of SBM rows (ALT vs NEU) is explicitly OUT
//     of scope for this PR, same boundary material-parser.ts documents for
//     MATERIAL rows (see that module's header, "Cross-file matching/diffing
//     ... is explicitly OUT of scope for this PR").
//
// ── Bedingte Pflichtfeld-Logik (Leitfaden [34]) ─────────────────────────────
// Verrechnungsform is the only field the Leitfaden marks unconditionally
// Pflicht (besides Positionsnummer); Werkzeug-/Vorrichtungsart only becomes
// Pflicht once Verrechnungsform is set ("Vorab leer ... solange nicht
// ausgefuellt wird es ... ROT dargestellt"). The Excel-side red-cell-fill
// enforcement itself is NOT reproduced here (Master-Prompt/backlog risk note:
// "Formatierungssemantik muesste zusaetzlich ausgelesen werden fuer volle
// Treue" — cell-fill colour is not read by this parser, only values).
//
// ── Client-bundle discipline (KAR-893 lesson, "Bundle-Lehre aus #272") ─────
// Exported through the qaf-differences barrel (index.ts), which several
// 'use client' components already import from — canonical-model.ts/
// canonical-fields.ts must stay OUT of the static import graph, exactly like
// material-parser.ts's loadMaterialRegistry(). loadSbmRegistry() is the same
// pattern: a dynamic import, cached after the first call. Only TYPE imports
// (erased at compile time) are static below.

import type { Worksheet } from 'exceljs'
import type { CanonicalField } from './canonical-fields.types'
import { worksheetToGrid } from './workbook-adapter'
import { normalizeProcessName } from './normalizer'
import { matchesModuleSheetName } from './module-sheet-names'
import type { ComparisonSide } from './rule-engine'
import type { PlausibilityIssue, PlausibilitySeverity } from './plausibility'
import type { FacetDegradation } from './types'
import { MIN_SIGNAL_MAPPED_COLUMNS } from './types'
import {
  normalizeIgnoredCandidateSheets,
  worksheetHasHeaderLabelInRegion,
  CANDIDATE_SCAN_MAX_COLS,
  type IgnoredCandidateSheetEntry,
  type IgnoredCandidateSheetsMeta,
} from './candidate-sheet-plausibility'

// ── Row shape ────────────────────────────────────────────────────────────

/**
 * The 35 SBM fields the Leitfaden documents completely with an explicit
 * Pflicht/optional/bedingt column (S.34-37, canonical-fields.ts SBM_FIELDS) —
 * camelCase mirror of the canonical ids' `sbm_*` suffix, same relationship as
 * material-parser.ts's MaterialFieldKey to MATERIAL_FIELDS.
 */
export interface SbmRowValues {
  positionNumber: string
  verrechnungsform: string
  toolFixtureType: string
  componentDesignation: string
  cavityConfiguration: string
  stageCount: number | null
  /** "Anzahl Stufen/Takte: Aktiv" / "Number of Stages: Active" (KAR-910/
   * fehlerreport-analyse.md §5 Fall #74) — the Leitfaden's own screenshot
   * ([38]) shows this as a distinct sub-column under the combined
   * "Anzahl Stufen/Takte/Komponenten (Aktiv/Leer)" header group. Separate
   * from `stageCount` above (the pre-existing combined field), not a
   * replacement. */
  stageCountActive: number | null
  /** "Anzahl Stufen/Takte: Leer" / "Number of Stages: Empty" — sibling of
   * stageCountActive above, same evidence (Fall #75). */
  stageCountEmpty: number | null
  serviceLifeCycles: number | null
  toolDeploymentDate: string
  orderLeadTimeWeeks: number | null
  manufacturerLocation: string
  procurementCurrency: string
  toolFixtureCostBw: number | null
  bmwPartNumber: string
  componentIndex: string
  length: number | null
  width: number | null
  height: number | null
  sheetThickness: number | null
  componentSurface: string
  toolDimensions: string
  toolWeight: number | null
  toolConcept: string
  connectionConcept: string
  slidersGeneral: number | null
  slidersHydraulic: number | null
  slidersAngled: number | null
  slidersJaw: number | null
  slidersTotal: number | null
  remarks: string
  quotationCurrency: string
  exchangeRate: number | null
  toolFixtureCount: number | null
  /** "Summe Werkzeug-/Vorrichtungskosten [AW]" — the unconditional row-level
   * AW cost total (Leitfaden [37]: berechnet, no Verrechnungsform gate,
   * unlike totalSbmToolCostAw below). This is the field
   * reconciliation.ts's sbm_detail_sum check sums against summary.
   * devicesAndTools. */
  totalToolFixtureCostAw: number | null
  /** "Summe Werkzeugkosten SBM [AW]" — bedingt, only filled when
   * Verrechnungsform = SBM (Leitfaden [37]). Captured as a data field, not
   * used for reconciliation (that would double-count against
   * totalToolFixtureCostAw for the same row). */
  totalSbmToolCostAw: number | null
  devicesFollowupMaintenancePerUnit: number | null
}

export type SbmFieldKey = keyof SbmRowValues

/**
 * A parsed SBM-DEVICES-FWZ row. sourceCells/normalized/rawText are present
 * from day one (same "kein Nachruesten" instruction material-parser.ts
 * follows) — every row carries per-field cell provenance for whichever
 * columns were actually located in the header.
 *
 * deviceClassification is NOT one of the 35 canonical SBM fields — it is a
 * derived enrichment from the Betriebsmittelkategorie reference table
 * (SBM_DEVICE_CATEGORY_TABLE below), attached per row so downstream code
 * (UI, a future Phase-2 validation rule) does not have to re-run the lookup.
 * Null when toolFixtureType itself is blank — a valid intermediate state
 * (Verrechnungsform chosen, Werkzeugart not yet filled, Leitfaden [34]), not
 * something to flag for review.
 */
export type SbmRow = SbmRowValues & {
  sourceCells: Partial<Record<SbmFieldKey, string>>
  normalized: Partial<Record<SbmFieldKey, string | number | null>>
  rawText: Partial<Record<SbmFieldKey, string>>
  deviceClassification: SbmDeviceClassificationResult | null
}

const TEXT_FIELDS: Set<SbmFieldKey> = new Set([
  'positionNumber',
  'verrechnungsform',
  'toolFixtureType',
  'componentDesignation',
  'cavityConfiguration',
  'toolDeploymentDate',
  'manufacturerLocation',
  'procurementCurrency',
  'bmwPartNumber',
  'componentIndex',
  'componentSurface',
  'toolDimensions',
  'toolConcept',
  'connectionConcept',
  'remarks',
  'quotationCurrency',
])

/** Every SbmFieldKey — used to build the default-filled row shape. */
const ALL_FIELD_KEYS: readonly SbmFieldKey[] = [
  'positionNumber',
  'verrechnungsform',
  'toolFixtureType',
  'componentDesignation',
  'cavityConfiguration',
  'stageCount',
  'stageCountActive',
  'stageCountEmpty',
  'serviceLifeCycles',
  'toolDeploymentDate',
  'orderLeadTimeWeeks',
  'manufacturerLocation',
  'procurementCurrency',
  'toolFixtureCostBw',
  'bmwPartNumber',
  'componentIndex',
  'length',
  'width',
  'height',
  'sheetThickness',
  'componentSurface',
  'toolDimensions',
  'toolWeight',
  'toolConcept',
  'connectionConcept',
  'slidersGeneral',
  'slidersHydraulic',
  'slidersAngled',
  'slidersJaw',
  'slidersTotal',
  'remarks',
  'quotationCurrency',
  'exchangeRate',
  'toolFixtureCount',
  'totalToolFixtureCostAw',
  'totalSbmToolCostAw',
  'devicesFollowupMaintenancePerUnit',
]

/**
 * The 3 header-level fields that MUST be located for an SBM header row to be
 * usable at all — Positionsnummer (row identity), Werkzeug-/Vorrichtungsart
 * (the field the task instruction names explicitly, and the Betriebsmittel-
 * kategorie classification's join key) and Summe Werkzeug-/Vorrichtungskosten
 * [AW] (the per-row AW cost total reconciliation.ts needs to sum against
 * summary.devicesAndTools). Mirrors material-parser.ts's
 * CORE_MATERIAL_FIELD_KEYS 3-field pattern.
 *
 * Deliberately does NOT throw when missing (unlike qaf-parser.ts's
 * parseQAFTemplate): SBM-DEVICES-FWZ is an OPTIONAL, additive sheet — a
 * malformed SBM sheet must not fail the whole file ingest. See
 * parseSbmWorksheet below: below this minimum, coreFieldsFound is false and
 * rows stays empty.
 */
export const CORE_SBM_FIELD_KEYS: readonly SbmFieldKey[] = ['positionNumber', 'toolFixtureType', 'totalToolFixtureCostAw']

// KAR-958/P2 (gate-audit.md B6, coreFieldsFound-Resilienz) — the shared
// MIN_SIGNAL_MAPPED_COLUMNS floor from types.ts (PR #325 review fix #5, was
// a duplicated local literal here); below it, a degraded SBM header is still
// treated as a genuinely empty/foreign sheet.

const HEADER_MATCH_MIN = 5
const HEADER_SCAN_MAX_ROWS = 20

/**
 * So viele aufeinanderfolgende Leerzeilen überspringt der Zeilenlauf, bevor er
 * die Tabelle für beendet hält.
 *
 * Zwei, weil diese Formblätter einzelne Trennzeilen zwischen Baugruppen setzen
 * und unter dem Kopf eine Abstandszeile führen — beides mitten in der Tabelle.
 * Grosszügiger gewählt würde der Fussbereich mitgelesen.
 */
const MAX_BLANK_ROW_RUN = 2

/** So viele gleiche Zellen gelten als Wiederholung der Kopfzeile. */
const HEADER_ECHO_MIN_HITS = 3

/**
 * Beschriftungen, mit denen der Fussbereich unter der Tabelle beginnt.
 *
 * Sie stehen in der Identitätsspalte und nicht bei der Bauteilbezeichnung —
 * daran unterscheidet sich der Blattfuss von den Zwischensummenzeilen
 * innerhalb der Tabelle, die eine Bezeichnung tragen („TOTAL Baugruppe").
 * Ohne diesen Halt liest der Zeilenlauf Vorlagenhinweise und die
 * Gesamtsummenzeile als Positionen mit.
 */
const FOOTER_LABEL = /^\s*(summen?|gesamtsumme|total|gesamt|zusätzliche zeile|zusaetzliche zeile)\b/i

/**
 * Wiederholt diese Zeile die Kopfzeile?
 *
 * Verbundene Kopfzellen liefern beim Auslesen in jeder überspannten Zeile
 * denselben Text. Ohne diese Prüfung stünde die Kopfzeile als Position im
 * Ergebnis — mit einer Bauteilbezeichnung namens „Bauteilbezeichnung".
 */
function isHeaderEcho(
  row: unknown[],
  colMap: Map<number, SbmFieldKey>,
  ctx: Awaited<ReturnType<typeof loadSbmRegistry>>,
): boolean {
  let hits = 0
  for (const colIdx of colMap.keys()) {
    const cell = normalizeHeaderCell(row[colIdx])
    if (cell === '') continue
    if (matchHeaderColumnSync(cell, ctx) !== null && ++hits >= HEADER_ECHO_MIN_HITS) return true
  }
  return false
}

// ── Sheet detection ─────────────────────────────────────────────────────────

/**
 * True when a worksheet name identifies the SBM-DEVICES-FWZ detail sheet.
 * The Leitfaden's own screenshot (Abbildung 24, [38]) shows the tab literally
 * named "SBM-DEVICES-FWZ". No other QAF module name (SUMMARY, MANUFACTURING,
 * MATERIAL, LOGISTICS, RMR, LC_CN, CO2E, WAF, LAF, LEK, G60 —
 * canonical-fields.types.ts QafModule) contains the substring "sbm", so a
 * simple substring match is safe, mirroring isMaterialSheetName's simplicity.
 * Sheet-name alias source centralized in module-sheet-names.ts (KAR-905/P3.1).
 */
export function isSbmSheetName(name: string): boolean {
  return matchesModuleSheetName(name, 'SBM')
}

/** First worksheet whose name matches isSbmSheetName, or null when the
 * workbook has none (the additive gate). */
export function findSbmWorksheet(wb: { worksheets: Worksheet[] }): Worksheet | null {
  return wb.worksheets.find((w) => isSbmSheetName(w.name)) ?? null
}

// ── Canonical registry bridge (dynamic import — see module header) ─────────

interface ColumnMatch {
  key: SbmFieldKey
  /** 1.0 exact, 0.9 normalized-only — see material-parser.ts module header
   * for the two-tier rationale, reused verbatim here. */
  confidence: number
}

interface SbmRegistryCtx {
  /** SBM-only slice of the canonical registry — scoping is required, not
   * cosmetic: "Positionsnummer Fertigungsschritt" is shared verbatim with
   * MANUFACTURING and MATERIAL — matching against the full registry would
   * make every SBM header ambiguous, same reasoning as material-parser.ts. */
  registry: readonly CanonicalField[]
  idToKey: Record<string, SbmFieldKey>
  findByAliasFn: (
    label: string,
    lang: 'de' | 'en' | undefined,
    registry: readonly CanonicalField[],
  ) => CanonicalField[]
}

let sbmRegistryPromise: Promise<SbmRegistryCtx> | null = null

async function loadSbmRegistry(): Promise<SbmRegistryCtx> {
  if (!sbmRegistryPromise) {
    sbmRegistryPromise = (async () => {
      const [{ byModule, findByAlias }, { SBM_FIELD_KEY_TO_CANONICAL }] = await Promise.all([
        import('./canonical-model'),
        import('./canonical-fields'),
      ])
      const registry = byModule('SBM')
      const idToKey = Object.fromEntries(
        Object.entries(SBM_FIELD_KEY_TO_CANONICAL).map(([key, canonicalId]) => [canonicalId, key as SbmFieldKey]),
      ) as Record<string, SbmFieldKey>

      return { registry, idToKey, findByAliasFn: findByAlias }
    })()
  }
  return sbmRegistryPromise
}

function matchHeaderColumnSync(headerCell: string, ctx: SbmRegistryCtx): ColumnMatch | null {
  if (headerCell === '') return null

  for (const field of ctx.registry) {
    if (headerCell === field.labelDe || headerCell === field.labelEn) {
      const key = ctx.idToKey[field.id]
      if (key) return { key, confidence: 1 }
    }
  }

  const hits = ctx.findByAliasFn(headerCell, undefined, ctx.registry)
  const distinctKeys = new Set(
    hits.map((f) => ctx.idToKey[f.id]).filter((k): k is SbmFieldKey => k !== undefined),
  )
  if (distinctKeys.size === 1) {
    const [key] = distinctKeys
    return { key, confidence: 0.9 }
  }

  return null
}

/** Match one already-whitespace-normalized header string against the SBM
 * canonical field registry. Exported for the same reason material-parser.ts
 * exports matchMaterialHeaderColumn: single-cell callers (tests, a future UI
 * preview) that don't want to manage the registry cache themselves. */
export async function matchSbmHeaderColumn(headerCell: string): Promise<ColumnMatch | null> {
  const ctx = await loadSbmRegistry()
  return matchHeaderColumnSync(headerCell, ctx)
}

function normalizeHeaderCell(v: unknown): string {
  return String(v ?? '')
    .replace(/[\r\n]+/g, ' ')
    .replace(/\s+/g, ' ')
    .trim()
}

function colLetter(col0: number): string {
  let n = col0 + 1
  let out = ''
  while (n > 0) {
    const rem = (n - 1) % 26
    out = String.fromCharCode(65 + rem) + out
    n = Math.floor((n - 1) / 26)
  }
  return out
}

function toNum(v: unknown): number | null {
  if (v === null || v === undefined || v === '') return null
  // `Number(new Date(...))` liefert den Millisekunden-Epoch, nicht NaN — eine
  // Zahlenspalte mit datums-/zeitartigem Zellformat käme sonst als
  // Milliardenwert in der Kalkulation an, ohne Fehler und ohne Warnung. Seit
  // der BIFF-Lesepfad `cellDates: true` setzt, liefern auch .xls-Dateien echte
  // Date-Objekte; 964 von 1076 Realdateien tragen Datumszellen, ein Großteil
  // davon in genau den Blättern, die dieser Parser liest.
  if (v instanceof Date) return null
  const n = Number(v)
  return isNaN(n) ? null : n
}

/** Best matching row for the SBM header, scanning the first
 * HEADER_SCAN_MAX_ROWS rows exactly like material-parser.ts's
 * findMaterialHeaderRow. */
export async function findSbmHeaderRow(grid: unknown[][]): Promise<number | null> {
  if (!grid || grid.length === 0) return null
  const ctx = await loadSbmRegistry()

  let bestIdx: number | null = null
  let bestScore = 0
  const scanLimit = Math.min(grid.length, HEADER_SCAN_MAX_ROWS)
  for (let i = 0; i < scanLimit; i++) {
    const row = grid[i] ?? []
    let score = 0
    for (const cell of row) {
      if (matchHeaderColumnSync(normalizeHeaderCell(cell), ctx)) score += 1
    }
    if (score > bestScore) {
      bestScore = score
      bestIdx = i
    }
  }
  return bestScore >= HEADER_MATCH_MIN ? bestIdx : null
}

// ── Parse-level diagnostics ─────────────────────────────────────────────────

export interface SbmParseMeta {
  /** Arithmetic mean of per-column confidence over mapped columns — 1.0 for
   * an intact sheet, lower once normalized-only matches enter the mix. */
  parseConfidence: number
  /** Header cell text of every non-empty header-row cell that matched no SBM
   * canonical field. */
  unmappedHeaders: string[]
  /** Number of header columns mapped to an SbmFieldKey. */
  mappedFieldCount: number
  /** True once every CORE_SBM_FIELD_KEYS was located. False means the header
   * is missing at least one core field — since KAR-958/P2, rows is NOT
   * unconditionally empty in that case anymore (see
   * MIN_SIGNAL_MAPPED_COLUMNS / `degradation` below). */
  coreFieldsFound: boolean
  /** KAR-958/P2 — set only when `coreFieldsFound` is false AND at least
   * MIN_SIGNAL_MAPPED_COLUMNS columns mapped (partial extraction attempted).
   * `undefined` on every intact parse and on a genuinely empty/foreign
   * sheet. */
  degradation?: FacetDegradation
  /**
   * KAR-927 (Multi-QAF-Programm P0.2, "Kandidaten-Sichtbarkeit an .find()-
   * Kollaps-Stellen") — an entry for EVERY OTHER worksheet that also matched
   * `isSbmSheetName` but was NOT chosen (findSbmWorksheet always picks the
   * FIRST match, exactly as before — this only reports what else was there,
   * selection itself is unchanged). `undefined` (key omitted, never `[]`)
   * whenever at most one SBM-named sheet matched — the overwhelming common
   * case for a standard QAF.
   *
   * Adversarial-review fix (KAR-927 F1/F2, 12.07.2026): a PRIOR version of
   * this field only listed a candidate when a full `parseSbmWorksheet` call
   * on it ALSO located every CORE_SBM_FIELD_KEYS (`coreFieldsFound`). That
   * had two bugs: (F2) `isSbmSheetName`'s alias list has a broad "sbm"
   * substring fallback, and every real BMW template bundles non-data // allow-customer-string
   * "SBM_Matrix"/"SBM_Dropdown" reference tabs that also match it // allow-customer-string
   * (real-corpus finding, 12.07.2026) — firing a FULL worksheet parse
   * (worksheetToGrid + header mapping + data-row extraction) on both of them
   * on nearly every real ingest, purely to read one throwaway boolean; (F1)
   * the all-or-nothing gate swallowed exactly the genuinely-broken second
   * sheets this field exists to surface — e.g. a Multi-QAF file bundling a DE
   * and an EN SBM-DEVICES-FWZ tab where the EN copy's Positionsnummer header
   * was renamed/corrupted: `coreFieldsFound` came back false, so the whole
   * candidate silently vanished again.
   *
   * Fixed: EVERY other name-matching sheet is now listed unconditionally,
   * each tagged `plausibleData` by a CHEAP, BOUNDED header-region scan (see
   * candidate-sheet-plausibility.ts module header) for the single SBM
   * identity label — totalToolFixtureCostAw/"Summe Werkzeug-/
   * Vorrichtungskosten [AW]" (NOT toolFixtureType/"Werkzeug-/
   * Vorrichtungsart": the real-corpus regression proved BMW's own SBM_Matrix // allow-customer-string
   * reference tab literally reuses that exact label as ITS OWN header row —
   * see parseSbmSheet's comment for the full writeup) — never a full parse.
   * SBM_Matrix/SBM_Dropdown still report `plausibleData: false` (a // allow-customer-string
   * classification/applicability matrix never carries a cost-total column),
   * so the ANTI-SPAM property is preserved (see sbmParseMetaToPlausibilityIssue
   * below, which only turns `plausibleData: true` entries into a user-facing
   * message) while a genuinely broken second data sheet is now visible in
   * the meta regardless of whether its Positionsnummer column survived. */
  ignoredCandidateSheets?: IgnoredCandidateSheetEntry[]
}

export type SbmParseResult = SbmRow[] & SbmParseMeta

function withParseMeta(rows: SbmRow[], meta: SbmParseMeta): SbmParseResult {
  return Object.assign(rows, meta) as SbmParseResult
}

const EMPTY_PARSE_META: SbmParseMeta = {
  parseConfidence: 0,
  unmappedHeaders: [],
  mappedFieldCount: 0,
  coreFieldsFound: false,
}

/**
 * Parse an already-located SBM-DEVICES-FWZ worksheet. Pure aside from the
 * lazy registry import. Never throws on a degraded/unusable header (see
 * CORE_SBM_FIELD_KEYS doc comment) — returns an empty, flagged result
 * instead, so a malformed SBM sheet cannot fail the whole file ingest.
 */
export async function parseSbmWorksheet(ws: Worksheet): Promise<SbmParseResult> {
  const grid = worksheetToGrid(ws)
  if (grid.length < 2) return withParseMeta([], EMPTY_PARSE_META)

  const headerIdx = await findSbmHeaderRow(grid)
  if (headerIdx === null) return withParseMeta([], EMPTY_PARSE_META)

  const headerRow = (grid[headerIdx] as unknown[]).map(normalizeHeaderCell)
  const sheetName = ws.name

  const ctx = await loadSbmRegistry()
  const colMap = new Map<number, SbmFieldKey>()
  const colConfidence = new Map<number, number>()
  const unmappedHeaders: string[] = []
  const usedKeys = new Set<SbmFieldKey>()

  for (let i = 0; i < headerRow.length; i++) {
    const cell = headerRow[i]
    if (cell === '') continue
    const match = matchHeaderColumnSync(cell, ctx)
    if (!match) {
      unmappedHeaders.push(cell)
      continue
    }
    if (usedKeys.has(match.key)) {
      // No documented SBM label duplicate (unlike MATERIAL's Mengeneinheit
      // collision) — a second column claiming an already-used key is treated
      // as unmapped rather than silently overwriting the first column.
      unmappedHeaders.push(cell)
      continue
    }

    colMap.set(i, match.key)
    colConfidence.set(i, match.confidence)
    usedKeys.add(match.key)
  }

  const mappedKeys = new Set(colMap.values())
  const coreFieldsFound = CORE_SBM_FIELD_KEYS.every((k) => mappedKeys.has(k))
  // KAR-958/P2 (gate-audit.md B6): below the minimum-signal floor, treated
  // exactly as before this PR. At/above it, a missing core field no longer
  // discards every already-mapped column — the row-push guard below still
  // independently requires positionNumber (possibly itself a missing core
  // field), so a sheet without any identifiable rows still yields `rows: []`.
  if (colMap.size < MIN_SIGNAL_MAPPED_COLUMNS) {
    return withParseMeta([], {
      parseConfidence: 0,
      unmappedHeaders,
      mappedFieldCount: colMap.size,
      coreFieldsFound: false,
    })
  }

  const mappedFieldCount = colMap.size
  const confidences = [...colConfidence.values()]
  const parseConfidence = confidences.length > 0 ? confidences.reduce((a, b) => a + b, 0) / confidences.length : 0

  const rows: SbmRow[] = []
  let blankRun = 0

  for (let i = headerIdx + 1; i < grid.length; i++) {
    const row = (grid[i] as unknown[]) ?? []

    // Eine einzelne Leerzeile beendet die Tabelle nicht. Diese Formblätter
    // setzen Trennzeilen zwischen Baugruppen, und direkt unter dem Kopf steht
    // regelmässig eine Abstandszeile. Erst ein längerer Leerlauf ist das Ende
    // der Tabelle — was danach kommt, ist Fussbereich.
    if (row.every((c) => c === '' || c === null || c === undefined)) {
      if (++blankRun > MAX_BLANK_ROW_RUN) break
      continue
    }
    blankRun = 0

    // Der Kopf kann über mehrere verbundene Zeilen laufen; die
    // Wiederholungen darunter sind keine Positionen.
    if (isHeaderEcho(row, colMap, ctx)) continue

    const r: Partial<SbmRowValues> = {}
    for (const key of ALL_FIELD_KEYS) {
      ;(r as Record<string, unknown>)[key] = TEXT_FIELDS.has(key) ? '' : null
    }

    const sourceCells: Partial<Record<SbmFieldKey, string>> = {}
    const normalized: Partial<Record<SbmFieldKey, string | number | null>> = {}
    const rawText: Partial<Record<SbmFieldKey, string>> = {}
    const sheetRowNumber = i + 1 // worksheetToGrid is 0-based, ExcelJS rows are 1-based

    for (const [colIdx, key] of colMap.entries()) {
      const val = row[colIdx]
      const normVal: string | number | null = TEXT_FIELDS.has(key) ? String(val ?? '').trim() : toNum(val)
      ;(r as Record<string, unknown>)[key] = normVal
      sourceCells[key] = `${sheetName}!${colLetter(colIdx)}${sheetRowNumber}`
      normalized[key] = normVal
      if (!TEXT_FIELDS.has(key) && normVal === null && val !== null && val !== undefined && String(val).trim() !== '') {
        rawText[key] = String(val)
      }
    }

    // Row-push guard: positionNumber is SBM's row-identity field (unlike
    // MATERIAL's materialDesignation) — Positionsnummer is the field the
    // Leitfaden documents as unconditionally Pflicht AND as the manual
    // cross-reference dimension to MANUFACTURING COSTS (Leitfaden [33]), so
    // a row without one is not a genuine SBM position.
    //
    // In der Praxis bleibt die Spalte allerdings oft leer, während die
    // Bauteilbezeichnung durchgehend gepflegt ist. Eine Zeile deswegen zu
    // verwerfen hiesse, ein vollständig ausgefülltes Werkzeugblatt als leer zu
    // melden — die Bezeichnung trägt die Identität dann genauso.
    // Der Blattfuss beginnt: alles darunter sind Summen und Vorlagenhinweise.
    if (!r.componentDesignation && FOOTER_LABEL.test(r.positionNumber ?? '')) break

    if (r.positionNumber || r.componentDesignation) {
      rows.push({
        ...(r as SbmRowValues),
        sourceCells,
        normalized,
        rawText,
        deviceClassification: classifySbmToolDeviceType(r.toolFixtureType ?? ''),
      })
    }
  }

  const degradation: FacetDegradation | undefined = coreFieldsFound
    ? undefined
    : {
        facet: 'sbm',
        reason: 'PARSE_FAILED',
        sheet: sheetName,
        message: `SBM-DEVICES-FWZ-Kernfelder fehlen: ${CORE_SBM_FIELD_KEYS.filter((k) => !mappedKeys.has(k)).join(', ')}.`,
      }

  return withParseMeta(rows, {
    parseConfidence,
    unmappedHeaders,
    mappedFieldCount,
    coreFieldsFound,
    ...(degradation ? { degradation } : {}),
  })
}

/**
 * Locate + parse the SBM-DEVICES-FWZ sheet from an already-loaded workbook.
 * Returns null when the workbook has no SBM sheet at all (the additive gate —
 * distinct from "sheet present but header unusable", which returns an empty
 * SbmParseResult with coreFieldsFound:false instead).
 */
export async function parseSbmSheet(wb: { worksheets: Worksheet[] }): Promise<SbmParseResult | null> {
  const ws = findSbmWorksheet(wb)
  if (!ws) return null
  const result = await parseSbmWorksheet(ws)
  // KAR-927/P0.2: findSbmWorksheet's `.find()` only ever returns the FIRST
  // matching worksheet — this reports the OTHER worksheet name(s) that also
  // matched `isSbmSheetName` but were silently discarded. `candidates[0]` is
  // provably the same worksheet `ws` already is (both scan `wb.worksheets`
  // from index 0 forward, stopping at the first match), so selection stays
  // byte-identical — this is purely additive.
  //
  // Adversarial-review fix (KAR-927 F1/F2, 12.07.2026): EVERY other
  // name-matching sheet is listed unconditionally — no full parse, no
  // all-or-nothing `coreFieldsFound` gate (see SbmParseMeta.
  // ignoredCandidateSheets doc comment for the full incident writeup). Each
  // entry's `plausibleData` comes from a cheap, BOUNDED header-region scan
  // (candidate-sheet-plausibility.ts) for the single SBM identity label —
  // totalToolFixtureCostAw ("Summe Werkzeug-/Vorrichtungskosten [AW]", the
  // field CORE_SBM_FIELD_KEYS[2] already names as the per-row AW cost total
  // reconciliation.ts sums against summary.devicesAndTools) — reusing the
  // exact same registry-driven matchHeaderColumnSync a real header-row scan
  // uses, not a newly hardcoded label string.
  //
  // toolFixtureType ("Werkzeug-/Vorrichtungsart") was tried FIRST and
  // REJECTED by the real-corpus regression (12.07.2026,
  // qaf-candidate-visibility.real-files.test.ts): BMW's own SBM_Matrix // allow-customer-string
  // reference tab (bundled in every real template) is itself a
  // Werkzeugart-keyed applicability matrix — its own header row literally
  // reads "Werkzeug-/Vorrichtungsart" verbatim, so that label is NOT a safe
  // MATERIAL/SBM-data-vs-reference-tab discriminator, it produced a false
  // `plausibleData: true` (and thus a spam message) on every real SBM_Matrix
  // sheet. totalToolFixtureCostAw does not have this problem — a
  // classification/applicability reference matrix structurally never carries
  // a cost-total column.
  //
  // Deliberately weaker than the old `coreFieldsFound` gate: a sheet with a
  // broken/renamed Positionsnummer header but an intact cost-total column
  // still reports `plausibleData: true` (fixes F1). SBM_Matrix/SBM_Dropdown // allow-customer-string
  // reference tabs structurally never carry that column, so they still
  // report `plausibleData: false` — the anti-spam property from the
  // real-corpus finding (12.07.2026) is preserved via
  // sbmParseMetaToPlausibilityIssue below, which only turns
  // `plausibleData: true` entries into a user-facing message.
  const nameCandidates = wb.worksheets.filter((w) => isSbmSheetName(w.name))
  if (nameCandidates.length > 1) {
    const ctx = await loadSbmRegistry()
    const isCostTotalLabel = (cell: string): boolean => matchHeaderColumnSync(cell, ctx)?.key === 'totalToolFixtureCostAw'
    result.ignoredCandidateSheets = nameCandidates.slice(1).map((w) => ({
      name: w.name,
      plausibleData: worksheetHasHeaderLabelInRegion(w, HEADER_SCAN_MAX_ROWS, CANDIDATE_SCAN_MAX_COLS, isCostTotalLabel),
    }))
  }
  return result
}

// ── Parse-meta -> PlausibilityIssue bridge (KAR-927/P0.2) ──────────────────
//
// Same pattern as material-parser.ts's materialParseMetaToPlausibilityIssue
// (and plausibility.ts's manufacturingIgnoredCandidatesToPlausibilityIssue /
// rmr-parser.ts's rmrParseMetaToPlausibilityIssue) — reuses the
// qaf_plausibility_issue path (no schema change), takes the meta as a plain
// (Pick'd) object. Threaded through actions.ts (ingest -> g60_meta.sbm.
// parseMeta -> KAR-899-style rehydration) and compare.ts
// (QafFileParsed.sbmParseMeta).

/**
 * Pure. Returns null when there is nothing to REPORT — no candidates at all,
 * OR every candidate is `plausibleData: false` (adversarial-review fix,
 * KAR-927 F1/F2: SBM_Matrix/SBM_Dropdown reference tabs are still listed in
 * the meta, see SbmParseMeta.ignoredCandidateSheets, but never turn into a
 * user-facing message — this is the anti-spam property the real-corpus
 * finding required, now enforced HERE instead of by never listing them at
 * all). Accepts both the current `IgnoredCandidateSheetEntry[]` shape and a
 * pre-redesign persisted `string[]` (normalizeIgnoredCandidateSheets — a
 * legacy entry is treated as `plausibleData: true`, since it only ever
 * existed under the old strict `coreFieldsFound` gate).
 */
export function sbmParseMetaToPlausibilityIssue(
  meta: IgnoredCandidateSheetsMeta,
  side: ComparisonSide,
): PlausibilityIssue | null {
  const plausible = normalizeIgnoredCandidateSheets(meta.ignoredCandidateSheets).filter((e) => e.plausibleData)
  if (plausible.length === 0) return null
  const names = plausible.map((e) => e.name)
  return {
    type: 'parser_ignored_sbm_candidate_sheets',
    severity: 'pruefen' as PlausibilitySeverity,
    step: side,
    explanation: `Mehrere SBM-DEVICES-FWZ-Kandidaten-Sheets im Workbook gefunden (${side}) — nur das erste passende Sheet wird ausgewertet, ignoriert: ${names.join(', ')}.`,
    explanationEn: `Multiple SBM-DEVICES-FWZ candidate sheets found in the workbook (${side}) — only the first matching sheet is evaluated, ignored: ${names.join(', ')}.`,
  }
}

/**
 * Derive the tri-state `SbmRow[] | null` value callers (actions.ts's
 * ingestQafUpload, and via it QafFileParsed.sbmRows / ReconciliationInput.
 * sbmRows) must persist/thread from a `parseSbmSheet` result — the single
 * source of truth for that derivation, so it cannot drift between callers.
 *
 * (Adversarial-review finding, KAR-898 follow-up, confidence 82): a naive
 * `parsed ? [...parsed] : null` is WRONG here — `SbmParseResult` is an
 * array, and `parseSbmWorksheet` returns an EMPTY array (truthy!) for BOTH
 * of two genuinely different situations:
 *   1. the sheet exists but its header is too degraded to trust
 *      (`coreFieldsFound: false` — we have NO IDEA whether real SBM cost
 *      rows exist, we simply failed to read them), and
 *   2. the sheet exists, its header is intact, and it legitimately has zero
 *      data rows (`coreFieldsFound: true`, `rows.length === 0`).
 * Collapsing both to `[]` would make reconciliation.ts's sbm_detail_sum
 * check treat a degraded-but-possibly-populated sheet exactly like "nothing
 * to reconcile" — which, combined with that check's deliberate "both sides
 * empty -> bestanden" shortcut (see reconciliation.ts module header), could
 * report a clean `bestanden` for a file whose SBM costs we never actually
 * read. This function makes that distinction explicit and testable in
 * isolation from the DB-bound ingest action.
 */
export function sbmRowsForReconciliation(parsed: SbmParseResult | null): SbmRow[] | null {
  if (parsed === null) return null
  if (!parsed.coreFieldsFound) return null
  return [...parsed]
}

/**
 * The shape actions.ts's ingestQafUpload persists on `qaf_file.g60_meta.sbm`
 * (see that file's `sbmMeta` local) — `null` when no SBM sheet was found at
 * all, otherwise the parsed rows plus the same SbmParseMeta a live
 * SbmParseResult carries.
 */
export interface PersistedSbmMeta {
  rows: SbmRow[]
  parseMeta: SbmParseMeta
}

/**
 * Persisted-JSONB counterpart to sbmRowsForReconciliation above (KAR-899
 * reconciliation-staleness fix) — same rationale and tri-state contract as
 * material-parser.ts's materialRowsFromPersistedMeta: `undefined` when
 * `qaf_file.g60_meta.sbm` carries no key at all (pre-KAR-898/P1.7 file, or a
 * G60 file — no SBM parse was ever attempted), `null`/rows otherwise,
 * delegated to sbmRowsForReconciliation so the live-parse and rehydrated-
 * JSONB entry points cannot drift on the coreFieldsFound semantics
 * introduced in 4d158f6.
 */
export function sbmRowsFromPersistedMeta(meta: PersistedSbmMeta | null | undefined): SbmRow[] | null | undefined {
  if (meta === undefined) return undefined
  if (meta === null) return null
  return sbmRowsForReconciliation(withParseMeta([...meta.rows], meta.parseMeta))
}

// ── Verrechnungsform (Leitfaden [34]/[37]) ──────────────────────────────────

/**
 * Verrechnungsform dropdown domain (Leitfaden [34]: "Form der Verrechnung des
 * Werkzeugs/der Vorrichtung (SBM, Vorrichtung, Folgeeinsatz oder
 * Folgewerkzeug) sowie Instandhaltungskosten"; [37] names the fifth value,
 * "Werkzeuginstandhaltung", as one of the states that gates the
 * "Vorrichtungen + Folgewerkzeuge/-einsaetze + Werkzeuginstandhaltung pro
 * Stueck [AW]" direct-input field). Modeled as a literal union + a runtime
 * array (not an enum) so a real file's value can still be captured as plain
 * text without a cast when it does not exactly match one of these five (a
 * parser must degrade, not throw — see module header).
 */
export const SBM_VERRECHNUNGSFORM_VALUES = [
  'SBM',
  'Vorrichtung',
  'Folgewerkzeug',
  'Folgeeinsatz',
  'Werkzeuginstandhaltung',
] as const

export type SbmVerrechnungsform = (typeof SBM_VERRECHNUNGSFORM_VALUES)[number]

// ── Betriebsmittelkategorie — Werkzeugart-Klassifikation (Leitfaden Teil 3,
// S.85-90) ───────────────────────────────────────────────────────────────
//
// Reference table only (see module header, "Scope discipline") — attached
// per row as a lookup result, NOT a blocking validation. Transcribed
// verbatim from the Leitfaden's Betriebsmittelkategorie-Zuordnungstabelle
// (6 pages, [85]-[90]): "Zur Einordnung der Verrechnung der Werkzeug- und
// Vorrichtungskosten als SBM, Vorrichtung oder Folgewerkzeug/-einsatz dient
// ausschliesslich folgende Tabelle." Every Werkzeugart the Leitfaden lists is
// included (90 individual rows across ~30 Fertigungsprozess groups) — the
// backlog/task estimate of "~40 Eintraege" undercounts the source table;
// this constant reports the real, measured count rather than truncating to
// match that estimate (SBM_DEVICE_CATEGORY_TABLE.length, verified by the
// >= 40 test in sbm-parser.test.ts).

export type SbmDeviceClassificationTag = 'SBM' | 'Folgewerkzeug' | 'Vorrichtung'

export interface SbmDeviceCategoryEntry {
  fertigungsprozess: string
  werkzeugart: string
  classification: readonly SbmDeviceClassificationTag[]
}

const SBM_TAG: Record<'SBM' | 'FW' | 'V', SbmDeviceClassificationTag> = {
  SBM: 'SBM',
  FW: 'Folgewerkzeug',
  V: 'Vorrichtung',
}

function e(fertigungsprozess: string, werkzeugart: string, ...tags: Array<'SBM' | 'FW' | 'V'>): SbmDeviceCategoryEntry {
  return { fertigungsprozess, werkzeugart, classification: tags.map((t) => SBM_TAG[t]) }
}

/** Leitfaden Teil 3, S.85: Beflocken – Kunststoffspritzgiessen (Teil 1). */
const SBM_DEVICE_CATEGORY_TABLE_PAGE_85: readonly SbmDeviceCategoryEntry[] = [
  e('Beflocken', 'Beflockungsaufnahmen', 'V'),
  e('Beflocken', 'Mechanisierungszubehoer (Greifer, Feeder)', 'V'),
  e('Beschneiden/Lochen', 'Beschneidewerkzeuge/Lochwerkzeuge', 'SBM', 'FW'),
  e('Beschneiden/Lochen', 'Mechanisierungszubehoer', 'V'),
  e('Biegen', 'Biegewerkzeuge (z.B. Rohrbiegen)', 'SBM', 'FW'),
  e('Biegen', 'Streckbiegewerkzeuge', 'SBM', 'FW'),
  e('Biegen', 'Mechanisierungszubehoer', 'V'),
  e('Blasen', 'Blaswerkzeuge', 'SBM', 'FW'),
  e('Blasen', 'Mechanisierungszubehoer (Entbutzen, Greifer, Feeder)', 'V'),
  e('Drucken', 'Druckaufnahmen (Tampondruck)/Klischees', 'V'),
  e('Drucken', 'Siebdruckschablonen', 'V'),
  e('Foerdertechnik/Behaelter', 'Werkstuecktraeger', 'V'),
  e('Foerdertechnik/Behaelter', 'Teilefoerderer bauteilbezogen', 'V'),
  e('Foerdertechnik/Behaelter', 'Transportgestelle, -behaelter', 'V'),
]

/** Leitfaden Teil 3, S.86: Glasverarbeitung – Kunststoffspritzgiessen (Teil 2). */
const SBM_DEVICE_CATEGORY_TABLE_PAGE_86: readonly SbmDeviceCategoryEntry[] = [
  e('Glasverarbeitung', 'Brechschablone', 'V'),
  e('Glasverarbeitung', 'Diadekor/Diakopie', 'V'),
  e('Glasverarbeitung', 'Glaspressformen (Pressbiegeformen)', 'V'),
  e('Glasverarbeitung', 'Mechanisierungszubehoer', 'V'),
  e('Galvanisieren', 'Galvanikgestelle', 'V'),
  e('Galvanisieren', 'Mechanisierungszubehoer', 'V'),
  e('Innenhochdruckumformen', 'IHU-Werkzeuge', 'SBM', 'FW'),
  e('Innenhochdruckumformen', 'Mechanisierungszubehoer', 'V'),
  e('Kaschieren/Umbugen', 'Kaschier-Umbugwerkzeuge fuer Standardmaschinen', 'SBM', 'FW'),
  e('Kaschieren/Umbugen', 'Kaschier-Umbugwerkzeuge fuer Sondermaschinen', 'V'),
  e('Kaschieren/Umbugen', 'Mechanisierungszubehoer', 'V'),
  e('Kleben', 'Klebeaufnahmen', 'V'),
  e('Kleben', 'Mechanisierungszubehoer', 'V'),
  e('Kunststoffspritzgiessen', 'Spritzgießwerkzeuge', 'SBM', 'FW'),
  e('Kunststoffspritzgiessen', 'Spritzgießwerkzeuge (Mehrkomponenten)', 'SBM', 'FW'),
  e('Kunststoffspritzgiessen', 'Mechanisierungszubehoer (Greifer, Feeder)', 'V'),
]

/** Leitfaden Teil 3, S.87: Lackieren – Metallguss (Teil 3). */
const SBM_DEVICE_CATEGORY_TABLE_PAGE_87: readonly SbmDeviceCategoryEntry[] = [
  e('Lackieren', 'Lackiergestelle,-aufnahmen', 'V'),
  e('Lackieren', 'Mechanisierungszubehoer', 'V'),
  e('Leiterplatten-Umfaenge (LP)', 'In-Circuit-Tester-Adapter (LP) inkl. Software', 'V'),
  e('Leiterplatten-Umfaenge (LP)', 'Funktionspruefadapter (ZB LP) inkl. Software', 'V'),
  e('Leiterplatten-Umfaenge (LP)', 'Endpruefadapter (ZB)', 'V'),
  e('Leiterplatten-Umfaenge (LP)', 'Stanzwerkzeuge fuer LP-Nutzen', 'V'),
  e('Leiterplatten-Umfaenge (LP)', 'Filmauftragsvorrichtung', 'V'),
  e('Leiterplatten-Umfaenge (LP)', 'Siebe', 'V'),
  e('Leiterplatten-Umfaenge (LP)', 'NC-Programme aller Art', 'V'),
  e('Leiterplatten-Umfaenge (LP)', 'Maskieren', 'V'),
  e('Leiterplatten-Umfaenge (LP)', 'Loetrahmen', 'V'),
  e('Messen/Pruefen', 'Messaufnahmen/-vorrichtungen spezifisch', 'V'),
  e('Messen/Pruefen', 'Pruefaufnahmen/-vorrichtungen spezifisch', 'V'),
  e('Metallguss', 'Druckgießwerkzeug', 'SBM', 'FW'),
  e('Metallguss', 'Thixomolding', 'SBM', 'FW'),
  e('Metallguss', 'Kokillenwerkzeug (Niederdruck)', 'SBM', 'FW'),
  e('Metallguss', 'Kokillenwerkzeug (Schwerkraft)', 'SBM', 'FW'),
  e('Metallguss', 'Kernkasten', 'SBM', 'FW'),
]

/** Leitfaden Teil 3, S.88: Metallguss Forts. – Schneiden (Teil 4). */
const SBM_DEVICE_CATEGORY_TABLE_PAGE_88: readonly SbmDeviceCategoryEntry[] = [
  e('Metallguss (Forts.)', 'Feingiessen', 'SBM', 'FW'),
  e('Metallguss (Forts.)', 'Sandgiessen', 'SBM', 'FW'),
  e('Metallguss (Forts.)', 'Stanzentgratwerkzeuge', 'SBM', 'FW'),
  e('Metallguss (Forts.)', 'Mechanisierungszubehoer', 'V'),
  e('Montage', 'Montagevorrichtungen aller Art', 'V'),
  e('Polieren', 'Poliervorrichtung', 'V'),
  e('Polieren', 'Mechanisierungszubehoer', 'V'),
  e('Pressen (Textile, Kunststoffe & Leder)', 'Schnitt-/Stanzwerkzeug (nicht Metall)', 'SBM', 'FW'),
  e('Pressen (Textile, Kunststoffe & Leder)', 'Press-/Formwerkzeug (ausgenommen Metall)', 'SBM', 'FW'),
  e('Pressen (Textile, Kunststoffe & Leder)', 'Mechanisierungszubehoer', 'V'),
  e('Schaeumen (Reaktionstechnik)', 'Schaeumwerkzeuge/-form', 'SBM', 'FW'),
  e('Schaeumen (Reaktionstechnik)', 'Mechanisierungszubehoer', 'V'),
  e('Schmieden', 'Warmpressen (Schmieden)', 'SBM', 'FW'),
  e('Schmieden', 'Mechanisierungszubehoer', 'V'),
  e('Schneiden', 'Schneidaufnahmen allgemein', 'V'),
]

/** Leitfaden Teil 3, S.89: Schweissen – Stanzen/Umformen/Nachformen (Teil 5). */
const SBM_DEVICE_CATEGORY_TABLE_PAGE_89: readonly SbmDeviceCategoryEntry[] = [
  e('Schweissen (Heisselemente, Infrarot, Kunststoff, Laser, Rotation, Ultraschall)', 'Schweißwerkzeuge', 'SBM', 'FW'),
  e('Schweissen (Heisselemente, Infrarot, Kunststoff, Laser, Rotation, Ultraschall)', 'Schweißwerkzeuge fuer Sondermaschine', 'V'),
  e('Schweissen (Heisselemente, Infrarot, Kunststoff, Laser, Rotation, Ultraschall)', 'Schweißaufnahme/-gestelle', 'V'),
  e('Schweissen (Heisselemente, Infrarot, Kunststoff, Laser, Rotation, Ultraschall)', 'Spannvorrichtung', 'V'),
  e('Sintern', 'Sinterwerkzeug', 'SBM', 'FW'),
  e('Sintern', 'Mechanisierungszubehoer', 'V'),
  e('Spruehformen/Formhaeute', 'Galvanoschale/-einsatz', 'SBM', 'FW'),
  e('Stanzen/Umformen/Nachformen', 'Einzelwerkzeug', 'SBM', 'FW'),
  e('Stanzen/Umformen/Nachformen', 'Folgeverbundwerkzeuge/Prog Die', 'SBM', 'FW'),
  e('Stanzen/Umformen/Nachformen', 'Feinschneiden', 'SBM', 'FW'),
  e('Stanzen/Umformen/Nachformen', 'Platinenschnitte', 'SBM', 'FW'),
  e('Stanzen/Umformen/Nachformen', 'Strasse/Tandem', 'SBM', 'FW'),
  e('Stanzen/Umformen/Nachformen', 'Ziehen/Formen', 'SBM', 'FW'),
  e('Stanzen/Umformen/Nachformen', 'Transfer', 'SBM', 'FW'),
  e('Stanzen/Umformen/Nachformen', 'Nachformen/Kalibrieren', 'SBM', 'FW'),
  e('Stanzen/Umformen/Nachformen', 'Warmumformen/PHS', 'SBM', 'FW'),
  e('Stanzen/Umformen/Nachformen', 'Kaltpressen/Fliesspressen (Massivumformen)', 'SBM', 'FW'),
  e('Stanzen/Umformen/Nachformen', 'Mechanisierungszubehoer', 'V'),
]

/** Leitfaden Teil 3, S.90: Strangpressen – Zerspanen, Ende (Teil 6, letzte Seite). */
const SBM_DEVICE_CATEGORY_TABLE_PAGE_90: readonly SbmDeviceCategoryEntry[] = [
  e('Strangpressen', 'Strangpressen', 'SBM', 'FW'),
  e('Strangpressen', 'Extrudierwerkzeuge', 'SBM', 'FW'),
  e('Strangpressen', 'Mechanisierungszubehoer', 'V'),
  e('Walzen', 'Rollensatz', 'V'),
  e('Walzen', 'Walzwerkzeug (mit bauteilbezogener Narbung)', 'V'),
  e('Walzen', 'Mechanisierungszubehoer', 'V'),
  e('Zerspanen', 'Sonderwerkzeug mechanische Fertigung', 'V'),
  e('Zerspanen', 'Aufspannvorrichtung BAZ', 'V'),
  e('Zerspanen', 'Spannvorrichtung mechanische Fertigung', 'V'),
]

/** Full Betriebsmittelkategorie table, Leitfaden Teil 3 S.85-90, all 6 pages
 * concatenated in source order. */
export const SBM_DEVICE_CATEGORY_TABLE: readonly SbmDeviceCategoryEntry[] = [
  ...SBM_DEVICE_CATEGORY_TABLE_PAGE_85,
  ...SBM_DEVICE_CATEGORY_TABLE_PAGE_86,
  ...SBM_DEVICE_CATEGORY_TABLE_PAGE_87,
  ...SBM_DEVICE_CATEGORY_TABLE_PAGE_88,
  ...SBM_DEVICE_CATEGORY_TABLE_PAGE_89,
  ...SBM_DEVICE_CATEGORY_TABLE_PAGE_90,
]

/** Werkzeugart -> entry, keyed by normalizeProcessName (case/umlaut/
 * whitespace-insensitive) — built once at module load (pure data derivation,
 * same "computed constant" category as canonical-fields.ts's registry). Some
 * Werkzeugart labels (e.g. "Mechanisierungszubehoer") legitimately repeat
 * across Fertigungsprozess groups; they always carry the same classification
 * (Vorrichtung) so a single flat map is safe — verified by the "no duplicate
 * Werkzeugart text mapping to different classifications" test. */
const SBM_DEVICE_CATEGORY_INDEX: ReadonlyMap<string, SbmDeviceCategoryEntry> = new Map(
  SBM_DEVICE_CATEGORY_TABLE.map((entry) => [normalizeProcessName(entry.werkzeugart), entry]),
)

export interface SbmDeviceClassificationResult {
  /** The matched Betriebsmittelkategorie entry, or null when the Werkzeugart
   * text has no known match. */
  entry: SbmDeviceCategoryEntry | null
  /** True when the Werkzeugart could not be classified against
   * SBM_DEVICE_CATEGORY_TABLE — a "pruefen"-style hint for downstream
   * consumers, NEVER an error (task instruction: "Unbekannte Werkzeugart
   * darf NICHT als Fehler behandelt werden"). */
  needsReview: boolean
}

/**
 * Classify a Werkzeug-/Vorrichtungsart value against the Betriebsmittel-
 * kategorie reference table (Leitfaden Teil 3, S.85-90). Pure, never throws.
 * Returns null for a blank Werkzeugart (nothing to classify yet — a valid
 * intermediate state per Leitfaden [34], not itself a review flag).
 */
export function classifySbmToolDeviceType(werkzeugart: string): SbmDeviceClassificationResult | null {
  const key = normalizeProcessName(werkzeugart)
  if (key === '') return null
  const entry = SBM_DEVICE_CATEGORY_INDEX.get(key) ?? null
  return { entry, needsReview: entry === null }
}
