// G60 structure guard tests (KAR-888 / P0.3). Synthetic fixtures only — no
// real BMW G60 file exists in this repo (backlog constraint); anchor // allow-customer-string
// COORDINATES/CONFIG (G60_HEADER_ROW=12, synonym list) were verified against
// two real files on the operator's server 2026-07-09 (header TEXTS read // allow-customer-string
// only, no values, files never copied here — see structure-guard.ts module
// header). Every mutation below starts from the shared `makeWorkbook`
// fixture (fixtures.ts), which carries the label anchors this guard checks
// (row-12 tab headers, INPUT B22-B29 rate-card labels) so the "intact
// template" case is a true regression check against the same fixture the
// rest of the G60 suite uses.

import { describe, it, expect } from 'vitest'
import { sheetFromCells, workbookFromSheets, detectG60, extractFullTab } from '../parser'
import {
  validateG60TabHeader,
  validateG60InputRates,
  validateG60Structure,
  parseG60WorkbookGuarded,
  buildG60StructureIssues,
  locateG60ColumnAnchors,
  g60TabColumnRelocationToPlausibilityIssue,
  G60_STRUCTURE_GUARD_CONFIG,
  G60_HEADER_ROW,
  DEFAULT_RELOCATED_CONFIDENCE,
} from '../structure-guard'
import { recomputeTab } from '../scenario'
import { makeWorkbook, tabCells, RATES_CELLS } from './fixtures'

/** Shift the AT ("Machinensatz") anchor from tabCells() one column to the
 * right (AU) — deletes AT12, sets AU12 to the anchor text, and moves the
 * AT41/AT15 VALUES to AU41/AU15 too (real file semantics: if a column is
 * inserted upstream, both the header AND the data below it shift together).
 * AT41/AT15 are left holding decoy values so a test can prove the guard
 * actually reads from the resolved column rather than leaking the old one. */
function shiftAtColumnToAu(cells: Record<string, unknown>): Record<string, unknown> {
  const shifted: Record<string, unknown> = { ...cells }
  delete shifted.AT12
  shifted.AU12 = 'Maschinensatz' // allow-customer-string
  shifted.AT41 = 999 // decoy — must NOT leak into the result
  shifted.AU41 = 3
  shifted.AT15 = 999 // decoy — must NOT leak into the result
  shifted.AU15 = 100
  return shifted
}

const HEADER_ROW = G60_HEADER_ROW

describe('validateG60TabHeader — intact template', () => {
  it('reports ok/confidence 1 when every header anchor matches', () => {
    const wb = makeWorkbook(100, 100)
    const finding = validateG60TabHeader(wb.sheet('2_2')!, HEADER_ROW)
    expect(finding).toEqual({ ok: true, mismatches: [], confidence: 1 })
  })
})

describe('validateG60TabHeader — leichte Abweichung (einzelner Anker)', () => {
  it('downgrades confidence but stays "soft" when exactly one header anchor is wrong', () => {
    const cells = tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 })
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells({ ...cells, AT12: 'Sonstiges' }), // renamed, single anchor
    })
    const finding = validateG60TabHeader(wb.sheet('2_2')!, HEADER_ROW)
    expect(finding.ok).toBe(false)
    expect(finding.confidence).toBe(G60_STRUCTURE_GUARD_CONFIG.softConfidence)
    expect(finding.mismatches).toEqual([{ cell: 'AT12', expected: expect.stringContaining('maschine'), found: 'Sonstiges' }])
  })
})

describe('validateG60TabHeader — Y-Spalte tolerant gegen den realen Template-Tippfehler', () => {
  it('matches on the "raw material" substring regardless of surcharge/sucharge spelling', () => {
    // KAR-888 PR review finding: real G60 files carry the header text with a
    // genuine template typo ("sucharge" instead of "surcharge") — the guard
    // must not treat that as a structural mismatch. Uses a representative
    // (not verbatim) English phrasing, mirroring the real template's typo
    // pattern without reproducing its exact internal wording.
    const cells = tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 })
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells({ ...cells, Y12: ' Raw material sucharge\n RoZ0 [AW]' }),
    })
    const finding = validateG60TabHeader(wb.sheet('2_2')!, HEADER_ROW)
    expect(finding).toEqual({ ok: true, mismatches: [], confidence: 1 })
  })

  it('still matches a hypothetical corrected "surcharge" spelling', () => {
    const cells = tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 })
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells({ ...cells, Y12: ' Raw material surcharge\n RoZ0 [AW]' }),
    })
    const finding = validateG60TabHeader(wb.sheet('2_2')!, HEADER_ROW)
    expect(finding).toEqual({ ok: true, mismatches: [], confidence: 1 })
  })
})

