// G60 structure guard (KAR-888 / P0.3) — minimal label-anchor validation that
// stops the G60 parser from silently returning wrong or empty numbers when a
// cost tab's header row or the INPUT sheet's rate-card labels no longer
// match what parser.ts's fixed-coordinate extraction assumes (Master-Prompt
// §7 "silent template modification without an updated version number", §8
// "only the affected section is blocked, not the whole file").
//
// Deliberately NOT a rewrite of the extraction itself (that is the semantic
// label-anchored G60 rebuild scoped to P1.3) — this module only wraps the
// existing, unmodified parser.ts: it can say "the coordinates I read do/don't
// look like what they should", it can never re-locate a value that moved.
//
// Anchor vocabulary — VERIFIED against two real G60 files on the operator's // allow-customer-string
// server on 2026-07-09 (100_G60_DP_QAF_Basis_24_10_BMW.xlsm and // allow-customer-string
// 20260508_BMW_QAF_G60_DP_RePricing_HO.xlsm; header TEXTS only, read-only, // allow-customer-string
// no values extracted, files never copied into this repo — see PR #269
// review). That verification corrected two things the original (pre-review)
// version of this module got wrong from Leitfaden-derived guessing alone:
//  1. The header row is NOT `TAB_ROW_FIRST − 1` (14) — both real files carry
//     a genuine header only in a ROW 12:14 MERGED CELL, whose value lives on
//     row 12. Row 14 (the merge's last row) reads back empty in both files,
//     which would have hard-excluded every real cost tab (5/5 anchors blank
//     at once). G60_HEADER_ROW is now the verified literal 12, decoupled
//     from parser.ts's TAB_ROW_FIRST (that constant is about the DATA rows
//     15-38, not the header — the two were never actually related).
//  2. Column Y's real header is " Raw material sucharge\n RoZ0 [AW]" — BMW's // allow-customer-string
//     own template has a typo ("sucharge", not "surcharge") in BOTH files
//     (basis AND repricing), so it is the template's actual spelling, not a
//     one-off error. The synonym list below matches on the substring "raw
//     material" (present regardless of the surcharge/sucharge spelling) so a
//     future corrected template still matches too.
// Columns W ("Material costs"), AO ("Cycle time"), AQ ("Number of direct
// employees") and AT ("Machine-hour rate") matched the ENGLISH synonym
// already present before this verification without any change — both real
// files are English-only at these cells (no German variant observed), the
// DE synonyms are kept as a documented-but-unverified fallback for a
// German-language template variant, not because one was seen.
//
// The INPUT!B22-B29 rate-card anchors reuse the label CELLS the parser
// already reads via inputCard() (B{row} next to C{row}) but never validates;
// the guard checks only PRESENCE there (any non-blank text), not exact
// wording. Real-file check: B22-B29 read 'Overhead FK'/'Overhead MAT'/
// 'Profit FK'/'Profit MAT' (the same four labels repeated for the
// direct/sourced halves) in both files — confirms presence-only is the
// right check here (an exact-match would have to know the labels repeat,
// which presence-only does not need to know).

import {
  detailTabs,
  parseG60Workbook,
  extractTab,
  type CellGetter,
  type G60ColumnOverrides,
  type G60ParseResult,
  type G60TabAggregate,
  type G60Workbook,
} from './parser'
import { normalizePosition as str } from '../normalizer'
import type { PlausibilityIssue, PlausibilitySeverity } from '../plausibility'
import type { FacetDegradation } from '../types'

// ── Findings ────────────────────────────────────────────────────────────────

export interface G60StructureMismatch {
  cell: string
  expected: string
  found: string
}

/** KAR-894/P1.3: one of the 5 anchored columns (W/Y/AO/AQ/AT) that
 * locateG60ColumnAnchors found in a neighbor column (±2) rather than at its
 * expected position — the value was still read (from `resolvedCol`), just
 * not from the fix-coordinate default. */
export interface G60ColumnRelocation {
  /** Anchor's expected (fix-coordinate) column, e.g. 'W'. */
  col: string
  /** Column the anchor text was actually found in and read from. */
  resolvedCol: string
  /** Signed column distance resolvedCol − col. */
  offset: number
  confidence: number
}

export interface G60StructureFinding {
  ok: boolean
  mismatches: G60StructureMismatch[]
  /** 1 = clean, 0.6 = soft (single anchor) mismatch, 0 = hard (core anchors gone). */
  confidence: number
  /** KAR-894/P1.3: anchors resolved to a neighbor column — additive, absent/empty
   * when nothing was relocated. Lives here (not a sibling top-level field) so the
   * persisted qaf_g60_tab.aggregate.structure JSONB carries it without a new key
   * (KAR-888-Shape erweitern, nicht ersetzen). */
  columnRelocations?: G60ColumnRelocation[]
}

const OK_FINDING: G60StructureFinding = { ok: true, mismatches: [], confidence: 1 }

