// WAF/LAF/LEK format detection — Detect & Reject (KAR-920 / backlog [P5.3]).
//
// Problem (05-backlog-phasenplan.md [P5.3] + 02-leitfaden-teil3.md [63]-[84]):
// WAF (Werkzeuganalyseformular), LAF (Logistikkostenanalyseformular) and LEK
// (Lieferantenentwicklungskosten) are separate Excel files with their own
// structure — NOT sheets inside a QAF workbook. WAF has its own price formula
// (Angebotspreis = (ΣMaterial+ΣFertigung)×(1+Zuschläge%)+Logistik,
// teil3.md:328 [83]); LEK has an enforced sheet-protection with an M50/P50
// sum-reconciliation field (teil3.md:139/164 [74]/[75]); LAF has 3 own sheets
// (Packaging/Transport/LVP, teil3.md:12-133 [67]-[73]). If one of these is
// uploaded where a QAF is expected, the QAF parsers (summary-metrics.ts,
// qaf-parser.ts, material-parser.ts, ...) either fail with a confusing
// generic error or — worse — silently match a handful of headers and produce
// wrong numbers instead of rejecting outright.
//
// Scope (P5.3, explicitly NOT full parsing — see backlog "Nicht im Scope"):
// detection only, via a small catalog of form-title signature strings taken
// VERBATIM from the Leitfaden extraction (02-leitfaden-teil3.md), matched
// against (a) worksheet tab names and (b) the header region (first
// HEADER_SCAN_ROWS rows) of every sheet — never a full-workbook parse. A hit
// rejects the upload immediately with a bilingual, format-named error instead
// of letting it fall through into a QAF parser.
//
// False-positive discipline (explicit task constraint: "ein echtes QAF darf
// NIE als WAF/LAF/LEK abgelehnt werden"): every signature below is a
// multi-word, form-specific phrase documented in the Leitfaden as the
// literal printed form title or worksheet tab name — never a single generic
// word ("Analyse", "Formular", "Transport" alone would all be far too easy
// to collide with legitimate QAF content, e.g. SBM-DEVICES-FWZ's own
// "Werkzeugart" field). Verified server-only against the 20-file real
// QAF sample under qaf-compare-kar824/input/ (structure-only smoke check,
// not committed — real supplier/OEM files never enter this repository, see
// CORRECTIONS "Rohdaten nicht vor Output-Validierung löschen" /
// confidentiality practice): 19 files pass through undetected as expected;
// one file (WAF_de.xlsm) is CORRECTLY detected as a genuine WAF — its
// "Analyse" worksheet repeats the exact "Werkzeug Analyse Formular" title
// across its header row and its field layout matches teil3.md:291-341
// [82]-[84] (Ansprechpartner/Stammdaten Bauteil/Kalkulatorische Material-
// kosten/Fertigungskosten) — a true positive, not a false one; see the PR
// body (KAR-920) for the full writeup.
//
// Pure core (ForeignFormWorkbook interface, no ExcelJS) + a thin ExcelJS
// bridge at the bottom — same "pure detection logic / ExcelJS adapter at the
// edge" split as g60/parser.ts + g60/bridge.ts, so detectForeignForm is
// unit-testable with synthetic fixtures, no ExcelJS/real workbook needed for
// the matching logic itself.
//
// Adversarial-review fix (KAR-920, 10.07.2026, F1 BLOCKING + F2): the FIRST
// version of detectForeignForm normalized and `.join(' ')`-concatenated (a)
// every header-region cell of a sheet into one blob, and (b) every sheet
// name into one blob, then ran a plain substring search over each blob. Two
// reviewer-verified PoCs broke that:
//   F1 — a real QAF's Prämissen sheet legitimately carries multi-column free
//   text down to row 16-20 (structurally verified across the 20-file real
//   sample). A plausible Prämissen note — "Sonderwerkzeuge werden separat
//   über das Werkzeug Analyse Formular kalkuliert und sind hier nicht
//   enthalten." — CONTAINS the "Werkzeug Analyse Formular" signature as a
//   substring of one long sentence, so the old blob-substring check rejected
//   a genuine QAF. That is strictly worse than the pre-P5.3 status quo (a
//   confusing parse error) — it is a hard false rejection of valid data.
//   F2 — `wb.sheetNames.join(' ')` let a signature phrase assemble across
//   the JOIN BETWEEN two unrelated sheet names (e.g. 'Kosten Werkzeug' +
//   'Analyse Formular X' → joined blob contains "WERKZEUG ANALYSE
//   FORMULAR" even though neither sheet name does).
// The fix drops both blobs entirely. Detection is now CELL-BOUND
// (isCloseCellMatch): a single sheet name or header-region cell only counts
// as a hit when its OWN normalized text contains the pattern AND is not
// substantially longer than the pattern, whereas the F1 Prämissen sentence is
// far longer than the phrase it happens to contain and is correctly excluded.
// The header-region path additionally requires MIN_HEADER_CELL_HITS
// independent cell hits (real form titles repeat across merged-cell
// segments — the real WAF_de.xlsm sample repeats its title across 12
// adjacent cells; a free-text cross-reference like the F1 PoC appears
// exactly once even before the isCloseCellMatch guard is considered). The
// sheet-name path stays a single-hit check (Excel's 31-char tab-name limit
// already bounds it, and each sheet name is now matched independently — no
// join — which is the F2 fix on its own).
//
// Re-Review rider (PR #293, Sev 30, 10.07.2026): isCloseCellMatch's original
// "substring + length-ratio ≤1.5" guard (CELL_LENGTH_SLACK) still let a
// SHORT cross-reference through — e.g. a Prämissen cell reading exactly
// "siehe Werkzeug Analyse Formular" (a short pointer to the separate WAF
// file, legitimately repeated 2× in rows 1-20, satisfying
// MIN_HEADER_CELL_HITS) is only ~1.2× the pattern's length, well inside the
// old 1.5 slack, and would have been misdetected as a genuine WAF title. Real
// form-title cells (verified across the WAF_de.xlsm sample and every
// evidence-quoted teil3.md screenshot title) contain EXACTLY the signature
// phrase and nothing else — no length tolerance is needed, only the
// punctuation/whitespace normalization normalizeForSignatureMatch already
// performs (hyphens, parentheses, double spaces, ...). isCloseCellMatch is
// now an exact match on the normalized text (cell == phrase), which also
// drops the length-ratio guard as dead weight — a strictly stronger check,
// not a relaxation, so every existing genuine-title fixture still matches
// (their cells already contain the phrase alone, nothing appended).
//
// KAR-923 (PR #294 review finding, 10.07.2026) — compound-title robustness,
// with an HONEST verification-status caveat: PR #293's exact-match-only rule
// is real-file-verified for exactly ONE signature — WAF-Signature-1
// ("Werkzeug Analyse Formular"), whose title cells in the real WAF_de.xlsm
// sample contain the phrase alone. LAF and WAF-Signature-2
// ("Werkzeuganalyseformular") have NO real-file sample; this repository has
// never seen a real LAF upload. The Leitfaden's own quoted wording for both
// — "LAF (Logistikkosten Analyse Formular), Version LAF 6.2_06_01"
// (teil3.md:13 [67]) and "Das Werkzeuganalyseformular" (teil3.md:334 [84],
// screenshot title) — reads as ONE printed compound title, not a
// phrase-only cell. An exact-match-only rule would silently MISS a real LAF
// or WAF-Signature-2 file: it falls through undetected into the QAF parser,
// which is precisely the false-negative failure class P5.3/detectForeignForm
// exists to prevent (worse than a WAF/LAF being over-rejected, since a
// false negative produces silently wrong numbers instead of a clear error).
//
// isCloseCellMatch is therefore widened from "cell == phrase" to "cell
// contains the phrase at a WORD boundary AND the cell isn't substantially
// longer than the phrase AND the cell contains no reference-marker word":
//   1. Word-boundary containment (containsPatternAsWholeWords) — the phrase
//      must appear as a contiguous run of whole normalized words, so
//      "WERKZEUG ANALYSE FORMULAR" still never matches out-of-order or
//      partial-word variants (unchanged from the F1/F2 fix).
//   2. Length slack (CLOSE_MATCH_MAX_EXTRA_CHARS = 40) — the normalized cell
//      may exceed the pattern by at most 40 chars, enough to cover every
//      documented compound-title shape (numbering "3. ", articles "Das ",
//      parenthetical/version suffixes "(...), Version LAF 6.2_06_01" — all
///     comfortably under 40 extra normalized chars) while still rejecting
//      the F1 PoC free-text SENTENCE (~106 normalized chars, more than 2×
//      the slack) by a wide margin — no separate "numbering/article/
//      parenthesis" pattern allowlist is needed, the length bound alone
//      already draws that line for every case this task documents.
//   3. Reference-marker exclusion (REFERENCE_MARKER_WORDS) — "siehe"/"vgl."/
//      "see" are explicitly disallowed anywhere in the cell. This is a
//      DELIBERATE, DOCUMENTED trade-off: the PR #293 Re-Review rider PoC
//      ("siehe Werkzeug Analyse Formular", Sev 30) is short enough to pass
//      the length slack on its own, so without this explicit exclusion the
//      widened word-boundary rule would reopen that exact gap. Excluding
//      known reference-marker words closes it again while still allowing
//      every documented compound-title shape (none of which start with a
//      cross-reference marker).
// Net effect: WAF-Signature-1 stays real-file-verified-exact (its title IS
// phrase-only, so it matches under either the old or the new rule). LAF and
// WAF-Signature-2 gain compound-title coverage that is Leitfaden-wording-
// plausible but UNVERIFIED against a real file — see CHANGELOG.md for the
// per-signature verification-status table. The MIN_HEADER_CELL_HITS ≥2
// requirement (below) is unchanged and remains the second net: even a
// widened per-cell match still needs 2 independent header-region hits (or a
// sheet-name hit) to reject an upload.
//
// PR #295 review rider (KAR-923, 10.07.2026, F1 Sev ~45 + F2 Sev ~20-25):
// two cheap gaps in the widened isCloseCellMatch, fixed before merge.
//   F1 — the sheet-name path (detectForeignForm's first loop) reused the
//   widened isCloseCellMatch but stayed SINGLE-HIT (no MIN_HEADER_CELL_HITS
//   net — that requirement only ever applied to header-region cells). Before
//   KAR-923 that was safe because isCloseCellMatch was itself an exact
//   match; after KAR-923's length slack, Excel's 31-char tab-name limit sits
//   UNDER the slack ceiling (pattern.length + 40), so a single tab named
//   e.g. "s. Werkzeug Analyse Formular" (29 chars, reviewer PoC) would
//   satisfy word-boundary+slack on ONE hit and reject an otherwise harmless
//   workbook. Fixed by introducing isExactSignatureMatch (cell == phrase,
//   no slack) for the sheet-name path only; the header-cell path keeps the
//   widened isCloseCellMatch + its MIN_HEADER_CELL_HITS ≥2 net.
//   F2 — REFERENCE_MARKER_WORDS only covered SIEHE/VGL/SEE. Reviewer PoCs
//   "gemäß Werkzeug Analyse Formular Anlage 3" and "laut Werkzeug Analyse
//   Formular kalkuliert" pass word-boundary+slack and were, before this
//   fix, only ever caught by MIN_HEADER_CELL_HITS (untested, and not a net
//   at all on the now-exact sheet-name path). Extended with
//   GEM/GEMÄSS/LAUT/S (see REFERENCE_MARKER_WORDS doc comment for the
//   GEM-vs-GEMÄSS normalization detail and the "s." abbreviation case).