describe('validateG60TabHeader — harte Abweichung (verschobene Zeile)', () => {
  it('escalates to confidence 0 when multiple header anchors are gone at once', () => {
    const cells = tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 })
    // Simulate a shifted row: the header row itself is blank (as if the whole
    // block moved down and row 12 now holds what used to be a different row).
    const shifted = { ...cells, W12: undefined, Y12: undefined, AO12: undefined, AQ12: undefined, AT12: undefined }
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells(shifted),
    })
    const finding = validateG60TabHeader(wb.sheet('2_2')!, HEADER_ROW)
    expect(finding.ok).toBe(false)
    expect(finding.confidence).toBe(0)
    expect(finding.mismatches).toHaveLength(5)
    expect(finding.mismatches.every((m) => m.found === '(leer)')).toBe(true)
  })
})

describe('validateG60InputRates — intact template', () => {
  it('reports ok/confidence 1 when all eight rate labels are present', () => {
    const wb = makeWorkbook(100, 100)
    expect(validateG60InputRates(wb)).toEqual({ ok: true, mismatches: [], confidence: 1 })
  })
})

describe('validateG60InputRates — leichte Abweichung', () => {
  it('downgrades to soft when exactly one of the eight rate labels is blank', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells({ ...RATES_CELLS, B22: undefined }),
      '2_2': sheetFromCells(tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 })),
    })
    const finding = validateG60InputRates(wb)
    expect(finding.ok).toBe(false)
    expect(finding.confidence).toBe(G60_STRUCTURE_GUARD_CONFIG.softConfidence)
    expect(finding.mismatches).toEqual([{ cell: 'B22', expected: expect.any(String), found: '(leer)' }])
  })
})

describe('validateG60InputRates — harte Abweichung (INPUT-Raten-Labels weg)', () => {
  it('escalates to confidence 0 when several rate labels are blank', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells({ ...RATES_CELLS, B22: undefined, B23: undefined, B24: undefined }),
      '2_2': sheetFromCells(tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 })),
    })
    const finding = validateG60InputRates(wb)
    expect(finding.ok).toBe(false)
    expect(finding.confidence).toBe(0)
    expect(finding.mismatches).toHaveLength(3)
  })

  it('treats a missing INPUT sheet as every rate label blank (hard)', () => {
    const wb = workbookFromSheets({ '2_2': sheetFromCells(tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 })) })
    const finding = validateG60InputRates(wb)
    expect(finding.confidence).toBe(0)
    expect(finding.mismatches).toHaveLength(8)
  })
})

describe('validateG60Structure — orchestrator', () => {
  it('intact template: no mismatches anywhere, no excluded tabs', () => {
    const wb = makeWorkbook(100, 100)
    const report = validateG60Structure(wb, ['2_2', '10_2'], HEADER_ROW)
    expect(report.input).toEqual({ ok: true, mismatches: [], confidence: 1 })
    expect(report.excludedTabs).toEqual({})
    expect(Object.keys(report.tabs)).toEqual(['2_2', '10_2'])
    expect(report.tabs['2_2']).toEqual({ ok: true, mismatches: [], confidence: 1 })
  })

  it('one shifted tab is excluded, the other tab is unaffected', () => {
    const shiftedCells = { ...tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 }) }
    delete (shiftedCells as Record<string, unknown>).W12
    delete (shiftedCells as Record<string, unknown>).Y12
    delete (shiftedCells as Record<string, unknown>).AO12
    delete (shiftedCells as Record<string, unknown>).AQ12
    delete (shiftedCells as Record<string, unknown>).AT12
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells(shiftedCells),
      '10_2': sheetFromCells(tabCells({ part: 'P-B', sales: 30, w: 4, at15: 100 })),
    })
    const report = validateG60Structure(wb, ['2_2', '10_2'], HEADER_ROW)
    expect(Object.keys(report.excludedTabs)).toEqual(['2_2'])
    expect(Object.keys(report.tabs)).toEqual(['10_2'])
    expect(report.tabs['10_2']).toEqual({ ok: true, mismatches: [], confidence: 1 })
  })

  it('a named tab missing from the workbook is recorded as excluded, not thrown', () => {
    const wb = makeWorkbook(100, 100)
    const report = validateG60Structure(wb, ['2_2', 'ghost_2'], HEADER_ROW)
    expect(report.excludedTabs.ghost_2).toBeDefined()
    expect(report.excludedTabs.ghost_2.confidence).toBe(0)
  })
})