export interface G60StructureGuardConfig {
  /** Confidence assigned when the mismatch count stays below hardMismatchThreshold. */
  softConfidence: number
  /** Mismatch count at/above which a finding escalates from soft to hard. */
  hardMismatchThreshold: number
  /** KAR-894/P1.3: confidence assigned when an anchor is found in a neighbor
   * column (±2) instead of its expected position — the value is still used
   * (read from the resolved column), just flagged for review. Optional
   * (KAR-886/896 additive-field pattern): defaults to
   * DEFAULT_RELOCATED_CONFIDENCE (0.8) when omitted, so every existing
   * config object literal in this codebase (including the four already in
   * this file's own test suite) keeps compiling and behaving unchanged. */
  relocatedConfidence?: number
}

/** Fallback for G60StructureGuardConfig.relocatedConfidence when a caller's
 * config object predates this field (KAR-894/P1.3). */
export const DEFAULT_RELOCATED_CONFIDENCE = 0.8

function relocatedConfidenceOf(config: G60StructureGuardConfig): number {
  return config.relocatedConfidence ?? DEFAULT_RELOCATED_CONFIDENCE
}

/** Named thresholds (RULE_ENGINE_CONFIG/RECONCILIATION_CONFIG pattern, KAR-889/887):
 * a single missing/wrong anchor stays "soft" (value kept, marked unsure); two
 * or more escalates to "hard" (affected section excluded) — matches backlog
 * P0.3's own (a) "einzelner Anker" / (b) "mehrere Kern-Anker" split.
 *
 * hardMismatchThreshold=2 was kept at its original value after real-file
 * verification (module header, 2026-07-09) rather than raised — all five
 * TAB_HEADER_ANCHORS matched cleanly (0 mismatches) against both verified
 * files once the header row itself was corrected to 12, so there was no
 * observed near-miss that would argue for a more lenient (higher) threshold.
 * Revisit if a future real file legitimately trips 2 anchors on an
 * otherwise-intact template. */
export const G60_STRUCTURE_GUARD_CONFIG: G60StructureGuardConfig = {
  softConfidence: 0.6,
  hardMismatchThreshold: 2,
  relocatedConfidence: DEFAULT_RELOCATED_CONFIDENCE,
}

function textOf(v: unknown): string {
  return str(v)
}

/**
 * Config is now an explicit parameter (P1.5/KAR-896, generalizing the KAR-889
 * ruleEngineConfig pattern) — every caller below defaults to the module
 * constant G60_STRUCTURE_GUARD_CONFIG, so passing nothing stays byte-identical
 * to the pre-KAR-896 behaviour.
 */
function finalizeFinding(
  mismatches: G60StructureMismatch[],
  config: G60StructureGuardConfig = G60_STRUCTURE_GUARD_CONFIG,
): G60StructureFinding {
  if (mismatches.length === 0) return OK_FINDING
  const hard = mismatches.length >= config.hardMismatchThreshold
  return { ok: false, mismatches, confidence: hard ? 0 : config.softConfidence }
}

// ── Anchors ─────────────────────────────────────────────────────────────────

/** Header row above the fixed data columns. VERIFIED against two real G60
 * files (see module header, 2026-07-09): the header lives in a row-12:14
 * merged cell whose value is only readable at row 12 — NOT `TAB_ROW_FIRST −
 * 1` (14), which was the original (wrong) assumption before verification.
 * Deliberately a standalone literal, not derived from parser.ts's
 * TAB_ROW_FIRST (15, the data rows 15-38) — the two turned out to be
 * unrelated coordinates, not off-by-one of each other. */
export const G60_HEADER_ROW = 12

interface ColumnAnchor {
  col: string
  synonyms: string[]
}

/** Representative subset of the fixed data columns (not all 14) — chosen
 * because each has a SINGLE, unambiguous semantic across both the row-41
 * aggregate and the per-row (15-38) use (see module header + backlog P0.3
 * Implementierungs-Design example "Zykluszeit/Cycle time, Lohnkosten/Labour
 * cost"). Column AS is deliberately excluded: it means "Personnel (Lohn)" at
 * row 41 (MCOL.Pers) but "ineff" (Ineffizienz-Faktor) per-row — a header
 * anchor for that column would have to describe two different concepts,
 * which is exactly the guessing risk this guard avoids.
 *
 * All five real-file header texts (module header, verified 2026-07-09)
 * matched an EN synonym already on this list without any wording change —
 * W/AO/AQ/AT needed no edit. Only Y needed 'raw material' added (substring,
 * catches the real template's "sucharge" typo and a hypothetical corrected
 * "surcharge" spelling alike) — no threshold change was needed, all five
 * anchors read as a clean match against both verified files. */
const TAB_HEADER_ANCHORS: readonly ColumnAnchor[] = [
  { col: 'W', synonyms: ['material'] },
  { col: 'Y', synonyms: ['raw material', 'materialzuschlag', 'rohstoffzuschlag', 'material surcharge', 'raw material surcharge'] },
  { col: 'AO', synonyms: ['zykluszeit', 'cycle time'] },
  { col: 'AQ', synonyms: ['mitarbeiter', 'anzahl mitarbeiter', 'employees'] },
  { col: 'AT', synonyms: ['maschine', 'maschinensatz', 'machine', 'machine rate'] },
]