import type { Worksheet } from 'exceljs'
import { resolveCell } from './workbook-adapter'

export type ForeignFormKind = 'LAF' | 'LEK' | 'WAF'

/**
 * Pure read access: sheet names + the header-region text of one sheet (never
 * a full-sheet read — see module header "Scope"). Mirrors g60/parser.ts's
 * CellGetter/G60Workbook split so the detection logic below is testable
 * without ExcelJS.
 */
export interface ForeignFormWorkbook {
  sheetNames: readonly string[]
  /**
   * Non-empty string cell values from the sheet's header region (row 1..
   * HEADER_SCAN_ROWS, every column), in reading order. Empty array if the
   * sheet has no such text or does not exist.
   */
  headerRegionText(sheetName: string): readonly string[]
}

/**
 * Rows scanned per sheet — "nur obere Zeilen lesen, kein Voll-Parse" (task
 * constraint). Every documented form-title signature (teil3.md) sits in the
 * top few rows of its sheet (form header/Kopfzeile, e.g. WAF's title spans
 * rows 1-5 in the real sample file); 20 gives headroom without approaching a
 * full-sheet read even for a large workbook.
 */
export const HEADER_SCAN_ROWS = 20

interface ForeignFormSignature {
  kind: ForeignFormKind
  /** Original phrase, exactly as printed/quoted in the Leitfaden. */
  label: string
  /** Normalized (see normalizeForSignatureMatch) substring matched against
   * sheet names and header-region text. */
  pattern: string
  /** Grep-able Leitfaden citation (Standing Order: "Quellenangaben werden
   * gegrept"). */
  source: string
}