describe('parseG60WorkbookGuarded — regression: intact template behaves exactly like parseG60Workbook', () => {
  it('returns identical tab aggregates for the unmutated fixture', () => {
    const wb = makeWorkbook(100, 100)
    const guarded = parseG60WorkbookGuarded(wb)
    expect(Object.keys(guarded.tabs)).toEqual(['2_2', '10_2'])
    expect(guarded.tabs['2_2'].Sales).toBe(20)
    expect(guarded.tabs['2_2'].SGA).toBeCloseTo(3.1)
    expect(guarded.inputStructure.ok).toBe(true)
    expect(guarded.excludedTabs).toEqual({})
    expect(guarded.rates).toEqual({
      ovFK_d: 0.1,
      ovMAT_d: 0.2,
      pfFK_d: 0.05,
      pfMAT_d: 0.1,
      ovFK_s: 0.15,
      ovMAT_s: 0.25,
      pfFK_s: 0.08,
      pfMAT_s: 0.12,
    })
  })
})

describe('parseG60WorkbookGuarded — harte Abweichung: fehlende INPUT-Labels', () => {
  // KAR-961/P4 (gate-audit.md B4): a hard-broken rate-card used to drop
  // EVERY tab wholesale (`.tabs = {}`) even though the rate-card only feeds
  // 2 of a tab's ~15 fields (SGA/Profit, see structure-guard.ts module
  // header + parser.ts extractTab). It now degrades ONLY those two fields
  // per tab and attaches a file-level `rateDegradation` — every other
  // rate-independent field (Sales/DJ41 here) stays exactly as extractTab
  // would compute it on an intact rate-card.
  //
  // PR #328 review fix (finding [3]/[5]): SGA/Profit are set to `null`, not
  // `0` — a persisted/rendered 0 would be indistinguishable from a genuine
  // zero SG&A/profit (§23-principle, "fehlend sichtbar fehlend"). This test
  // used to assert `.toBe(0)`; it now asserts `.toBeNull()`.
  it('nulls (never zeroes) SGA/Profit on every tab, keeps every rate-independent field, and attaches rateDegradation', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells({ ...RATES_CELLS, B22: undefined, B23: undefined, B24: undefined, B25: undefined }),
      '2_2': sheetFromCells(tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 })),
      '10_2': sheetFromCells(tabCells({ part: 'P-B', sales: 30, w: 4, at15: 100 })),
    })
    const guarded = parseG60WorkbookGuarded(wb)
    expect(Object.keys(guarded.tabs)).toEqual(['2_2', '10_2'])
    for (const name of ['2_2', '10_2']) {
      expect(guarded.tabs[name].SGA).toBeNull()
      expect(guarded.tabs[name].Profit).toBeNull()
    }
    // Rate-independent fields: unchanged from an intact-rate-card extraction.
    expect(guarded.tabs['2_2'].Sales).toBe(20)
    expect(guarded.tabs['10_2'].Sales).toBe(30)
    expect(guarded.tabs['2_2'].Material).toBe(guarded.tabs['2_2'].W + guarded.tabs['2_2'].Y)
    expect(guarded.tabs['2_2'].steps.length).toBeGreaterThan(0)

    expect(guarded.inputStructure.ok).toBe(false)
    expect(guarded.inputStructure.confidence).toBe(0)
    expect(guarded.rateDegradation).toEqual({
      facet: 'g60_rates',
      reason: 'PARSE_FAILED',
      sheet: 'INPUT',
      message: expect.stringContaining('SGA/Profit'),
    })
    // PR #328 review fix (finding [9]): the message discloses the
    // TC/Sales/HK/Sur caveat rather than silently implying only SGA/Profit
    // could possibly be affected by the broken rate card.
    expect(guarded.rateDegradation?.message).toMatch(/TC\/Sales\/HK\/Sur/)
  })

  it('does not set rateDegradation when the rate-card is intact (regression anchor)', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells(tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 })),
    })
    const guarded = parseG60WorkbookGuarded(wb)
    expect(guarded.rateDegradation).toBeUndefined()
    expect(guarded.tabs['2_2'].SGA).not.toBeNull()
    expect(guarded.tabs['2_2'].SGA).not.toBe(0)
  })
})