/** INPUT!B22-B29 — the SG&A/profit rate labels already read (but never
 * validated) by inputCard()/inputRates(). CORE: every one of the eight rates
 * feeds the SGA/Profit accumulation in EVERY cost tab (parser.ts
 * extractTab) — an unlabelled/shifted row here means C22-C29 may no longer
 * be the rates the parser assumes them to be. */
const INPUT_RATE_LABEL_ROWS = [22, 23, 24, 25, 26, 27, 28, 29] as const

// ── Label-based column localization (KAR-894/P1.3) ────────────────────────────
//
// KAR-888 only ever asked "does the anchor match at its fix coordinate?" — a
// shifted column (e.g. a column inserted/removed upstream of W) reads as a
// flat mismatch even though the real header text is still ON THE SHEET, just
// one or two columns over. locateG60ColumnAnchors extends that single
// yes/no check into three outcomes per anchor:
//   - matched   (offset 0): fix coordinate confirmed, confidence 1.0 — the
//     overwhelming majority case (both verified real files, see module
//     header — 0/5 anchors ever needed this path).
//   - relocated (offset ±1/±2): anchor text found in EXACTLY ONE neighbor
//     column — the value is read from there instead, confidence 0.8, and a
//     dedicated "prüfen" issue (g60_column_relocated) is raised so a reviewer
//     knows the coordinate moved. Deliberately conservative: if the anchor
//     text matches in MORE than one neighbor (ambiguous — which one moved?),
//     this is treated exactly like "missing", not guessed at.
//   - missing   (nowhere in offset 0/±1/±2): unchanged KAR-888 behaviour —
//     validateG60TabHeader still counts it as a mismatch, and the existing
//     soft/hard escalation (finalizeFinding) still applies.
// Deliberately scoped to the same 5 anchors as KAR-888 (W/Y/AO/AQ/AT) — the
// remaining fix-coordinate columns (AP/AS/AR/BC/AX/BA/AZ/AD/DB, …) have no
// verified anchor text (see TAB_HEADER_ANCHORS doc comment on why AS in
// particular never got one) and stay purely coordinate-based; guessing a
// synonym list for them without a real-file verification would repeat the
// exact mistake KAR-888's module header documents correcting (Leitfaden-only
// guessing, wrong on 2/2 real files for the header row itself).

export type G60AnchorStatus = 'matched' | 'relocated' | 'missing'

export interface G60ColumnLocation {
  /** Anchor's expected (fix-coordinate) column, e.g. 'W'. */
  col: string
  status: G60AnchorStatus
  /** Column the value should be read from — `col` itself when matched, the
   * found neighbor when relocated, null when missing. */
  resolvedCol: string | null
  /** Signed column distance resolvedCol − col (0 unless relocated). */
  offset: number
  confidence: number
}

/** Search order for a shifted anchor: nearest neighbor first, left before
 * right (arbitrary tie-break — ambiguity between two directions is handled
 * by the ">1 candidate → missing" rule below regardless of search order). */
const NEIGHBOR_OFFSETS = [-1, 1, -2, 2] as const

function colToNum(col: string): number {
  let n = 0
  for (const ch of col) n = n * 26 + (ch.charCodeAt(0) - 64)
  return n
}

function numToCol(n: number): string {
  let s = ''
  let rest = n
  while (rest > 0) {
    const remainder = (rest - 1) % 26
    s = String.fromCharCode(65 + remainder) + s
    rest = Math.floor((rest - 1) / 26)
  }
  return s
}

/** Column letter ± offset (Excel-style, 'A'=1). Null if it would shift below
 * column A — none of the five anchors are close enough to A for this to
 * matter in practice, but a defensive bound is cheap. */
function shiftCol(col: string, offset: number): string | null {
  const n = colToNum(col) + offset
  return n < 1 ? null : numToCol(n)
}

function locateAnchor(
  sheet: CellGetter,
  anchor: ColumnAnchor,
  headerRow: number,
  config: G60StructureGuardConfig,
): G60ColumnLocation {
  const matchesAt = (col: string): boolean => {
    const found = textOf(sheet(`${col}${headerRow}`)).trim().toLowerCase()
    return found !== '' && anchor.synonyms.some((s) => found.includes(s))
  }
  if (matchesAt(anchor.col)) {
    return { col: anchor.col, status: 'matched', resolvedCol: anchor.col, offset: 0, confidence: 1 }
  }
  const candidates = NEIGHBOR_OFFSETS.map((offset) => ({ offset, col: shiftCol(anchor.col, offset) })).filter(
    (c) => c.col !== null && matchesAt(c.col),
  )
  if (candidates.length === 1) {
    const { offset, col } = candidates[0]
    return { col: anchor.col, status: 'relocated', resolvedCol: col as string, offset, confidence: relocatedConfidenceOf(config) }
  }
  // 0 candidates (nowhere found) or >1 (ambiguous — which one is it?) both
  // fall back to "missing": an ambiguous match is deliberately NOT guessed
  // at, same safety posture as the rest of this module.
  return { col: anchor.col, status: 'missing', resolvedCol: null, offset: 0, confidence: 0 }
}