// Every entry below is a literal, multi-word form-title/tab-name string
// quoted in 02-leitfaden-teil3.md — nothing invented (task constraint:
// "nichts erfinden, nur Belegtes"). Single generic words are deliberately
// excluded (false-positive discipline, see module header).
const FOREIGN_FORM_SIGNATURES: readonly ForeignFormSignature[] = [
  // LEK — Lieferantenentwicklungskosten (teil3.md [74]-[79]).
  {
    kind: 'LEK',
    label: 'LEK COST BREAK-DOWN',
    pattern: 'LEK COST BREAK DOWN',
    source: '02-leitfaden-teil3.md:201 [78] – Screenshot-Titel „LEK Version 2.2_04_01 …, «LEK COST BREAK-DOWN», Seite 2 von 4."',
  },
  {
    kind: 'LEK',
    label: 'Zahlungsplan LEK Allgemein',
    pattern: 'ZAHLUNGSPLAN LEK ALLGEMEIN',
    source: '02-leitfaden-teil3.md:171 [76] – Screenshot-Titel „…, «Zahlungsplan LEK Allgemein»."',
  },
  // LAF — Logistikkostenanalyseformular (teil3.md [67]-[73]).
  {
    kind: 'LAF',
    label: 'Logistikkosten Analyse Formular',
    pattern: 'LOGISTIKKOSTEN ANALYSE FORMULAR',
    source: '02-leitfaden-teil3.md:13 [67] – „LAF (Logistikkosten Analyse Formular), Version LAF 6.2_06_01 …"',
  },
  {
    kind: 'LAF',
    label: 'Lieferantenvorverpackung - LVP',
    pattern: 'LIEFERANTENVORVERPACKUNG LVP',
    source: '02-leitfaden-teil3.md:127 [73] – „LAF, Seite 3 von 4, «Lieferantenvorverpackung - LVP»."',
  },
  // WAF — Werkzeuganalyseformular (teil3.md [80]-[84]).
  {
    kind: 'WAF',
    label: 'Werkzeug Analyse Formular',
    pattern: 'WERKZEUG ANALYSE FORMULAR',
    source: '02-leitfaden-teil3.md:254 [80] – „WAF (Werkzeug Analyse Formular)."',
  },
  {
    kind: 'WAF',
    label: 'Werkzeuganalyseformular',
    pattern: 'WERKZEUGANALYSEFORMULAR',
    source: '02-leitfaden-teil3.md:334 [84] – Titel „Das Werkzeuganalyseformular (vollständiger Screenshot)"',
  },
]