describe('parseG60WorkbookGuarded — harte Abweichung: verschobene Zeile in einem Reiter', () => {
  it('excludes only the affected tab, keeps the rest usable (Master-Prompt §8)', () => {
    const shiftedCells = { ...tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 }) }
    delete (shiftedCells as Record<string, unknown>).W12
    delete (shiftedCells as Record<string, unknown>).Y12
    delete (shiftedCells as Record<string, unknown>).AO12
    delete (shiftedCells as Record<string, unknown>).AQ12
    delete (shiftedCells as Record<string, unknown>).AT12
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells(shiftedCells),
      '10_2': sheetFromCells(tabCells({ part: 'P-B', sales: 30, w: 4, at15: 100 })),
    })
    const guarded = parseG60WorkbookGuarded(wb)
    expect(Object.keys(guarded.tabs)).toEqual(['10_2'])
    expect(guarded.excludedTabs['2_2']).toBeDefined()
    expect(guarded.excludedTabs['2_2'].confidence).toBe(0)
  })
})

describe('buildG60StructureIssues', () => {
  it('emits nothing when everything is clean', () => {
    const issues = buildG60StructureIssues({
      side: 'ALT',
      inputStructure: { ok: true, mismatches: [], confidence: 1 },
      tabStructure: {},
      excludedTabs: {},
    })
    expect(issues).toEqual([])
  })

  it('emits a "pruefen" issue for a soft tab mismatch, keeping the value', () => {
    const issues = buildG60StructureIssues({
      side: 'NEU',
      inputStructure: { ok: true, mismatches: [], confidence: 1 },
      tabStructure: {
        '2_2': { ok: false, mismatches: [{ cell: 'AT12', expected: 'Maschine', found: 'Sonstiges' }], confidence: 0.6 },
      },
      excludedTabs: {},
    })
    expect(issues).toHaveLength(1)
    expect(issues[0]).toMatchObject({ type: 'g60_structure_tab_soft_mismatch', severity: 'pruefen' })
    expect(issues[0].explanation).toContain('AT12')
  })

  it('emits a "kritisch" issue naming cell/expected/found for an excluded tab', () => {
    const issues = buildG60StructureIssues({
      side: 'ALT',
      inputStructure: { ok: true, mismatches: [], confidence: 1 },
      tabStructure: {},
      excludedTabs: {
        '2_2': {
          ok: false,
          confidence: 0,
          mismatches: [
            { cell: 'W12', expected: 'Material', found: '(leer)' },
            { cell: 'Y12', expected: 'Materialzuschlag', found: '(leer)' },
          ],
        },
      },
    })
    expect(issues).toHaveLength(1)
    expect(issues[0].type).toBe('g60_structure_tab_excluded')
    expect(issues[0].severity).toBe('kritisch')
    expect(issues[0].explanation).toContain('W12')
    expect(issues[0].explanation).toContain('nicht übernommen')
  })

  it('emits a "kritisch" issue for hard-blocked INPUT rates', () => {
    const issues = buildG60StructureIssues({
      side: 'NEU',
      inputStructure: {
        ok: false,
        confidence: 0,
        mismatches: [
          { cell: 'B22', expected: 'Bezeichner der Master-Rate', found: '(leer)' },
          { cell: 'B23', expected: 'Bezeichner der Master-Rate', found: '(leer)' },
        ],
      },
      tabStructure: {},
      excludedTabs: {},
    })
    expect(issues).toHaveLength(1)
    expect(issues[0].type).toBe('g60_structure_input_rates_blocked')
    expect(issues[0].severity).toBe('kritisch')
  })
})

// KAR-906/P3.2: every g60_structure_* / g60_column_relocated issue carries an
// English counterpart — cell coordinates/labels are literal Excel content
// and stay unchanged in both languages, only the surrounding prose differs.
describe('buildG60StructureIssues — bilingual (KAR-906)', () => {
  it('g60_structure_tab_soft_mismatch carries a distinct English explanation', () => {
    const issues = buildG60StructureIssues({
      side: 'NEU',
      inputStructure: { ok: true, mismatches: [], confidence: 1 },
      tabStructure: {
        '2_2': { ok: false, mismatches: [{ cell: 'AT12', expected: 'Maschine', found: 'Sonstiges' }], confidence: 0.6 },
      },
      excludedTabs: {},
    })
    expect(issues[0].explanationEn).toBeTruthy()
    expect(issues[0].explanationEn).not.toBe(issues[0].explanation)
    expect(issues[0].explanationEn).toContain('AT12')
    expect(issues[0].explanationEn).toMatch(/deviates slightly/)
  })

  it('g60_structure_tab_excluded carries a distinct English explanation naming the same cell', () => {
    const issues = buildG60StructureIssues({
      side: 'ALT',
      inputStructure: { ok: true, mismatches: [], confidence: 1 },
      tabStructure: {},
      excludedTabs: {
        '2_2': {
          ok: false,
          confidence: 0,
          mismatches: [{ cell: 'W12', expected: 'Material', found: '(leer)' }],
        },
      },
    })
    expect(issues[0].explanationEn).toContain('W12')
    expect(issues[0].explanationEn).toMatch(/were not adopted/)
  })

  it('g60_structure_input_rates_blocked carries a distinct English explanation', () => {
    const issues = buildG60StructureIssues({
      side: 'NEU',
      inputStructure: {
        ok: false,
        confidence: 0,
        mismatches: [{ cell: 'B22', expected: 'Bezeichner der Master-Rate', found: '(leer)' }],
      },
      tabStructure: {},
      excludedTabs: {},
    })
    expect(issues[0].explanationEn).toContain('B22')
    // PR #328 review fix (finding [4]): the message now names SG&A/profit
    // specifically (withheld per cost tab) rather than implying the whole
    // INPUT-master-rates section was dropped — see
    // g60InputStructureToPlausibilityIssue's updated doc comment.
    expect(issues[0].explanationEn).toMatch(/SG&A\/profit/)
    expect(issues[0].explanationEn).toMatch(/not adopted/)
  })
})