/** Locate all 5 anchored columns for one cost tab's header row. Pure — takes
 * a CellGetter, no workbook/IO. */
export function locateG60ColumnAnchors(
  sheet: CellGetter,
  headerRow: number = G60_HEADER_ROW,
  config: G60StructureGuardConfig = G60_STRUCTURE_GUARD_CONFIG,
): G60ColumnLocation[] {
  return TAB_HEADER_ANCHORS.map((anchor) => locateAnchor(sheet, anchor, headerRow, config))
}

/** Validate one cost tab's header row against the representative column
 * anchors. Pure — takes a CellGetter, no workbook/IO.
 *
 * KAR-894/P1.3: built on locateG60ColumnAnchors — an anchor found in exactly
 * one neighbor column ('relocated') is NOT counted as a mismatch here (the
 * value is still reliably readable, just from a different column); only
 * anchors that are 'missing' (nowhere in offset 0/±1/±2, or ambiguously in
 * more than one neighbor) count toward mismatches/escalation, exactly as
 * before this change. This is an intentional behaviour improvement, not a
 * regression: a file that used to soft/hard-mismatch on a shifted-but-
 * findable column now resolves it instead (see module header). Every
 * existing KAR-888 regression fixture has no stray neighbor-cell content, so
 * this produces byte-identical output for all of them (verified by the
 * unmodified test suite). */
export function validateG60TabHeader(
  sheet: CellGetter,
  headerRow: number = G60_HEADER_ROW,
  config: G60StructureGuardConfig = G60_STRUCTURE_GUARD_CONFIG,
): G60StructureFinding {
  const mismatches: G60StructureMismatch[] = []
  for (const location of locateG60ColumnAnchors(sheet, headerRow, config)) {
    if (location.status !== 'missing') continue
    const anchor = TAB_HEADER_ANCHORS.find((a) => a.col === location.col)!
    const cellAddr = `${location.col}${headerRow}`
    const found = textOf(sheet(cellAddr))
    const normalized = found.trim().toLowerCase()
    mismatches.push({
      cell: cellAddr,
      expected: anchor.synonyms.join(' / '),
      found: normalized === '' ? '(leer)' : found,
    })
  }
  return finalizeFinding(mismatches, config)
}

/** Validate the INPUT sheet's eight rate-card labels (B22-B29). Presence-only
 * (see module header) — a missing INPUT sheet counts every label as blank. */
export function validateG60InputRates(
  wb: G60Workbook,
  config: G60StructureGuardConfig = G60_STRUCTURE_GUARD_CONFIG,
): G60StructureFinding {
  const input = wb.sheet('INPUT')
  const mismatches: G60StructureMismatch[] = []
  for (const row of INPUT_RATE_LABEL_ROWS) {
    const cellAddr = `B${row}`
    const found = textOf(input ? input(cellAddr) : null)
    if (found.trim() === '') {
      mismatches.push({
        cell: cellAddr,
        expected: 'Bezeichner der Master-Rate (Gemeinkosten-/Gewinn-Satz, direkt-/fremdvergeben)',
        found: '(leer)',
      })
    }
  }
  return finalizeFinding(mismatches, config)
}

// ── Orchestrator ──────────────────────────────────────────────────────────────

export interface G60StructureReport {
  input: G60StructureFinding
  /** Per-tab findings for tabs that stayed usable (ok or soft-mismatched). */
  tabs: Record<string, G60StructureFinding>
  /** Tabs whose header anchors hit the hard threshold — excluded from the parse result. */
  excludedTabs: Record<string, G60StructureFinding>
}

/** Run the header-anchor check for a set of named tabs plus the INPUT
 * rate-card check. Pure; does not mutate the workbook or any parse result. */
export function validateG60Structure(
  wb: G60Workbook,
  tabNames: readonly string[],
  headerRow: number = G60_HEADER_ROW,
  config: G60StructureGuardConfig = G60_STRUCTURE_GUARD_CONFIG,
): G60StructureReport {
  const input = validateG60InputRates(wb, config)
  const tabs: Record<string, G60StructureFinding> = {}
  const excludedTabs: Record<string, G60StructureFinding> = {}
  for (const name of tabNames) {
    const sheet = wb.sheet(name)
    const finding: G60StructureFinding = sheet
      ? validateG60TabHeader(sheet, headerRow, config)
      : { ok: false, mismatches: [{ cell: name, expected: 'Reiter im Workbook vorhanden', found: '(fehlt)' }], confidence: 0 }
    if (finding.confidence === 0) excludedTabs[name] = finding
    else tabs[name] = finding
  }
  return { input, tabs, excludedTabs }
}

