import { describe, it, expect } from 'vitest'
import {
  detectG60,
  detailTabs,
  inputRates,
  inputCard,
  volumes,
  extractTab,
  extractFullTab,
  parseG60Workbook,
  sheetFromCells,
  workbookFromSheets,
  G60_RATES_SOURCE_CELLS,
  DEFAULT_G60_DETECTION_CONFIG,
  g60TabLabel,
  primaryComponentRow,
  type G60ComponentRow,
} from '../parser'
import { makeWorkbook, RATES_CELLS } from './fixtures'

// ── Synthetic G60 workbook (values hand-computed against b3_core.js) ─────────

describe('detectG60 / detailTabs', () => {
  const wb = makeWorkbook(100, 100)

  it('detects a G60 workbook (INPUT + cost tabs)', () => {
    expect(detectG60(wb)).toBe(true)
    expect(detectG60(workbookFromSheets({ Zusammenfassung: sheetFromCells({}) }))).toBe(false)
  })

  it('filters ^\\d+_2 tabs, excludes masters, natural sort', () => {
    expect(detailTabs(wb)).toEqual(['2_2', '10_2'])
  })
})

describe('inputRates / inputCard / volumes', () => {
  const wb = makeWorkbook(100, 100)

  it('reads the eight INPUT rates (C22–C29)', () => {
    expect(inputRates(wb)).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,
    })
  })

  it('reads the rate card (B/C rows 20–59) keyed by C-code', () => {
    const card = inputCard(wb)
    expect(card.C42).toEqual({ label: 'Lohnsatz DE', value: 38 })
    expect(card.C43?.value).toBeCloseTo(0.21)
  })

  it('reads years/volumes from the Stückzahlen sheet', () => {
    expect(volumes(wb)).toEqual({ years: [2026, 2027], vol: [10000, 20000] })
  })
})

describe('parseG60Workbook — INPUT rate provenance (KAR-894/P1.3)', () => {
  it('exposes a static source-cell address per rate field (INPUT!C22-C29)', () => {
    const wb = makeWorkbook(100, 100)
    const parsed = parseG60Workbook(wb)
    expect(parsed.ratesSourceCells).toEqual(G60_RATES_SOURCE_CELLS)
    expect(parsed.ratesSourceCells).toEqual({
      ovFK_d: 'INPUT!C22',
      ovMAT_d: 'INPUT!C23',
      pfFK_d: 'INPUT!C24',
      pfMAT_d: 'INPUT!C25',
      ovFK_s: 'INPUT!C26',
      ovMAT_s: 'INPUT!C27',
      pfFK_s: 'INPUT!C28',
      pfMAT_s: 'INPUT!C29',
    })
  })

  it('threads the tab name into every tab aggregate’s sourceCells', () => {
    const wb = makeWorkbook(100, 100)
    const parsed = parseG60Workbook(wb)
    expect(parsed.tabs['2_2'].sourceCells?.W).toBe('2_2!W41')
    expect(parsed.tabs['10_2'].sourceCells?.W).toBe('10_2!W41')
  })
})

describe('extractTab', () => {
  const wb = makeWorkbook(100, 100)
  const rec = extractTab(wb.sheet('2_2')!, inputRates(wb), '2_2')

  it('reads the row-41 aggregates and derives the V11 buckets', () => {
    expect(rec.part).toBe('P-A')
    expect(rec.Sales).toBe(20)
    expect(rec.Material).toBeCloseTo(4.5) // W + Y
    expect(rec.Labor).toBe(2) // Pers (AS41)
    expect(rec.Manufacturing).toBeCloseTo(4) // MfgTot − Pers
    expect(rec.ScrapB).toBeCloseTo(0.4)
  })

  it('accumulates SGA/Profit from EUR rows by directed/sourced rates', () => {
    // directed row: 10·0.1 + 5·0.2 = 2 ; sourced row: 4·0.15 + 2·0.25 = 1.1
    expect(rec.SGA).toBeCloseTo(3.1)
    // directed: 10·0.05 + 5·0.1 = 1 ; sourced: 4·0.08 + 2·0.12 = 0.56
    expect(rec.Profit).toBeCloseTo(1.56)
  })

  it('collects process steps (AO>0) and the primary machine rate at15', () => {
    expect(rec.steps).toHaveLength(1)
    expect(rec.steps[0]).toMatchObject({ row: 15, cycle: 60, emp: 2, machrate: 100, scrapstep: 0.02 })
    expect(rec.at15).toBe(100)
  })
})