/**
 * Uppercase, punctuation/whitespace collapsed to single spaces, trimmed —
 * tolerant of hyphen/space variants ("LEK COST BREAK-DOWN" vs "... BREAK
 * DOWN") without weakening the multi-word phrase match itself (word order
 * and adjacency still matter — see the false-positive tests).
 */
function normalizeForSignatureMatch(raw: string): string {
  return raw
    .toUpperCase()
    .replace(/[^A-Z0-9]+/g, ' ')
    .replace(/\s+/g, ' ')
    .trim()
}

/**
 * Extra normalized chars a cell may carry beyond the signature phrase itself
 * and still count as a title-cell hit (KAR-923, see module header). Covers
 * every documented compound-title shape (numbering, articles, parenthetical/
 * version suffixes) with headroom, while the F1 PoC free-text sentence
 * (~106 normalized chars vs. a ~25-31 char pattern) stays far outside it.
 */
const CLOSE_MATCH_MAX_EXTRA_CHARS = 40

/**
 * Reference/cross-reference marker words (normalized, i.e. uppercase) that
 * disqualify a cell even when it otherwise satisfies the word-boundary +
 * length-slack check — KAR-923 deliberate trade-off, see module header. A
 * cell like "siehe Werkzeug Analyse Formular" is short enough to pass the
 * length slack on its own; without this explicit exclusion the widened rule
 * would reopen the PR #293 Re-Review rider gap (Sev 30).
 *
 * PR #295 review (F2, Sev ~20-25): the initial KAR-923 list only covered
 * SIEHE/VGL/SEE. Reviewer PoCs "gemäß Werkzeug Analyse Formular Anlage 3"
 * and "laut Werkzeug Analyse Formular" pass word-boundary+slack and were
 * previously only caught by MIN_HEADER_CELL_HITS (untested, and not a net
 * at all on the sheet-name path — see the F1 fix below). Extended with
 * GEM/GEMÄSS/LAUT/S. Two notes on GEM vs. GEMÄSS: normalizeForSignatureMatch
 * splits "gemäß" into "GEM" + "SS" (Ä is stripped as a non-[A-Z0-9]
 * separator; JS's default toUpperCase maps ß→SS, which IS [A-Z0-9] and
 * survives) — so "GEM" is what actually fires today; "GEMÄSS" is kept in
 * the set too as a defensive/documentation no-op in case the normalizer's
 * umlaut handling ever changes. "S" covers the abbreviated pointer "s."
 * (German "siehe" shorthand, e.g. "s. Werkzeug Analyse Formular"), which
 * normalizes to the standalone word "S".
 */