// ── Guarded parse ─────────────────────────────────────────────────────────────

export interface G60GuardedParseResult extends G60ParseResult {
  inputStructure: G60StructureFinding
  /** Findings for tabs that stayed in `.tabs` but are soft-mismatched (ok findings omitted). */
  tabStructure: Record<string, G60StructureFinding>
  excludedTabs: Record<string, G60StructureFinding>
  /**
   * KAR-894/P1.3 adversarial-review follow-up: the SAME columnOverrides this
   * function already computed (via locateG60ColumnAnchors) to re-extract a
   * relocated tab's G60TabAggregate — exposed here so a caller building the
   * full component rows for the same tab (currently only actions.ts's
   * ingest path, via g60ExtractFullTab/extractFullTab) applies the identical
   * relocation instead of silently re-deriving it (or, worse, omitting it —
   * the exact bug this field exists to close). Present for every
   * non-excluded tab, `{}` when nothing was relocated (the overwhelming
   * majority case), so a caller can always do
   * `extractFullTab(sheet, guarded.columnOverridesByTab[name])` unconditionally.
   */
  columnOverridesByTab: Record<string, G60ColumnOverrides>
  /**
   * KAR-961/P4 (gate-audit.md B4): set whenever the INPUT rate-card is
   * hard-broken (`inputStructure.confidence === 0`) — undefined otherwise.
   * SGA/Profit are the ONLY two G60TabAggregate fields parser.ts's
   * extractTab computes FROM the rate-card (`rates.ovFK_d`/`ovMAT_d`/
   * `pfFK_d`/`pfMAT_d`/…, see that function) — every other row-41 MCOL
   * field (W/Y/Pers/Mach/AV/MfgTot/Scrap/HK/Sur/Sales/TC), Material/Labor/
   * Manufacturing/ScrapB, and every `steps[]` entry are plain cell reads,
   * structurally independent of the rate-card AT THE JS LAYER. Reusing the
   * P1 FacetDegradation taxonomy (../types.ts) here documents WHICH fields
   * are unreliable without withholding the ones that are not — see the
   * `.tabs` doc below for what actually happens to SGA/Profit in this case.
   *
   * PR #328 review fix (finding [9], verified PLAUSIBLE — documented rather
   * than "fixed", see rationale below): "structurally independent of the
   * rate-card" is only true of THIS module's own JS recomputation. TC/Sales/
   * HK/Sur are read via bridge.ts as native Excel-formula CACHED RESULTS
   * (read-only — see that module's header), and the real G60 template's own
   * in-sheet formulas for those fields plausibly reference the same
   * INPUT!C22-C29 rate-card cells this guard just found unreliable. Whether
   * that is actually the case for a given file is NOT determinable from a
   * cached cell value — this module (and ExcelJS) never sees the formula
   * AST, only its last-computed result — so no code fix closes this; it is
   * disclosed instead, in `rateDegradation.message` above, as an explicit
   * "verify manually" caveat. `rateDegradation` itself intentionally still
   * only claims what it can prove (SGA/Profit): widening its `facet`/fields
   * to silently also flag TC/Sales/HK/Sur would overclaim a certainty this
   * module does not have — worse than the current honest disclosure.
   */
  rateDegradation?: FacetDegradation
}

/** Build the extractTab columnOverrides for the anchors locateG60ColumnAnchors
 * classified 'relocated' (KAR-894/P1.3). Anchors that matched at their fix
 * coordinate or are missing entirely are left out — extractTab's own
 * `?? 'W'`-style defaults handle those. */
function columnOverridesFrom(locations: readonly G60ColumnLocation[]): G60ColumnOverrides {
  const overrides: G60ColumnOverrides = {}
  for (const location of locations) {
    if (location.status === 'relocated' && location.resolvedCol) {
      ;(overrides as Record<string, string>)[location.col] = location.resolvedCol
    }
  }
  return overrides
}

/** Merge locateG60ColumnAnchors' relocated anchors into an existing
 * G60StructureFinding (KAR-894/P1.3) — additive: a finding with no relocated
 * anchors passes through unchanged (identity, not a clone) so the common
 * case allocates nothing extra. A relocation degrades `ok`/`confidence` even
 * when every anchor is technically "found" (relocated ≠ mismatch, but it is
 * also not the clean fix-coordinate case) — `Math.min` so an existing softer
 * (missing-anchor) confidence is never accidentally raised back up. */
function mergeRelocationIntoFinding(
  finding: G60StructureFinding,
  locations: readonly G60ColumnLocation[],
  config: G60StructureGuardConfig,
): G60StructureFinding {
  const relocated = locations.filter((l) => l.status === 'relocated')
  if (relocated.length === 0) return finding
  return {
    ...finding,
    ok: false,
    confidence: Math.min(finding.confidence, relocatedConfidenceOf(config)),
    columnRelocations: relocated.map((l) => ({
      col: l.col,
      resolvedCol: l.resolvedCol as string,
      offset: l.offset,
      confidence: l.confidence,
    })),
  }
}