describe('extractTab — cell provenance (KAR-894/P1.3)', () => {
  const wb = makeWorkbook(100, 100)
  const rec = extractTab(wb.sheet('2_2')!, inputRates(wb), '2_2')

  it('records the source cell for every row-41 aggregate field', () => {
    expect(rec.sourceCells).toEqual({
      W: '2_2!W41',
      Y: '2_2!Y41',
      Pers: '2_2!AS41',
      Mach: '2_2!AT41',
      AV: '2_2!AV41',
      MfgTot: '2_2!BB41',
      Scrap: '2_2!BD41',
      HK: '2_2!BF41',
      Sur: '2_2!DI41',
      Sales: '2_2!DJ41',
      TC: '2_2!DG41',
    })
  })

  it('records the source cell for the 3 anchored per-step fields (AO/AQ/AT), not the un-anchored ones', () => {
    expect(rec.steps[0].sourceCells).toEqual({ cycle: '2_2!AO15', emp: '2_2!AQ15', machrate: '2_2!AT15' })
  })

  it('uses the tab name passed in, not a hardcoded default', () => {
    const rec2 = extractTab(wb.sheet('10_2')!, inputRates(wb), '10_2')
    expect(rec2.sourceCells?.W).toBe('10_2!W41')
  })
})

describe('extractTab — columnOverrides (KAR-894/P1.3)', () => {
  it('omitting overrides stays byte-identical to the 3-arg call (regression)', () => {
    const wb = makeWorkbook(100, 100)
    const rates = inputRates(wb)
    const withDefault = extractTab(wb.sheet('2_2')!, rates, '2_2')
    const withExplicitEmpty = extractTab(wb.sheet('2_2')!, rates, '2_2', {})
    expect(withDefault).toEqual(withExplicitEmpty)
  })

  it('redirects the AT (Mach) column read to the override, both at row 41 and per-row (machrate/at15), and records the resolved address', () => {
    const wb = makeWorkbook(100, 100)
    const rates = inputRates(wb)
    const baseline = extractTab(wb.sheet('2_2')!, rates, '2_2')
    // Baseline (no override): AT41=3, AT15=100 (fixture literals).
    expect(baseline.Mach).toBe(3)
    expect(baseline.at15).toBe(100)

    // Redirect the AT anchor to column AS (Pers's column: AS41=2, AS15=0.5).
    const relocated = extractTab(wb.sheet('2_2')!, rates, '2_2', { AT: 'AS' })
    expect(relocated.Mach).toBe(2)
    expect(relocated.sourceCells?.Mach).toBe('2_2!AS41')
    expect(relocated.at15).toBeCloseTo(0.5)
    expect(relocated.steps[0].machrate).toBeCloseTo(0.5)
    expect(relocated.steps[0].sourceCells?.machrate).toBe('2_2!AS15')
    // Un-overridden columns (e.g. W/Pers) stay exactly as the baseline.
    expect(relocated.W).toBe(baseline.W)
    expect(relocated.Pers).toBe(baseline.Pers)
  })
})

describe('extractFullTab (scenario base rows)', () => {
  it('computes per-unit labour/machine and the constant rem_pu (V11 formulas)', () => {
    const wb = makeWorkbook(120, 100)
    const rows = extractFullTab(wb.sheet('2_2')!)
    const r15 = rows.find((r) => r.r === 15)!
    // lab_pu = AQ·AR·(AS+1)·AO/AP/3600 = 2·30·1.5·60/1/3600 = 1.5
    // mac_pu = AT·AO/AP/3600 = 120·60/3600 = 2
    expect(r15.rem_pu).toBeCloseTo(10 - 1.5 - 2)
  })
})