describe('detectG60 — Haertung: INPUT-Sheet ohne jede Raten-Bezeichnung', () => {
  it('rejects a workbook that superficially matches (INPUT + cost tabs) but carries none of the rate-card labels', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells({}), // no B22..B29 labels at all
      '10_2': sheetFromCells({}),
    })
    expect(detectG60(wb)).toBe(false)
  })

  it('still accepts a workbook with at least one rate-card label present', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells({ B22: 'Gemeinkosten FK direktvergeben' }),
      '10_2': sheetFromCells({}),
    })
    expect(detectG60(wb)).toBe(true)
  })
})

// P1.5/KAR-896: G60StructureGuardConfig is now an explicit parameter on every
// function in this module (generalizing the KAR-889 ruleEngineConfig
// pattern) — a caller-supplied config must actually change the outcome, and
// omitting it must stay byte-identical to the G60_STRUCTURE_GUARD_CONFIG
// default (already covered by every other describe block above).
describe('G60StructureGuardConfig injection (KAR-896)', () => {
  it('a single-anchor mismatch stays hard-excluded with a stricter (threshold=1) config', () => {
    const cells = tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 })
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells({ ...cells, AT12: 'Sonstiges' }), // single anchor wrong
    })
    const strict = { softConfidence: 0.6, hardMismatchThreshold: 1 }
    const finding = validateG60TabHeader(wb.sheet('2_2')!, HEADER_ROW, strict)
    expect(finding.confidence).toBe(0) // would be 0.6 (soft) under the module default
  })

  it('validateG60InputRates honours a custom hardMismatchThreshold', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells({ ...RATES_CELLS, B22: undefined }), // exactly 1 missing label
      '2_2': sheetFromCells({}),
    })
    const strict = { softConfidence: 0.6, hardMismatchThreshold: 1 }
    expect(validateG60InputRates(wb, strict).confidence).toBe(0)
    expect(validateG60InputRates(wb, G60_STRUCTURE_GUARD_CONFIG).confidence).toBe(G60_STRUCTURE_GUARD_CONFIG.softConfidence)
  })

  it('validateG60Structure threads a custom config into both input + per-tab checks', () => {
    const cells = tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 })
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells({ ...cells, AT12: 'Sonstiges' }),
    })
    const strict = { softConfidence: 0.6, hardMismatchThreshold: 1 }
    const report = validateG60Structure(wb, ['2_2'], HEADER_ROW, strict)
    expect(Object.keys(report.excludedTabs)).toEqual(['2_2'])
  })

  it('parseG60WorkbookGuarded threads a custom config through and excludes the affected tab', () => {
    const cells = tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 })
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells({ ...cells, AT12: 'Sonstiges' }),
      '10_2': sheetFromCells(tabCells({ part: 'P-B', sales: 30, w: 4, at15: 100 })),
    })
    const strict = { softConfidence: 0.6, hardMismatchThreshold: 1 }
    const guarded = parseG60WorkbookGuarded(wb, strict)
    expect(Object.keys(guarded.tabs)).toEqual(['10_2'])
    expect(guarded.excludedTabs['2_2']).toBeDefined()
  })

  it('omitting the config parameter stays byte-identical to the module default (regression)', () => {
    const wb = makeWorkbook(100, 100)
    const withDefault = parseG60WorkbookGuarded(wb)
    const withExplicitDefault = parseG60WorkbookGuarded(wb, G60_STRUCTURE_GUARD_CONFIG)
    expect(withDefault).toEqual(withExplicitDefault)
  })
})

// ── Label-based column localization (KAR-894/P1.3) ────────────────────────────