/**
 * Guarded wrapper around parseG60Workbook (parser.ts stays untouched — no
 * parser rewrite, KAR-888 scope). Behaviour:
 *  - intact template: identical `.tabs`/`.rates` to parseG60Workbook (regression).
 *  - a single tab with a hard header mismatch (>= hardMismatchThreshold
 *    anchors gone, e.g. a shifted row): that tab is dropped from `.tabs` —
 *    "nur die betroffene Sektion blockieren, nicht die ganze Datei"
 *    (Master-Prompt §8) — every other tab stays exactly as before.
 *  - INPUT rate-card labels hard-broken (>= hardMismatchThreshold of the
 *    eight B22-B29 labels blank): KAR-961/P4 (gate-audit.md B4) — the
 *    rate-card only feeds TWO fields per tab, SGA and Profit (parser.ts
 *    extractTab: `ax * rates.ovFK_d + w * rates.ovMAT_d` etc.) — every
 *    other rate-INDEPENDENT field (the 11 row-41 MCOL aggregates, Material/
 *    Labor/Manufacturing/ScrapB, every `steps[]` entry) is a plain cell
 *    read that does not touch `rates` at all. Every tab therefore now stays
 *    in `.tabs`; only its `SGA`/`Profit` are zeroed (never silently kept as
 *    numbers computed off rate cells that are no longer verifiably the
 *    rates) and a `rateDegradation` (FacetDegradation, ../types.ts) is
 *    attached to the guarded result so a consumer can tell "these two
 *    fields are unreliable" apart from "there is no data here at all" —
 *    replacing the pre-KAR-961 behaviour of dropping the ENTIRE file's
 *    detail view (`.tabs = {}`) for a rate-card problem that structurally
 *    affects only 2 of a tab's ~15 fields (`.rates`/`.card`/`.volumes`
 *    still stay populated exactly as before, unaffected either way).
 *  - KAR-894/P1.3: a tab whose header anchors are intact-but-shifted (one of
 *    the 5 anchored columns found ±1/±2 away, see locateG60ColumnAnchors) is
 *    re-extracted with that column substituted instead of being downgraded
 *    to a soft/hard mismatch — the persisted per-tab structure finding
 *    carries `columnRelocations` and confidence 0.8 so the relocation stays
 *    visible/reviewable without losing the (still reliably locatable) data.
 */
export function parseG60WorkbookGuarded(
  wb: G60Workbook,
  config: G60StructureGuardConfig = G60_STRUCTURE_GUARD_CONFIG,
): G60GuardedParseResult {
  const raw = parseG60Workbook(wb)
  const report = validateG60Structure(wb, detailTabs(wb), G60_HEADER_ROW, config)

  // KAR-961/P4 (gate-audit.md B4): a hard-broken rate-card no longer drops
  // every tab — it degrades only the two rate-DEPENDENT fields (SGA/Profit)
  // on every surviving tab, see doc above. `rateDegradation` is attached to
  // the guarded result (not per-tab — the cause and remedy are file-level,
  // identical for every tab) whenever this applies.
  const rateCardBroken = report.input.confidence === 0
  const rateDegradation: FacetDegradation | undefined = rateCardBroken
    ? {
        facet: 'g60_rates',
        reason: 'PARSE_FAILED',
        sheet: 'INPUT',
        // PR #328 review fix (finding [3]/[5]/[9]): "auf 0 degradiert" was
        // wrong even before this fix — SGA/Profit are now withheld (null),
        // not zeroed, see G60TabAggregate.SGA doc comment. Also (finding
        // [9], verified PLAUSIBLE, documented rather than "fixed" — see
        // module header note below) discloses that TC/Sales/HK/Sur are
        // native Excel-formula cached results this module cannot verify
        // independently of the rate card.
        message:
          'INPUT-Ratenkarte (B22-B29) hard-broken (Konfidenz 0) — SGA/Profit je Kostenreiter als fehlend (null) markiert, nicht auf 0 gesetzt. Alle übrigen Kostenreiter-Felder (Material/Labor/Manufacturing/ScrapB/Kostenreiter-Zeilen 15-38) bleiben unverändert extrahiert. Hinweis: TC/Sales/HK/Sur (Zeile 41, Spalten DG/DJ/BF/DI) sind native Excel-Formelergebnisse, die selbst auf derselben defekten Ratenkarte basieren KÖNNEN — das ist aus den gecachten Zellwerten nicht prüfbar und daher nicht Teil dieser Degradation; bei einer hard-broken Ratenkarte manuell gegenprüfen.',
      }
    : undefined

  const tabs: Record<string, G60TabAggregate> = {}
  const tabFindings: Record<string, G60StructureFinding> = {}
  const columnOverridesByTab: Record<string, G60ColumnOverrides> = {}
  for (const [name, aggregate] of Object.entries(raw.tabs)) {
    if (name in report.excludedTabs) continue
    const sheet = wb.sheet(name)
    const baseFinding = report.tabs[name] ?? OK_FINDING
    if (!sheet) {
      // PR #328 review fix (finding [3]/[5]): NULL, not 0 — see
      // G60TabAggregate.SGA doc comment (§23-principle, "fehlend sichtbar
      // fehlend"). A persisted/rendered 0 here would be indistinguishable
      // from a genuine zero SGA/Profit.
      tabs[name] = rateCardBroken ? { ...aggregate, SGA: null, Profit: null } : aggregate
      tabFindings[name] = baseFinding
      columnOverridesByTab[name] = {}
      continue
    }
    const locations = locateG60ColumnAnchors(sheet, G60_HEADER_ROW, config)
    const overrides = columnOverridesFrom(locations)
    const relocatedCount = locations.filter((l) => l.status === 'relocated').length
    const extracted = relocatedCount > 0 ? extractTab(sheet, raw.rates, name, overrides) : aggregate
    // extractTab always recomputes SGA/Profit FROM raw.rates (see that
    // function) — when the rate-card is hard-broken those rates are not
    // verifiably correct, so a relocated tab's freshly-recomputed SGA/Profit
    // get withheld (null) exactly like an already-extracted tab's do, one
    // line below. PR #328 review fix (finding [3]/[5]): NULL, not 0.
    tabs[name] = rateCardBroken ? { ...extracted, SGA: null, Profit: null } : extracted
    tabFindings[name] = mergeRelocationIntoFinding(baseFinding, locations, config)
    columnOverridesByTab[name] = overrides
  }

  return {
    ...raw,
    tabs,
    inputStructure: report.input,
    tabStructure: Object.fromEntries(Object.entries(tabFindings).filter(([, f]) => !f.ok)),
    excludedTabs: report.excludedTabs,
    columnOverridesByTab,
    rateDegradation,
  }
}