describe('extractFullTab — columnOverrides (KAR-894/P1.3 adversarial-review follow-up)', () => {
  it('omitting overrides stays byte-identical to the 1-arg call (regression)', () => {
    const wb = makeWorkbook(100, 100)
    const withDefault = extractFullTab(wb.sheet('2_2')!)
    const withExplicitEmpty = extractFullTab(wb.sheet('2_2')!, {})
    expect(withDefault).toEqual(withExplicitEmpty)
  })

  it('redirects W/Y/AO/AQ/AT reads to the override, leaves un-anchored columns (X/AP/AR/AS/BC/AX/BA/AZ/AD/DB) literal', () => {
    const wb = makeWorkbook(100, 100)
    const baseline = extractFullTab(wb.sheet('2_2')!)
    const r15Baseline = baseline.find((r) => r.r === 15)!
    // Fixture literals for row 15 (tabCells): AT15=100 (opts.at15).
    expect(r15Baseline.AT).toBe(100)

    // Redirect AT to AS (Pers's column: AS15=0.5) — same override style as
    // extractTab's columnOverrides above, applied to the FULL-tab reader.
    const overridden = extractFullTab(wb.sheet('2_2')!, { AT: 'AS' })
    const r15Overridden = overridden.find((r) => r.r === 15)!
    expect(r15Overridden.AT).toBeCloseTo(0.5)
    // Un-overridden anchor/non-anchor fields are unaffected by the AT override.
    expect(r15Overridden.W).toBe(r15Baseline.W)
    expect(r15Overridden.AO).toBe(r15Baseline.AO)
    expect(r15Overridden.AQ).toBe(r15Baseline.AQ)
    expect(r15Overridden.AS).toBe(r15Baseline.AS)
    expect(r15Overridden.AP).toBe(r15Baseline.AP)
    // rem_pu depends on AT (mac_pu), so the override changes it too —
    // proves the redirected value actually feeds the downstream formula,
    // not just the raw field.
    expect(r15Overridden.rem_pu).not.toBeCloseTo(r15Baseline.rem_pu)
  })
})


describe('num-Verhalten (bewusste V11-Verbesserungen)', () => {
  it('parses German comma decimals correctly (V11 parseFloat truncated them)', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells({ C22: '0,1', B42: 'Lohnsatz', C42: '38,50' }),
      '2_2': sheetFromCells({}),
    })
    expect(inputRates(wb).ovFK_d).toBeCloseTo(0.1)
  })

  it('reads a leading numeric prefix from unit-suffixed text like parseFloat', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells({ C22: '38 EUR/h' }),
      '2_2': sheetFromCells({}),
    })
    expect(inputRates(wb).ovFK_d).toBe(38)
  })

  it('pairs volumes with their year column even across gaps (V11 shifted them)', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells({}),
      Stückzahlen: sheetFromCells({ C4: 2026, C5: 10000, E4: 2028, E5: 20000 }),
      '2_2': sheetFromCells({}),
    })
    expect(volumes(wb)).toEqual({ years: [2026, 2028], vol: [10000, 20000] })
  })
})

describe('detectG60 — Haertung gegen False-Positives', () => {
  it('rejects a Summary QAF that coincidentally has INPUT + \\d+_2 sheets', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells({}),
      '10_2': sheetFromCells({}),
      Zusammenfassung: sheetFromCells({}),
    })
    expect(detectG60(wb)).toBe(false)
  })

  it('rejects the English SUMMARY variant as well', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells({}),
      '10_2': sheetFromCells({}),
      SUMMARY: sheetFromCells({}),
    })
    expect(detectG60(wb)).toBe(false)
  })
})

// PR #328 review fix (finding [0]): DEFAULT_G60_DETECTION_CONFIG must stay
// byte-identical to the pre-KAR-961 hardcoded `wb.sheet('INPUT')` lookup
// (strict, case-sensitive, no trimming) — a case/whitespace-variant sheet
// name must NOT be detected under the default config, only under an
// explicit, non-default `inputSheetNameMatch: 'caseInsensitiveTrimmed'`.
describe('detectG60 — Default-Config bleibt strikt case-sensitiv (PR #328 review fix)', () => {
  it('does NOT detect a workbook whose rate-card sheet is "Input" (different case) under the default config', () => {
    const wb = workbookFromSheets({
      Input: sheetFromCells(RATES_CELLS),
      '10_2': sheetFromCells({}),
    })
    expect(detectG60(wb)).toBe(false)
  })

  it('does NOT detect a workbook whose rate-card sheet is "INPUT " (trailing whitespace) under the default config', () => {
    const wb = workbookFromSheets({
      'INPUT ': sheetFromCells(RATES_CELLS),
      '10_2': sheetFromCells({}),
    })
    expect(detectG60(wb)).toBe(false)
  })

  it('still detects the exact "INPUT" sheet under the default config (regression anchor)', () => {
    const wb = workbookFromSheets({
      INPUT: sheetFromCells(RATES_CELLS),
      '10_2': sheetFromCells({}),
    })
    expect(detectG60(wb)).toBe(true)
  })

  it('DOES detect the case/whitespace variant when inputSheetNameMatch is explicitly set to caseInsensitiveTrimmed', () => {
    const wb = workbookFromSheets({
      'input  ': sheetFromCells(RATES_CELLS),
      '10_2': sheetFromCells({}),
    })
    expect(
      detectG60(wb, {
        ...DEFAULT_G60_DETECTION_CONFIG,
        inputSheetNameMatch: 'caseInsensitiveTrimmed',
      }),
    ).toBe(true)
  })
})