describe('locateG60ColumnAnchors', () => {
  it('intact template: all 5 anchors matched at offset 0, confidence 1', () => {
    const wb = makeWorkbook(100, 100)
    const locations = locateG60ColumnAnchors(wb.sheet('2_2')!)
    expect(locations).toEqual([
      { col: 'W', status: 'matched', resolvedCol: 'W', offset: 0, confidence: 1 },
      { col: 'Y', status: 'matched', resolvedCol: 'Y', offset: 0, confidence: 1 },
      { col: 'AO', status: 'matched', resolvedCol: 'AO', offset: 0, confidence: 1 },
      { col: 'AQ', status: 'matched', resolvedCol: 'AQ', offset: 0, confidence: 1 },
      { col: 'AT', status: 'matched', resolvedCol: 'AT', offset: 0, confidence: 1 },
    ])
  })

  it('relocated: anchor text found one column over (offset +1) resolves with the default confidence 0.8', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells(shiftAtColumnToAu(tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 }))),
    })
    const locations = locateG60ColumnAnchors(wb.sheet('2_2')!)
    expect(locations.find((l) => l.col === 'AT')).toEqual({
      col: 'AT',
      status: 'relocated',
      resolvedCol: 'AU',
      offset: 1,
      confidence: DEFAULT_RELOCATED_CONFIDENCE,
    })
    // The other 4 anchors are unaffected by the AT shift.
    expect(locations.filter((l) => l.col !== 'AT').every((l) => l.status === 'matched')).toBe(true)
  })

  it('missing: anchor absent at the fix coordinate and both neighbor tiers stays "missing", not guessed', () => {
    const cells = tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 })
    const removed = { ...cells }
    delete (removed as Record<string, unknown>).AT12
    const wb = workbookFromSheets({ INPUT: sheetFromCells(RATES_CELLS), '2_2': sheetFromCells(removed) })
    const locations = locateG60ColumnAnchors(wb.sheet('2_2')!)
    expect(locations.find((l) => l.col === 'AT')).toEqual({ col: 'AT', status: 'missing', resolvedCol: null, offset: 0, confidence: 0 })
  })

  it('ambiguous: anchor text found in TWO neighbor columns is treated as missing rather than guessed', () => {
    const cells = tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 })
    const ambiguous: Record<string, unknown> = { ...cells }
    delete ambiguous.AT12
    ambiguous.AU12 = 'Maschinensatz' // allow-customer-string  (offset +1)
    ambiguous.AS12 = 'Maschine' // allow-customer-string  (offset -1) — a second, equally valid candidate
    const wb = workbookFromSheets({ INPUT: sheetFromCells(RATES_CELLS), '2_2': sheetFromCells(ambiguous) })
    const locations = locateG60ColumnAnchors(wb.sheet('2_2')!)
    expect(locations.find((l) => l.col === 'AT')?.status).toBe('missing')
  })

  it('honours a custom relocatedConfidence from config', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells(shiftAtColumnToAu(tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 }))),
    })
    const custom = { ...G60_STRUCTURE_GUARD_CONFIG, relocatedConfidence: 0.5 }
    const locations = locateG60ColumnAnchors(wb.sheet('2_2')!, G60_HEADER_ROW, custom)
    expect(locations.find((l) => l.col === 'AT')?.confidence).toBe(0.5)
  })
})

describe('validateG60TabHeader — a relocated anchor is not a mismatch (KAR-894/P1.3)', () => {
  it('stays ok:true/confidence 1 when the only "wrong" anchor is actually shifted and findable one column over', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells(shiftAtColumnToAu(tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 }))),
    })
    const finding = validateG60TabHeader(wb.sheet('2_2')!, G60_HEADER_ROW)
    expect(finding).toEqual({ ok: true, mismatches: [], confidence: 1 })
  })
})