// ── Persistence bridge (PlausibilityIssue) ────────────────────────────────────
//
// Reuses the same PlausibilityIssue shape as reconciliation.ts/rule-engine.ts,
// namespaced g60_structure_* — no schema change, no new UI component (the
// existing "Section"/"Pill" plausibility rendering pattern is reused, see
// qaf-g60-detail.tsx). issue_type-suffix _blocked is deliberately reused from
// the established "blockiert" pattern (KAR-889) so the UI's existing
// suffix-based label lookup (plausibilityBadge in appendix-run.ts +
// plausibilityKindClass) renders these as "Blockiert" without any UI code
// change.

function mismatchSummary(mismatches: readonly G60StructureMismatch[]): string {
  const first = mismatches[0]
  if (!first) return ''
  return `erwartete ${first.expected} in Zelle ${first.cell}, gefunden ${first.found}`
}

/** EN counterpart of mismatchSummary (KAR-906/P3.2) — cell coordinates and
 * expected/found label text are not translated (they are literal Excel
 * content, not prose). */
function mismatchSummaryEn(mismatches: readonly G60StructureMismatch[]): string {
  const first = mismatches[0]
  if (!first) return ''
  return `expected ${first.expected} in cell ${first.cell}, found ${first.found}`
}

export function g60TabExclusionToPlausibilityIssue(tabName: string, finding: G60StructureFinding, side: 'ALT' | 'NEU'): PlausibilityIssue {
  return {
    type: 'g60_structure_tab_excluded',
    severity: 'kritisch',
    step: `${side} · ${tabName}`,
    explanation: `G60-Struktur weicht ab: ${mismatchSummary(finding.mismatches)} — Werte dieser Sektion (Reiter ${tabName}) nicht übernommen.`,
    explanationEn: `G60 structure deviates: ${mismatchSummaryEn(finding.mismatches)} — values in this section (tab ${tabName}) were not adopted.`,
  }
}

export function g60InputStructureToPlausibilityIssue(finding: G60StructureFinding, side: 'ALT' | 'NEU'): PlausibilityIssue | null {
  if (finding.ok) return null
  if (finding.confidence === 0) {
    // PR #328 review fix (finding [4]): pre-KAR-961, a hard-broken rate card
    // dropped every tab wholesale, so "Werte ... nicht übernommen" was
    // accurate for the WHOLE cost-tab section. Since KAR-961/P4
    // (parseG60WorkbookGuarded, gate-audit.md B4) every tab stays in
    // `.tabs` — only its SGA/Profit are withheld (null, see
    // G60TabAggregate.SGA doc comment) — so the message now names exactly
    // those two fields instead of implying the whole section is gone.
    return {
      type: 'g60_structure_input_rates_blocked',
      severity: 'kritisch',
      step: side,
      explanation: `G60-Struktur weicht ab: ${mismatchSummary(finding.mismatches)} — SG&A/Gewinn (SGA/Profit) je Kostenreiter nicht übernommen (als fehlend markiert, nicht als 0), übrige Kostenreiter-Felder bleiben erhalten (${finding.mismatches.length} von ${INPUT_RATE_LABEL_ROWS.length} INPUT-Bezeichnern fehlen).`,
      explanationEn: `G60 structure deviates: ${mismatchSummaryEn(finding.mismatches)} — SG&A/profit were not adopted per cost tab (marked as missing, not as 0); the remaining cost-tab fields stay intact (${finding.mismatches.length} of ${INPUT_RATE_LABEL_ROWS.length} INPUT labels are missing).`,
    }
  }
  return {
    type: 'g60_structure_input_rates_soft_mismatch',
    severity: 'pruefen',
    step: side,
    explanation: `G60-Struktur weicht leicht ab: ${mismatchSummary(finding.mismatches)} — Wert wird trotzdem übernommen, aber als unsicher markiert.`,
    explanationEn: `G60 structure deviates slightly: ${mismatchSummaryEn(finding.mismatches)} — the value was adopted anyway, but flagged as uncertain.`,
  }
}