// ── primaryComponentRow / g60TabLabel (KAR-966 item 1 + PR #330 review fix) ──

describe('primaryComponentRow (PR #330 review fix: shared AO>0-gated/AT-tie-broken row selector)', () => {
  it('gates on AO>0 (Zykluszeit) — an AO<=0 row is never eligible even with the highest AT', () => {
    const rows = [
      { AO: 5, AT: 0 },
      { AO: 0, AT: 999 },
    ]
    expect(primaryComponentRow(rows)).toEqual({ AO: 5, AT: 0 })
  })

  it('tie-breaks eligible (AO>0) rows on the highest AT', () => {
    const rows = [
      { AO: 5, AT: 10 },
      { AO: 5, AT: 200 },
      { AO: 5, AT: 5 },
    ]
    expect(primaryComponentRow(rows)).toEqual({ AO: 5, AT: 200 })
  })

  it('returns null when no row has AO>0', () => {
    expect(primaryComponentRow([{ AO: 0, AT: 100 }])).toBeNull()
    expect(primaryComponentRow([])).toBeNull()
  })
})

describe('g60TabLabel', () => {
  const row = (
    over: Partial<Pick<G60ComponentRow, 'desig' | 'proc' | 'AO' | 'AT'>>,
  ): Pick<G60ComponentRow, 'desig' | 'proc' | 'AO' | 'AT'> => ({
    desig: '',
    proc: '',
    AO: 1,
    AT: 0,
    ...over,
  })

  it('formats "<Name> (<Sachnummer>)" when both the primary row\'s desig and the tab\'s part are present', () => {
    const rows = [row({ desig: 'Spritzguss', AT: 120 })]
    expect(g60TabLabel('1', '12345-678', rows)).toBe('Spritzguss (12345-678)')
  })

  it('falls back to proc when desig is blank', () => {
    const rows = [row({ proc: 'Montage', AT: 50 })]
    expect(g60TabLabel('2', null, rows)).toBe('Montage')
  })

  it('falls back to the bare part (Sachnummer/Komponente) when no row carries a name', () => {
    const rows = [row({ AT: 50 })]
    expect(g60TabLabel('3', '999-000', rows)).toBe('999-000')
  })

  it('falls back to "Schritt <tabName>" as the last resort — never a blank label', () => {
    expect(g60TabLabel('10', null, [])).toBe('Schritt 10')
    expect(g60TabLabel('10', '   ', [row({ desig: '  ' })])).toBe('Schritt 10')
  })

  it('picks the PRIMARY row (AO>0 gate, highest AT tie-break) — same rule as calculatorParamsFrom', () => {
    const rows = [row({ desig: 'Vorbereitung', AT: 10 }), row({ desig: 'Spritzguss', AT: 200 }), row({ desig: 'Nacharbeit', AT: 5 })]
    expect(g60TabLabel('1', null, rows)).toBe('Spritzguss')
  })

  it('never invents a value — a row with AO<=0 (no real process step) is not eligible as primary even if it has a desig', () => {
    const rows = [row({ desig: 'Nicht-Prozesszeile', AO: 0, AT: 999 })]
    expect(g60TabLabel('1', '55-1', rows)).toBe('55-1')
  })

  // PR #330 review fix, finding 1 (KORREKTHEIT, schwerste): g60TabLabel used
  // to gate on AT>0 while calculatorParamsFrom (app/qaf-differences/actions.ts)
  // gates on AO>0 with AT only as a tie-break. For a tab where the AO>0 row
  // and the highest-AT row are DIFFERENT rows, the dropdown showed the label
  // of one row while the calculator loaded the values of the other — this
  // test is red against the pre-fix AT>0 gate (it would have picked
  // "Nacharbeit", AT=50, instead of "Spritzguss", the only AO>0/real-process
  // row here, AT=0).
  it('picks the label from the SAME row calculatorParamsFrom loads values from: AO>0/AT=0 beats AO<=0/AT>0', () => {
    const rows = [row({ desig: 'Spritzguss', AO: 12, AT: 0 }), row({ desig: 'Nacharbeit', AO: 0, AT: 50 })]
    expect(g60TabLabel('1', null, rows)).toBe('Spritzguss')
    expect(primaryComponentRow(rows)).toEqual(row({ desig: 'Spritzguss', AO: 12, AT: 0 }))
  })
})