const REFERENCE_MARKER_WORDS: ReadonlySet<string> = new Set(['SIEHE', 'VGL', 'SEE', 'GEMÄSS', 'GEM', 'LAUT', 'S'])

/** Whole-word containment on already-normalized (single-space-separated)
 * text — padding both sides with a boundary space ensures `pattern` cannot
 * match as part of a longer word or across word order (KAR-920 F1/F2
 * discipline preserved). */
function containsPatternAsWholeWords(normalizedText: string, pattern: string): boolean {
  return ` ${normalizedText} `.includes(` ${pattern} `)
}

function containsReferenceMarkerWord(normalizedText: string): boolean {
  return normalizedText.split(' ').some((word) => REFERENCE_MARKER_WORDS.has(word))
}

/**
 * A single cell's (or sheet name's) normalized text is a permitted
 * signature "hit" candidate. Widened in KAR-923 from "cell == phrase"
 * (PR #293 Re-Review rider) to: the phrase appears at a word boundary
 * (containsPatternAsWholeWords — still rejects out-of-order/partial-word
 * near-misses, KAR-920 F1/F2), the cell is not substantially longer than
 * the phrase (CLOSE_MATCH_MAX_EXTRA_CHARS — still rejects the F1 PoC
 * free-text sentence by a wide margin), and the cell carries no
 * reference-marker word (REFERENCE_MARKER_WORDS — still rejects the
 * Re-Review rider's short cross-reference PoC). See the module header's
 * KAR-923 comment for the full rationale and the honest per-signature
 * verification-status caveat (only WAF-Signature-1 is real-file-verified;
 * this widening covers LAF/WAF-Signature-2 on Leitfaden wording alone).
 */
function isCloseCellMatch(normalizedText: string, pattern: string): boolean {
  if (!containsPatternAsWholeWords(normalizedText, pattern)) return false
  if (containsReferenceMarkerWord(normalizedText)) return false
  return normalizedText.length <= pattern.length + CLOSE_MATCH_MAX_EXTRA_CHARS
}

/**
 * Strict signature match for worksheet (tab) names — PR #295 review (F1,
 * Sev ~45). The sheet-name path in detectForeignForm is single-hit (no
 * MIN_HEADER_CELL_HITS net), which was safe pre-KAR-923 because
 * isCloseCellMatch was itself an exact match. KAR-923 widened
 * isCloseCellMatch with a length slack for the header-cell path — but
 * Excel's 31-char tab-name limit sits UNDER that slack ceiling
 * (pattern.length + 40 easily exceeds 31 for every signature in this
 * catalog), so a short cross-reference-shaped tab name like
 * "s. Werkzeug Analyse Formular" (29 chars) would satisfy the widened
 * word-boundary+slack check on a SINGLE hit and reject an otherwise
 * harmless workbook outright. Real form-title tab names (this repo has
 * never seen the sheet-name path fire on a real WAF/LAF/LEK file — the
 * real WAF_de.xlsm sample's matching sheet is named "Analyse", not a
 * signature phrase; the sheet-name path is unverified against any real
 * file) have no documented reason to need compound-title slack the way a
 * printed form-title CELL does, so the sheet-name path stays exact-match
 * only: no slack, no reference-marker widening needed (an exact match by
 * definition carries no extra words to exclude).
 */
function isExactSignatureMatch(normalizedText: string, pattern: string): boolean {
  return normalizedText === pattern
}