/** Soft (missing-anchor) mismatch issue. KAR-894/P1.3: guarded on
 * `mismatches.length === 0` in addition to `ok` — a relocation-only finding
 * (KAR-894) also has `ok: false` but an EMPTY mismatches array (a relocated
 * anchor is not a mismatch, see validateG60TabHeader), which must not
 * produce an empty/misleading "weicht leicht ab: —" issue here; it gets its
 * own dedicated g60_column_relocated issue instead (below). This guard is a
 * no-op for every pre-P1.3 finding — `!ok` never occurred with an empty
 * mismatches array before this change. */
export function g60TabSoftMismatchToPlausibilityIssue(tabName: string, finding: G60StructureFinding, side: 'ALT' | 'NEU'): PlausibilityIssue | null {
  if (finding.ok || finding.mismatches.length === 0) return null
  return {
    type: 'g60_structure_tab_soft_mismatch',
    severity: 'pruefen' as PlausibilitySeverity,
    step: `${side} · ${tabName}`,
    explanation: `G60-Struktur weicht leicht ab: ${mismatchSummary(finding.mismatches)} — Wert wird trotzdem übernommen, aber als unsicher markiert.`,
    explanationEn: `G60 structure deviates slightly: ${mismatchSummaryEn(finding.mismatches)} — the value was adopted anyway, but flagged as uncertain.`,
  }
}

/** KAR-894/P1.3: one anchor-relocation issue per tab, summarizing every
 * column locateG60ColumnAnchors resolved to a neighbor. `pruefen` severity —
 * same posture as the existing soft-mismatch issue (value kept, flagged for
 * review), distinct `type` so the UI/export can tell "shifted but resolved"
 * apart from "still not found anywhere". */
export function g60TabColumnRelocationToPlausibilityIssue(
  tabName: string,
  finding: G60StructureFinding,
  side: 'ALT' | 'NEU',
): PlausibilityIssue | null {
  const relocations = finding.columnRelocations
  if (!relocations || relocations.length === 0) return null
  const summary = relocations
    .map((r) => `${r.col}→${r.resolvedCol} (Offset ${r.offset > 0 ? '+' : ''}${r.offset})`)
    .join(', ')
  return {
    type: 'g60_column_relocated',
    severity: 'pruefen',
    step: `${side} · ${tabName}`,
    explanation: `G60-Spalte(n) per Anker automatisch neu zugeordnet: ${summary} — Werte wurden aus der neuen Position übernommen (Confidence ${relocations[0].confidence}).`,
    explanationEn: `G60 column(s) automatically re-assigned via anchor: ${summary} — values were adopted from the new position (confidence ${relocations[0].confidence}).`,
  }
}

export interface G60StructureIssueInput {
  side: 'ALT' | 'NEU'
  inputStructure: G60StructureFinding
  /** Findings for tabs that stayed usable but are soft-mismatched and/or
   * column-relocated (ok findings are skipped). */
  tabStructure: Record<string, G60StructureFinding>
  excludedTabs: Record<string, G60StructureFinding>
}

/** Pure: turn one file's persisted structure findings into the same
 * PlausibilityIssue[] shape the rest of the engine already renders/exports. */
export function buildG60StructureIssues(input: G60StructureIssueInput): PlausibilityIssue[] {
  const out: PlausibilityIssue[] = []
  const inputIssue = g60InputStructureToPlausibilityIssue(input.inputStructure, input.side)
  if (inputIssue) out.push(inputIssue)
  for (const [name, finding] of Object.entries(input.excludedTabs)) {
    out.push(g60TabExclusionToPlausibilityIssue(name, finding, input.side))
  }
  for (const [name, finding] of Object.entries(input.tabStructure)) {
    const softIssue = g60TabSoftMismatchToPlausibilityIssue(name, finding, input.side)
    if (softIssue) out.push(softIssue)
    const relocationIssue = g60TabColumnRelocationToPlausibilityIssue(name, finding, input.side)
    if (relocationIssue) out.push(relocationIssue)
  }
  return out
}