describe('parseG60WorkbookGuarded — column relocation re-extracts from the resolved column (KAR-894/P1.3)', () => {
  it('reads the Mach aggregate + machrate/at15 from the resolved column, records provenance, degrades confidence to 0.8, keeps the tab usable', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells(shiftAtColumnToAu(tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 }))),
      '10_2': sheetFromCells(tabCells({ part: 'P-B', sales: 30, w: 4, at15: 100 })),
    })
    const guarded = parseG60WorkbookGuarded(wb)

    expect(Object.keys(guarded.excludedTabs)).toEqual([])
    expect(Object.keys(guarded.tabs)).toEqual(['2_2', '10_2'])

    const relocatedTab = guarded.tabs['2_2']
    expect(relocatedTab.Mach).toBe(3) // NOT the 999 decoy left at AT41
    expect(relocatedTab.sourceCells?.Mach).toBe('2_2!AU41')
    expect(relocatedTab.at15).toBe(100) // NOT the 999 decoy left at AT15
    expect(relocatedTab.steps[0].machrate).toBe(100)
    expect(relocatedTab.steps[0].sourceCells?.machrate).toBe('2_2!AU15')
    // Un-relocated columns on the same tab are untouched.
    expect(relocatedTab.W).toBe(4)

    const finding = guarded.tabStructure['2_2']
    expect(finding.ok).toBe(false)
    expect(finding.confidence).toBe(DEFAULT_RELOCATED_CONFIDENCE)
    expect(finding.mismatches).toEqual([]) // relocated ≠ mismatch
    expect(finding.columnRelocations).toEqual([{ col: 'AT', resolvedCol: 'AU', offset: 1, confidence: DEFAULT_RELOCATED_CONFIDENCE }])

    // The unaffected tab is completely untouched by the other tab's relocation.
    expect(guarded.tabs['10_2']).toEqual(guarded.tabs['10_2'])
    expect(guarded.tabStructure['10_2']).toBeUndefined()
  })

  it('a genuinely missing anchor alongside a relocated one: confidence is the worse (min) of soft/relocated, both surface distinctly', () => {
    const shifted = shiftAtColumnToAu(tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 }))
    delete (shifted as Record<string, unknown>).AQ12 // genuinely gone, no neighbor text set either
    const wb = workbookFromSheets({ INPUT: sheetFromCells(RATES_CELLS), '2_2': sheetFromCells(shifted) })
    const guarded = parseG60WorkbookGuarded(wb)

    expect(Object.keys(guarded.excludedTabs)).toEqual([]) // only 1 genuinely-missing anchor: stays soft, not hard
    const finding = guarded.tabStructure['2_2']
    expect(finding.mismatches).toHaveLength(1)
    expect(finding.mismatches[0].cell).toBe('AQ12')
    expect(finding.columnRelocations).toEqual([{ col: 'AT', resolvedCol: 'AU', offset: 1, confidence: DEFAULT_RELOCATED_CONFIDENCE }])
    expect(finding.confidence).toBe(Math.min(G60_STRUCTURE_GUARD_CONFIG.softConfidence, DEFAULT_RELOCATED_CONFIDENCE))
  })

  it('excluded (hard mismatch) tabs are never re-extracted, even if one of the missing anchors would have resolved via relocation search', () => {
    // Two anchors genuinely gone (no neighbor text anywhere) → hard exclusion
    // regardless of a third, separately-relocatable anchor.
    const shifted = shiftAtColumnToAu(tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 }))
    delete (shifted as Record<string, unknown>).W12
    delete (shifted as Record<string, unknown>).Y12
    const wb = workbookFromSheets({ INPUT: sheetFromCells(RATES_CELLS), '2_2': sheetFromCells(shifted) })
    const guarded = parseG60WorkbookGuarded(wb)
    expect(Object.keys(guarded.tabs)).toEqual([])
    expect(guarded.excludedTabs['2_2']).toBeDefined()
    expect(guarded.excludedTabs['2_2'].confidence).toBe(0)
  })
})

describe('g60TabColumnRelocationToPlausibilityIssue / buildG60StructureIssues (KAR-894/P1.3)', () => {
  it('emits a distinct "pruefen" g60_column_relocated issue naming the anchor and resolved column', () => {
    const issue = g60TabColumnRelocationToPlausibilityIssue('2_2', {
      ok: false,
      mismatches: [],
      confidence: 0.8,
      columnRelocations: [{ col: 'AT', resolvedCol: 'AU', offset: 1, confidence: 0.8 }],
    }, 'NEU')
    expect(issue).toMatchObject({ type: 'g60_column_relocated', severity: 'pruefen' })
    expect(issue?.explanation).toContain('AT→AU')
  })

  it('returns null when there are no relocations', () => {
    expect(g60TabColumnRelocationToPlausibilityIssue('2_2', { ok: true, mismatches: [], confidence: 1 }, 'NEU')).toBeNull()
  })

  it('buildG60StructureIssues emits the relocation issue for a relocation-only finding without an empty soft-mismatch issue', () => {
    const issues = buildG60StructureIssues({
      side: 'NEU',
      inputStructure: { ok: true, mismatches: [], confidence: 1 },
      tabStructure: {
        '2_2': { ok: false, mismatches: [], confidence: 0.8, columnRelocations: [{ col: 'AT', resolvedCol: 'AU', offset: 1, confidence: 0.8 }] },
      },
      excludedTabs: {},
    })
    expect(issues).toHaveLength(1)
    expect(issues[0].type).toBe('g60_column_relocated')
  })

  it('buildG60StructureIssues emits BOTH issues for a tab with a genuine mismatch AND a relocation', () => {
    const issues = buildG60StructureIssues({
      side: 'NEU',
      inputStructure: { ok: true, mismatches: [], confidence: 1 },
      tabStructure: {
        '2_2': {
          ok: false,
          mismatches: [{ cell: 'AQ12', expected: 'employees', found: '(leer)' }],
          confidence: 0.6,
          columnRelocations: [{ col: 'AT', resolvedCol: 'AU', offset: 1, confidence: 0.8 }],
        },
      },
      excludedTabs: {},
    })
    expect(issues.map((i) => i.type).sort()).toEqual(['g60_column_relocated', 'g60_structure_tab_soft_mismatch'])
  })
})