/**
 * Independent header-region cell hits required for a signature to count —
 * see the KAR-920 adversarial-review fix comment in the module header. A
 * real form title repeats across merged-cell segments (WAF_de.xlsm real
 * sample: 12 adjacent hits); a stray free-text cross-reference (F1 PoC)
 * produces at most 1, even ignoring the length guard above.
 */
const MIN_HEADER_CELL_HITS = 2

export interface ForeignFormDetection {
  kind: ForeignFormKind
  /** Which catalog signature matched (original, non-normalized phrase). */
  matchedLabel: string
  /** Leitfaden source citation for the matched signature. */
  source: string
  reasonDe: string
  reasonEn: string
}

function toDetection(sig: ForeignFormSignature): ForeignFormDetection {
  return {
    kind: sig.kind,
    matchedLabel: sig.label,
    source: sig.source,
    reasonDe: `Diese Datei ist ein ${sig.kind}-Formular, kein QAF — wird aktuell nicht unterstützt.`,
    reasonEn: `This file is a ${sig.kind} form, not a QAF — currently unsupported.`,
  }
}

/** Count of header-region cells (across ALL sheets, since a form title can
 * legitimately land on one worksheet only, e.g. WAF's single "Analyse"
 * sheet) that individually close-match `pattern` — see isCloseCellMatch. */
function countHeaderCellHits(wb: ForeignFormWorkbook, pattern: string): number {
  let hits = 0
  for (const name of wb.sheetNames) {
    for (const cell of wb.headerRegionText(name)) {
      if (isCloseCellMatch(normalizeForSignatureMatch(cell), pattern)) hits++
    }
  }
  return hits
}

/**
 * Detect a WAF/LAF/LEK upload before it reaches any QAF parser. Checks each
 * sheet (tab) name INDIVIDUALLY first (cheap; no cross-sheet-name join —
 * KAR-920 F2 fix; EXACT match only — isExactSignatureMatch, PR #295 review
 * F1 fix — the sheet-name path is single-hit with no MIN_HEADER_CELL_HITS
 * net, so it must not use the KAR-923 header-cell slack), then the
 * header-region (rows 1..HEADER_SCAN_ROWS) cell hit count per sheet, using
 * the widened isCloseCellMatch and requiring MIN_HEADER_CELL_HITS
 * independent cells (KAR-920 F1 fix) — see the module header's
 * adversarial-review comment for the full rationale. Returns the FIRST
 * matching signature (catalog order is not meaningful for correctness — the
 * three formats' signature phrases cannot co-occur) or null when nothing
 * matches, which is the overwhelmingly common case: a real QAF file.
 */
export function detectForeignForm(wb: ForeignFormWorkbook): ForeignFormDetection | null {
  for (const sig of FOREIGN_FORM_SIGNATURES) {
    const sheetNameHit = wb.sheetNames.some((name) => isExactSignatureMatch(normalizeForSignatureMatch(name), sig.pattern))
    if (sheetNameHit) return toDetection(sig)
  }
  for (const sig of FOREIGN_FORM_SIGNATURES) {
    if (countHeaderCellHits(wb, sig.pattern) >= MIN_HEADER_CELL_HITS) return toDetection(sig)
  }
  return null
}

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

/**
 * Wrap a loaded ExcelJS workbook as the pure ForeignFormWorkbook interface —
 * same "adapter at the edge" pattern as g60/bridge.ts's
 * g60WorkbookFromExcelJs. Reads only row 1..HEADER_SCAN_ROWS per sheet via
 * ExcelJS's own getRow() (no worksheetToGrid full-sheet pass).
 */
export function foreignFormWorkbookFromExcelJs(wb: { worksheets: Worksheet[] }): ForeignFormWorkbook {
  const sheets = new Map(wb.worksheets.map((ws) => [ws.name, ws]))
  return {
    sheetNames: [...sheets.keys()],
    headerRegionText: (sheetName) => {
      const ws = sheets.get(sheetName)
      if (!ws) return []
      const rowCount = Math.min(HEADER_SCAN_ROWS, ws.rowCount ?? HEADER_SCAN_ROWS)
      const parts: string[] = []
      for (let r = 1; r <= rowCount; r++) {
        ws.getRow(r).eachCell({ includeEmpty: false }, (cell) => {
          const v = resolveCell(cell)
          if (typeof v === 'string' && v.trim() !== '') parts.push(v)
        })
      }
      return parts
    },
  }
}