describe('columnOverridesByTab consistency — aggregate path vs. component-rows path (KAR-894/P1.3 adversarial-review follow-up)', () => {
  it('exposes the exact overrides the aggregate re-extraction used, {} for un-relocated tabs', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells(shiftAtColumnToAu(tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 }))),
      '10_2': sheetFromCells(tabCells({ part: 'P-B', sales: 30, w: 4, at15: 100 })),
    })
    const guarded = parseG60WorkbookGuarded(wb)
    expect(guarded.columnOverridesByTab['2_2']).toEqual({ AT: 'AU' })
    expect(guarded.columnOverridesByTab['10_2']).toEqual({})
  })

  it('feeding columnOverridesByTab into extractFullTab reads the SAME resolved cell as the (already-relocated) aggregate/step path — no mix of stale/resolved values', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells(shiftAtColumnToAu(tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 }))),
    })
    const guarded = parseG60WorkbookGuarded(wb)
    const sheet = wb.sheet('2_2')!

    // The bug this test guards against: building fullTabs WITHOUT the
    // exposed overrides (i.e. calling extractFullTab with no 2nd arg, as
    // actions.ts did before the fix) would silently read the 999 decoy.
    const fullTabsWithoutFix = extractFullTab(sheet)
    const row15WithoutFix = fullTabsWithoutFix.find((r) => r.r === 15)!
    expect(row15WithoutFix.AT).toBe(999) // the exact stale-read this PR closes

    // The fix: reuse guarded.columnOverridesByTab['2_2'] (computed once,
    // inside parseG60WorkbookGuarded — not re-derived here).
    const fullTabsFixed = extractFullTab(sheet, guarded.columnOverridesByTab['2_2'])
    const row15Fixed = fullTabsFixed.find((r) => r.r === 15)!
    expect(row15Fixed.AT).toBe(100) // NOT the 999 decoy

    // Consistency: the resolved AT value in component_rows (fixed) matches
    // the already-relocation-aware aggregate/step path exactly — both read
    // from 2_2!AU15/2_2!AU41, neither leaks the stale 2_2!AT* decoy.
    const aggregate = guarded.tabs['2_2']
    expect(aggregate.at15).toBe(row15Fixed.AT)
    expect(aggregate.steps[0].machrate).toBe(row15Fixed.AT)
    expect(aggregate.sourceCells?.Mach).toBe('2_2!AU41')
    expect(aggregate.steps[0].sourceCells?.machrate).toBe('2_2!AU15')
  })

  it('recomputeTab on the fixed component rows yields a consistent (non-decoy) machine-rate base; the unfixed rows would silently diverge', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '2_2': sheetFromCells(shiftAtColumnToAu(tabCells({ part: 'P-A', sales: 20, w: 4, at15: 100 }))),
    })
    const guarded = parseG60WorkbookGuarded(wb)
    const sheet = wb.sheet('2_2')!

    const rowsFixed = extractFullTab(sheet, guarded.columnOverridesByTab['2_2'])
    const rowsUnfixed = extractFullTab(sheet) // simulates the pre-fix bug

    const baseFixed = recomputeTab(rowsFixed, guarded.rates, {})
    const baseUnfixed = recomputeTab(rowsUnfixed, guarded.rates, {})

    // Both bases are finite numbers (no NaN from a missing/undefined column read).
    expect(Number.isFinite(baseFixed.Mach)).toBe(true)
    expect(Number.isFinite(baseUnfixed.Mach)).toBe(true)
    // The fixed base and the unfixed (buggy) base diverge — proving the
    // relocation actually reaches the what-if calculator's Mach total, not
    // just the raw AT field checked above.
    expect(baseFixed.Mach).not.toBeCloseTo(baseUnfixed.Mach)
  })
})
