// Tests for the RAW MATERIAL RISKS sheet parser + Rohstoffzuschlag-Validierung
// (KAR-902 / P2.3). Fixtures are entirely synthetic (invented raw-material
// names/values) — no real Brose/Kiekert/Autoliv/BMW data or files, though the // allow-customer-string
// negative-quotation / Steel-Scrap-style numbers below are modeled after the
// documented Leitfaden [45] Abbildung 26 worked-example pattern (fictional
// numbers of the same structure, not the real market prices in that figure).

import { describe, it, expect } from 'vitest'
import ExcelJS from 'exceljs'
import {
  isRmrSheetName,
  findRmrWorksheet,
  findRmrHeaderRow,
  matchRmrHeaderColumn,
  parseRmrWorksheet,
  parseRmrSheet,
  rmrRowsForReconciliation,
  rmrRowsFromPersistedMeta,
  rmrParseMetaToPlausibilityIssue,
  validateRmrRawMaterialSurcharge,
  evaluateRmrValidation,
  rmrValidationResultToPlausibilityIssue,
  checkRmrValidation,
  CORE_RMR_FIELD_KEYS,
  RMR_VALIDATION_CONFIG,
  type RmrRow,
  type RmrRowValues,
  type PersistedRmrMeta,
} from '../rmr-parser'

// Header labels in canonical-fields.ts RMR_FIELDS declaration order
// (Leitfaden Teil 2 [43]).
const FULL_RMR_HEADERS = [
  'Positionsnummer', // 0 positionNumber
  'Rohstoffbezeichnung', // 1 rawMaterialDesignation
  'Bezugsgewicht [kg]', // 2 referenceWeight
  'Rohstoffschlüssel', // 3 rawMaterialKey
  'Rohstoffnotierung Ro [AW/kg]', // 4 rawMaterialQuotation
  'Rohstoffzuschlag [AW] RoZ0', // 5 rawMaterialSurcharge
  'Abwicklungsmodell', // 6 settlementModel
  'Indexbezeichnung (siehe Anfrage)', // 7 indexDesignation
  'Indexnotierung RoI0 [AW/kg] im Durchschnittszeitraum', // 8 indexQuotation
  'BMW Beteiligungsquote BQ [in %]', // 9 bmwParticipationRate // allow-customer-string
  'Schwellwert SW [in %]', // 10 threshold
  'Bemerkung', // 11 remark
]

async function workbookWithSheets(sheets: Array<{ name: string; rows: unknown[][] }>): Promise<ExcelJS.Workbook> {
  const wb = new ExcelJS.Workbook()
  for (const { name, rows } of sheets) {
    const ws = wb.addWorksheet(name)
    rows.forEach((row, i) => {
      ws.getRow(i + 1).values = row as ExcelJS.CellValue[]
    })
  }
  return wb
}

/**
 * The 12-row worked example from Leitfaden [45] Abbildung 26 "Rohstoff
 * Preisanteil Material" block — Positionsnummer, Rohstoffbezeichnung,
 * Bezugsgewicht [kg], Rohstoffschlüssel, Rohstoffnotierung Ro [AW/kg],
 * Rohstoffzuschlag [AW] RoZ0. Every RoZ0 in the source figure is consistent
 * with RoZ0 = Ro x Bezugsgewicht (rounded to 2 decimals) — including row 10
 * (Steel Scrap Coil EU), whose Ro AND RoZ0 are both negative (Gutschrift/
 * credit logic per [45] "Ausnahmen/Sonderfaelle"). Numbers transcribed
 * verbatim from the documented figure (fictional BMW example data, not a // allow-customer-string
 * real supplier quotation).
 */
function abbildung26MaterialRows(): unknown[][] {
  return [
    FULL_RMR_HEADERS,
    ['1', 'AL LME EU', 15.0, 'A013', 1.99, 29.85, '', '', '', '', '', ''],
    ['2', 'AL EODP Premium', 15.0, 'AP04', 0.21, 3.15, '', '', '', '', '', ''],
    ['3', 'AL LME US', 4.5, 'A013', 2.45, 11.03, '', '', '', '', '', ''],
    ['4', 'AL MWP Premium', 2.0, 'AP06', 0.26, 0.52, '', '', '', '', '', ''],
    ['5', 'AL MJP Premium', 2.5, 'APJP', 0.22, 0.55, '', '', '', '', '', ''],
    ['6', 'AL SHFE CN', 3.0, 'AMXP', 13.4, 40.2, '', '', '', '', '', ''],
    ['7', 'C3 Propylene EU', 1.5, 'PEEP', 1.578, 2.37, '', '', '', '', '', ''],
    ['8', 'PA 6 EU', 1.05, 'PECP', 1.371, 1.44, '', '', '', '', '', ''],
    ['9', 'Steel coil EU', 2.0, 'SE3F', 0.725, 1.45, '', '', '', '', '', ''],
    ['10', 'Steel Scrap Coil EU', 1.0, 'SE3S', -0.32, -0.32, '', '', '', '', '', ''],
    ['11', 'Steel scrap adder', 2.0, 'SRZS', 0.25, 0.5, '', '', '', '', '', ''],
    ['12', 'Dysprosium Export', 0.3, 'EXDP', 10.0, 3.0, '', '', '', '', '', ''],
  ]
}

async function cleanRmrWorksheet(): Promise<ExcelJS.Worksheet> {
  const wb = await workbookWithSheets([{ name: 'RAW MATERIAL RISKS', rows: abbildung26MaterialRows() }])
  return wb.worksheets[0]
}

// ── Multi-block fixtures (KAR-902 follow-up fix, adversarial-review finding
// confidence 85: "Zwei-Block-Layout erzeugt stillen Datenverlust/Korruption")
// ────────────────────────────────────────────────────────────────────────
//
// Leitfaden [44]: "Rohstoffpreisanteile werden in 2 Bloecken dargestellt:
// Rohstoffpreisanteil Material und Rohstoffpreisanteil Energie." Abbildung 26
// (S.45) shows the Energie block using DIFFERENT unit-suffixed column labels
// for the same two roles the RoZ0 formula needs (Energieverbrauch pro h
// [kWh] instead of Bezugsgewicht [kg], Rohstoffnotierung Ro [AW/kWh] instead
// of [AW/kg]) — mapped onto the SAME RmrFieldKeys via the aliases added to
// canonical-fields.ts for this fix.
const ENERGIE_RMR_HEADERS = [
  'Positionsnummer',
  'Rohstoffbezeichnung',
  'Energieverbrauch pro h [kWh]',
  'Rohstoffschlüssel',
  'Rohstoffnotierung Ro [AW/kWh]',
  'Rohstoffzuschlag [AW] RoZ0',
  'Abwicklungsmodell',
  'Indexbezeichnung (siehe Anfrage)',
  'Indexnotierung RoI0 [AW/kg] im Durchschnittszeitraum',
  'BMW Beteiligungsquote BQ [in %]', // allow-customer-string
  'Schwellwert SW [in %]',
  'Bemerkung',
]

/**
 * The 2-row "Rohstoff Preisanteil Energie" worked example from Leitfaden
 * [45] Abbildung 26 — Gas EU / Gas US. Both rows are consistent with
 * RoZ0 = Ro x Energieverbrauch (0.030*34.18=1.0254~1.03, 0.090*2.78=0.2502~
 * 0.25), the SAME formula the Material block uses. Fictional example
 * structure, not real market data.
 */
function abbildung26EnergieRows(): unknown[][] {
  return [
    ENERGIE_RMR_HEADERS,
    ['1', 'Gas EU', 34.18, 'EENG', 0.03, 1.03, '', '', '', '', '', ''],
    ['2', 'Gas US', 2.78, 'EUNG', 0.09, 0.25, '', '', '', '', '', ''],
  ]
}

const BLANK_RMR_ROW = ['', '', '', '', '', '', '', '', '', '', '', '']
const ENERGIE_TITLE_ROW = ['Rohstoff Preisanteil Energie', '', '', '', '', '', '', '', '', '', '', '']
const MATERIAL_TITLE_ROW = ['Rohstoff Preisanteil Material', '', '', '', '', '', '', '', '', '', '', '']

/** Both documented blocks, WITH title rows (enabling title-text block-type
 * detection) AND a blank-row separator between them — the "clean" stacked
 * layout. */
function stackedBlocksWithTitlesAndSeparator(): unknown[][] {
  return [ENERGIE_TITLE_ROW, ...abbildung26EnergieRows(), BLANK_RMR_ROW, MATERIAL_TITLE_ROW, ...abbildung26MaterialRows()]
}

/** Both documented blocks, NO title rows and NO blank-row separator — the
 * Material block's header row appears IMMEDIATELY after the Energie block's
 * last data row. This is exactly the "ohne Trenner" scenario the finding
 * calls out: without the garbage-row guard, FULL_RMR_HEADERS would be read
 * as a spurious data row (Rohstoffbezeichnung="Rohstoffbezeichnung" etc.). */
function stackedBlocksNoSeparator(): unknown[][] {
  return [...abbildung26EnergieRows(), ...abbildung26MaterialRows()]
}

/** A single, otherwise-clean Material block followed — far enough down that
 * it falls outside the normal per-block header-scan window — by a second,
 * lone header row with no data of its own. Simulates a genuinely missed
 * block (e.g. a very large gap) that the Sicherheitsnetz must still flag. */
function materialBlockPlusFarTrailingHeader(): unknown[][] {
  const material = abbildung26MaterialRows() // 13 rows: header + 12 data
  const blankPadding: unknown[][] = Array.from({ length: 25 }, () => [...BLANK_RMR_ROW])
  return [...material, ...blankPadding, [...FULL_RMR_HEADERS]]
}

/** A Material block followed by a blank separator and then a SECOND header
 * row that is itself too degraded to trust (Rohstoffbezeichnung + Rohstoff-
 * notierung Ro columns dropped, same shape as degradedRmrRows() below) —
 * the "block found but core fields missing" Sicherheitsnetz path. */
function materialBlockPlusDegradedSecondBlock(): unknown[][] {
  const material = abbildung26MaterialRows()
  const degradedHeaders = FULL_RMR_HEADERS.filter(
    (h) => h !== 'Rohstoffbezeichnung' && h !== 'Rohstoffnotierung Ro [AW/kg]',
  )
  return [...material, [...BLANK_RMR_ROW], degradedHeaders, ['1', 5, 'X', '', '', '', '', '', '', '']]
}

/**
 * KAR-958/P3 review fix (PR #325 finding #4) fixture: a FULLY INTACT first
 * block (all 12 Abbildung-26 rows, every CORE_RMR_FIELD_KEYS mapped) is
 * followed by a SECOND block that is degraded (missing ONLY positionNumber —
 * designation AND quotation both stay mapped, so per the finding #3 fix
 * above this block still CONTRIBUTES rows, unlike a quotation-missing
 * block). This is the exact "intact block + degraded-but-contributing block"
 * combination the finding requires a test for: the old blanket
 * `coreFieldsFound` aggregate discarded the intact block's rows too the
 * moment ANY block degraded — the fix must keep them.
 */
function intactBlockPlusPositionNumberDegradedSecondBlock(): unknown[][] {
  const material = abbildung26MaterialRows()
  const positionIdx = FULL_RMR_HEADERS.indexOf('Positionsnummer')
  const degradedHeaders = FULL_RMR_HEADERS.filter((_, i) => i !== positionIdx)
  const degradedDataRows = [
    ['Second-Block Rohstoff A', 3.0, 'ZZ01', 0.5, 1.5, '', '', '', '', '', ''],
    ['Second-Block Rohstoff B', 2.0, 'ZZ02', 0.75, 1.5, '', '', '', '', '', ''],
  ]
  return [...material, [...BLANK_RMR_ROW], degradedHeaders, ...degradedDataRows]
}

/** A real RMR sheet whose header is too degraded to trust (Rohstoffbezeichnung
 * + Rohstoffnotierung Ro columns dropped) — coreFieldsFound stays false even
 * though the sheet genuinely exists and may carry real data the parser simply
 * could not locate. */
function degradedRmrRows(): unknown[][] {
  const rows = abbildung26MaterialRows()
  const headers = [...rows[0]]
  const designationIdx = headers.indexOf('Rohstoffbezeichnung')
  const quotationIdx = headers.indexOf('Rohstoffnotierung Ro [AW/kg]')
  return rows.map((row) => {
    const r = [...row]
    r.splice(Math.max(designationIdx, quotationIdx), 1)
    r.splice(Math.min(designationIdx, quotationIdx), 1)
    return r
  })
}

function blankRmrRow(overrides: Partial<RmrRowValues>): RmrRow {
  const base: RmrRowValues = {
    positionNumber: '',
    rawMaterialDesignation: '',
    referenceWeight: null,
    rawMaterialKey: '',
    rawMaterialQuotation: null,
    rawMaterialSurcharge: null,
    settlementModel: '',
    indexDesignation: '',
    indexQuotation: null,
    bmwParticipationRate: null,
    threshold: null,
    remark: '',
  }
  return { ...base, ...overrides, sourceCells: {}, normalized: {}, rawText: {}, blockType: 'unbekannt' }
}

describe('isRmrSheetName', () => {
  it('matches the plural "RAW MATERIAL RISKS" sheet title (Abbildung 26)', () => {
    expect(isRmrSheetName('RAW MATERIAL RISKS')).toBe(true)
    expect(isRmrSheetName('raw material risks')).toBe(true)
    expect(isRmrSheetName('  RAW MATERIAL RISKS  ')).toBe(true)
  })

  it('matches the singular "RAW MATERIAL RISK" footnote spelling ([17]/[21])', () => {
    expect(isRmrSheetName('RAW MATERIAL RISK')).toBe(true)
  })

  it('does not match the MATERIAL, SBM or Fertigungskosten sheets', () => {
    expect(isRmrSheetName('MATERIAL')).toBe(false)
    expect(isRmrSheetName('SBM-DEVICES-FWZ')).toBe(false)
    expect(isRmrSheetName('Fertigungskosten')).toBe(false)
  })
})

describe('findRmrWorksheet', () => {
  it('finds the RMR sheet among several worksheets', async () => {
    const wb = await workbookWithSheets([
      { name: 'SUMMARY', rows: [['x']] },
      { name: 'MATERIAL', rows: [['y']] },
      { name: 'RAW MATERIAL RISKS', rows: abbildung26MaterialRows() },
    ])
    const ws = findRmrWorksheet(wb)
    expect(ws?.name).toBe('RAW MATERIAL RISKS')
  })

  it('returns null when no RMR sheet exists', async () => {
    const wb = await workbookWithSheets([{ name: 'SUMMARY', rows: [['x']] }])
    expect(findRmrWorksheet(wb)).toBeNull()
  })
})

describe('matchRmrHeaderColumn', () => {
  it('matches the exact DE label', async () => {
    const m = await matchRmrHeaderColumn('Rohstoffbezeichnung')
    expect(m?.key).toBe('rawMaterialDesignation')
    expect(m?.confidence).toBe(1)
  })

  it('matches the exact EN label', async () => {
    const m = await matchRmrHeaderColumn('Raw material quotation Ro [AW/kg]')
    expect(m?.key).toBe('rawMaterialQuotation')
  })

  it('returns null for an unrecognized label', async () => {
    const m = await matchRmrHeaderColumn('Voellig unbekanntes Feld')
    expect(m).toBeNull()
  })
})

describe('findRmrHeaderRow', () => {
  it('locates the header row when it is row 0', async () => {
    const idx = await findRmrHeaderRow(abbildung26MaterialRows())
    expect(idx).toBe(0)
  })

  it('returns null for an empty grid', async () => {
    expect(await findRmrHeaderRow([])).toBeNull()
  })

  it('returns null when fewer than HEADER_MATCH_MIN columns match', async () => {
    const idx = await findRmrHeaderRow([['Voellig', 'Unbekannt']])
    expect(idx).toBeNull()
  })
})

describe('parseRmrWorksheet — clean sheet (Leitfaden [45] Abbildung 26 worked example)', () => {
  it('parses all 12 rows with coreFieldsFound true', async () => {
    const ws = await cleanRmrWorksheet()
    const result = await parseRmrWorksheet(ws)
    expect(result.coreFieldsFound).toBe(true)
    expect(result.length).toBe(12)
  })

  it('every CORE_RMR_FIELD_KEYS field is present on every row', async () => {
    const ws = await cleanRmrWorksheet()
    const result = await parseRmrWorksheet(ws)
    for (const row of result) {
      for (const key of CORE_RMR_FIELD_KEYS) {
        expect(row[key]).not.toBe(null)
        expect(row[key]).not.toBe('')
      }
    }
  })

  it('parses the Steel Scrap Coil row with a negative quotation AND a negative surcharge (no sign-flip, no error)', async () => {
    const ws = await cleanRmrWorksheet()
    const result = await parseRmrWorksheet(ws)
    const scrap = result.find((r) => r.rawMaterialDesignation === 'Steel Scrap Coil EU')
    expect(scrap).toBeDefined()
    expect(scrap!.rawMaterialQuotation).toBe(-0.32)
    expect(scrap!.rawMaterialSurcharge).toBe(-0.32)
    expect(scrap!.referenceWeight).toBe(1.0)
  })

  it('carries sourceCells/normalized/rawText provenance from day one', async () => {
    const ws = await cleanRmrWorksheet()
    const result = await parseRmrWorksheet(ws)
    expect(result[0].sourceCells.rawMaterialDesignation).toBe('RAW MATERIAL RISKS!B2')
    expect(result[0].normalized.rawMaterialDesignation).toBe('AL LME EU')
  })

  it('parseConfidence is 1.0 for exact-label headers', async () => {
    const ws = await cleanRmrWorksheet()
    const result = await parseRmrWorksheet(ws)
    expect(result.parseConfidence).toBe(1)
    expect(result.unmappedHeaders).toEqual([])
  })

  it('a single block with no title text stays blockType "unbekannt" (conservative default, no order-guessing on 1 block) and reports no unparsed-block finding', async () => {
    const ws = await cleanRmrWorksheet()
    const result = await parseRmrWorksheet(ws)
    expect(result.every((r) => r.blockType === 'unbekannt')).toBe(true)
    expect(result.possibleUnparsedBlockRow).toBeNull()
  })
})

describe('parseRmrWorksheet — degraded header (coreFieldsFound tri-state)', () => {
  it('returns coreFieldsFound false and empty rows when the identity field (Rohstoffbezeichnung) is among the missing core columns', async () => {
    const wb = await workbookWithSheets([{ name: 'RAW MATERIAL RISKS', rows: degradedRmrRows() }])
    const result = await parseRmrWorksheet(wb.worksheets[0])
    expect(result.coreFieldsFound).toBe(false)
    expect(result.length).toBe(0)
    expect(result.degradation?.reason).toBe('PARSE_FAILED')
  })

  // KAR-958/P3 review fix (PR #325 finding #3, supersedes the original
  // KAR-958/P2 claim for THIS specific field): dropping rawMaterialQuotation
  // from the header means the MIN_SIGNAL_MAPPED_COLUMNS floor is still met
  // (positionNumber + rawMaterialDesignation + 8 other non-core columns all
  // still map), and the row-push guard's OTHER check (rawMaterialDesignation
  // non-empty) still passes for every row — before this fix, that let every
  // row through with a SILENTLY null rawMaterialQuotation, indistinguishable
  // from a genuine RMR row whose quotation just hasn't been filled in yet
  // (Leitfaden [44]: quotation is set "zum Zeitpunkt der Vergabe"). The fix
  // (rmr-parser.ts's extractRmrRow) adds a second guard — the block's colMap
  // must have MAPPED a rawMaterialQuotation column at all, not just have a
  // non-blank cell in one — a foreign/degraded block missing that column
  // entirely now yields ZERO rows instead of fabricating them.
  it('extracts ZERO rows when rawMaterialQuotation is missing from the header entirely (the finding: was silently treated as real data before this fix)', async () => {
    const rows = abbildung26MaterialRows()
    const headers = [...rows[0]]
    const quotationIdx = headers.indexOf('Rohstoffnotierung Ro [AW/kg]')
    const withoutQuotation = rows.map((row) => {
      const r = [...row]
      r.splice(quotationIdx, 1)
      return r
    })
    const wb = await workbookWithSheets([{ name: 'RAW MATERIAL RISKS', rows: withoutQuotation }])
    const result = await parseRmrWorksheet(wb.worksheets[0])

    expect(result.coreFieldsFound).toBe(false)
    expect(result.length).toBe(0) // the fix: no rows emitted without a mapped quotation column
    expect(result.degradation).toEqual({
      facet: 'rmr',
      reason: 'PARSE_FAILED',
      sheet: 'RAW MATERIAL RISKS',
      message: expect.stringContaining('rawMaterialQuotation'),
    })
  })

  // The KAR-958/P2 partial-extraction contract still applies to a DIFFERENT
  // missing core field — one that is neither the row-push identity field
  // (rawMaterialDesignation) nor the new quotation guard (rawMaterialQuotation)
  // — proving the finding #3 fix is scoped to quotation specifically, not a
  // blanket revert of the whole partial-extraction feature.
  it('still extracts rows via partial extraction when the missing core field is positionNumber (designation + quotation both intact)', async () => {
    const rows = abbildung26MaterialRows()
    const headers = [...rows[0]]
    const positionIdx = headers.indexOf('Positionsnummer')
    const withoutPositionNumber = rows.map((row) => {
      const r = [...row]
      r.splice(positionIdx, 1)
      return r
    })
    const wb = await workbookWithSheets([{ name: 'RAW MATERIAL RISKS', rows: withoutPositionNumber }])
    const result = await parseRmrWorksheet(wb.worksheets[0])

    expect(result.coreFieldsFound).toBe(false)
    expect(result.length).toBe(12) // the fix does NOT touch this case: rows still survive
    expect(result.every((r) => r.positionNumber === '')).toBe(true) // the missing field itself stays blank
    expect(result.every((r) => r.rawMaterialDesignation !== '')).toBe(true)
    expect(result.every((r) => r.rawMaterialQuotation !== null)).toBe(true) // quotation itself is untouched, real data
    expect(result.degradation).toEqual({
      facet: 'rmr',
      reason: 'PARSE_FAILED',
      sheet: 'RAW MATERIAL RISKS',
      message: expect.stringContaining('positionNumber'),
    })
  })
})

describe('parseRmrWorksheet — empty/malformed input', () => {
  it('returns an empty degraded result for a grid with fewer than 2 rows', async () => {
    const wb = await workbookWithSheets([{ name: 'RAW MATERIAL RISKS', rows: [FULL_RMR_HEADERS] }])
    const result = await parseRmrWorksheet(wb.worksheets[0])
    expect(result.coreFieldsFound).toBe(false)
    expect(result.length).toBe(0)
  })

  it('never throws on a sheet with no recognizable header at all', async () => {
    const wb = await workbookWithSheets([{ name: 'RAW MATERIAL RISKS', rows: [['a', 'b'], ['c', 'd']] }])
    await expect(parseRmrWorksheet(wb.worksheets[0])).resolves.toBeDefined()
  })

  // PR #325 review fix (finding #7, cleanup): the anchor test material-/sbm-
  // /logistics-/lccn-parser.test.ts already have for their MIN_SIGNAL_
  // MAPPED_COLUMNS / HEADER_MATCH_MIN floor — a sheet whose name matches the
  // RMR sheet-name heuristic but whose header carries only 2 recognizable
  // RMR columns (below HEADER_MATCH_MIN=3) must never even be located as a
  // header row at all — never mistaken for a degraded-but-real RMR block.
  it('a genuinely foreign/near-empty sheet (fewer than HEADER_MATCH_MIN recognizable columns) stays empty, no degradation', async () => {
    const wb = await workbookWithSheets([
      {
        name: 'RAW MATERIAL RISKS',
        rows: [
          ['Positionsnummer', 'Rohstoffbezeichnung', 'Voellig', 'Andere', 'Spalten'],
          ['1', 'a', 'b', 'c', 'd'],
        ],
      },
    ])
    const result = await parseRmrWorksheet(wb.worksheets[0])
    expect(result.coreFieldsFound).toBe(false)
    expect(result).toHaveLength(0)
    expect(result.parseConfidence).toBe(0)
    expect(result.degradation).toBeUndefined()
    expect(result.possibleUnparsedBlockRow).toBeNull()
  })
})

// ── Multi-block parsing (KAR-902 follow-up fix, adversarial-review finding
// confidence 85) ────────────────────────────────────────────────────────

describe('parseRmrWorksheet — stacked blocks WITH title rows AND a blank-row separator', () => {
  it('parses BOTH blocks in full — no data loss (the original finding: a blank separator silently dropped the second block)', async () => {
    const wb = await workbookWithSheets([{ name: 'RAW MATERIAL RISKS', rows: stackedBlocksWithTitlesAndSeparator() }])
    const result = await parseRmrWorksheet(wb.worksheets[0])
    expect(result.coreFieldsFound).toBe(true)
    expect(result.length).toBe(14) // 2 Energie + 12 Material
  })

  it('assigns blockType from the literal title text ("Rohstoff Preisanteil Energie"/"...Material")', async () => {
    const wb = await workbookWithSheets([{ name: 'RAW MATERIAL RISKS', rows: stackedBlocksWithTitlesAndSeparator() }])
    const result = await parseRmrWorksheet(wb.worksheets[0])
    const energieRows = result.filter((r) => r.rawMaterialDesignation.startsWith('Gas '))
    const materialRows = result.filter((r) => !r.rawMaterialDesignation.startsWith('Gas '))
    expect(energieRows).toHaveLength(2)
    expect(materialRows).toHaveLength(12)
    expect(energieRows.every((r) => r.blockType === 'energie')).toBe(true)
    expect(materialRows.every((r) => r.blockType === 'material')).toBe(true)
  })

  it('the Energie block quantity/quotation columns (kWh-suffixed labels) map onto the SAME RmrFieldKeys via alias, and RoZ0 validation passes unchanged', async () => {
    const wb = await workbookWithSheets([{ name: 'RAW MATERIAL RISKS', rows: stackedBlocksWithTitlesAndSeparator() }])
    const result = await parseRmrWorksheet(wb.worksheets[0])
    const gasEu = result.find((r) => r.rawMaterialDesignation === 'Gas EU')
    expect(gasEu).toBeDefined()
    expect(gasEu!.referenceWeight).toBe(34.18) // mapped from "Energieverbrauch pro h [kWh]"
    expect(gasEu!.rawMaterialQuotation).toBe(0.03) // mapped from "Rohstoffnotierung Ro [AW/kWh]"
    const check = validateRmrRawMaterialSurcharge(gasEu!, 0, 'ALT')
    expect(check?.status).toBe('bestanden')
  })

  it('no unparsed-block finding when both documented blocks were fully consumed', async () => {
    const wb = await workbookWithSheets([{ name: 'RAW MATERIAL RISKS', rows: stackedBlocksWithTitlesAndSeparator() }])
    const result = await parseRmrWorksheet(wb.worksheets[0])
    expect(result.possibleUnparsedBlockRow).toBeNull()
  })
})

describe('parseRmrWorksheet — stacked blocks WITHOUT a separator (garbage-row guard)', () => {
  it('parses BOTH blocks in full with no garbage row (the original finding: the 2nd header row was silently read as a data row)', async () => {
    const wb = await workbookWithSheets([{ name: 'RAW MATERIAL RISKS', rows: stackedBlocksNoSeparator() }])
    const result = await parseRmrWorksheet(wb.worksheets[0])
    expect(result.coreFieldsFound).toBe(true)
    expect(result.length).toBe(14) // 2 Energie + 12 Material — NOT 15 (no garbage header-as-data row)
  })

  it('never pushes the second block\'s header row itself as a data row (no row whose designation equals a header label)', async () => {
    const wb = await workbookWithSheets([{ name: 'RAW MATERIAL RISKS', rows: stackedBlocksNoSeparator() }])
    const result = await parseRmrWorksheet(wb.worksheets[0])
    expect(result.some((r) => r.rawMaterialDesignation === 'Rohstoffbezeichnung')).toBe(false)
    expect(result.some((r) => r.positionNumber === 'Positionsnummer')).toBe(false)
  })

  it('assigns blockType via the order-heuristic fallback when no title text distinguishes the 2 blocks', async () => {
    const wb = await workbookWithSheets([{ name: 'RAW MATERIAL RISKS', rows: stackedBlocksNoSeparator() }])
    const result = await parseRmrWorksheet(wb.worksheets[0])
    const energieRows = result.filter((r) => r.rawMaterialDesignation.startsWith('Gas '))
    const materialRows = result.filter((r) => !r.rawMaterialDesignation.startsWith('Gas '))
    expect(energieRows.every((r) => r.blockType === 'energie')).toBe(true)
    expect(materialRows.every((r) => r.blockType === 'material')).toBe(true)
  })
})

describe('parseRmrWorksheet — Sicherheitsnetz: rmr_possible_unparsed_block', () => {
  it('flags a header-scoring row that sits beyond the normal per-block scan window (never silently dropped)', async () => {
    const wb = await workbookWithSheets([{ name: 'RAW MATERIAL RISKS', rows: materialBlockPlusFarTrailingHeader() }])
    const result = await parseRmrWorksheet(wb.worksheets[0])
    expect(result.coreFieldsFound).toBe(true)
    expect(result.length).toBe(12) // the fully-parsed first block only
    expect(result.possibleUnparsedBlockRow).not.toBeNull()
    expect(result.possibleUnparsedBlockRow).toBeGreaterThan(13)
  })

  it('flags a second header row that is itself too degraded to trust, without parsing it as data or dropping it silently', async () => {
    const wb = await workbookWithSheets([{ name: 'RAW MATERIAL RISKS', rows: materialBlockPlusDegradedSecondBlock() }])
    const result = await parseRmrWorksheet(wb.worksheets[0])
    expect(result.coreFieldsFound).toBe(true)
    expect(result.length).toBe(12) // first (intact) block only — degraded block's row never pushed
    expect(result.possibleUnparsedBlockRow).not.toBeNull()
  })

  it('rmrParseMetaToPlausibilityIssue converts a flagged row into a pruefen-severity rmr_possible_unparsed_block issue', () => {
    const issue = rmrParseMetaToPlausibilityIssue({ possibleUnparsedBlockRow: 39 }, 'ALT')
    expect(issue?.type).toBe('rmr_possible_unparsed_block')
    expect(issue?.severity).toBe('pruefen')
    expect(issue?.step).toBe('ALT')
    expect(issue?.explanation).toContain('39')
  })

  it('rmrParseMetaToPlausibilityIssue returns null when nothing was left unparsed', () => {
    expect(rmrParseMetaToPlausibilityIssue({ possibleUnparsedBlockRow: null }, 'ALT')).toBeNull()
  })
})

// KAR-958/P3 review fix (PR #325 finding #4, MUST-have test per task
// instruction "Test für die Zwei-Block-Kombination (intakt + degradiert-
// beitragend) MUSS dazu"): a fully intact FIRST block combined with a
// SECOND, degraded block that still contributes rows (see finding #3's
// positionNumber-missing scenario above) — the pre-fix `coreFieldsFound`
// blanket aggregate discarded BOTH blocks' rows from reconciliation the
// moment the second one degraded, even though the first block was never
// unreliable. rmrRowsForReconciliation must now filter PER BLOCK.
describe('parseRmrWorksheet + rmrRowsForReconciliation — two-block intact + degraded-contributing combination', () => {
  it('parseRmrWorksheet: both blocks land in the raw result (14 rows), but only the intact block sets coreFieldsFound-safe expectations', async () => {
    const wb = await workbookWithSheets([
      { name: 'RAW MATERIAL RISKS', rows: intactBlockPlusPositionNumberDegradedSecondBlock() },
    ])
    const result = await parseRmrWorksheet(wb.worksheets[0])

    expect(result.length).toBe(14) // 12 intact + 2 degraded — the RAW persist keeps both
    expect(result.coreFieldsFound).toBe(false) // whole-file aggregate still flips (a degraded block DID contribute)
    expect(result.degradation?.message).toContain('positionNumber')
    // The two degraded rows are visible with their reason, not silently dropped.
    const degradedRows = result.filter((r) => r.rawMaterialDesignation.startsWith('Second-Block'))
    expect(degradedRows).toHaveLength(2)
    expect(degradedRows.every((r) => r.positionNumber === '')).toBe(true)
  })

  it('rmrRowsForReconciliation: keeps the intact block\'s 12 rows, excludes the degraded block\'s 2 rows (the finding — NOT a blanket null)', async () => {
    const wb = await workbookWithSheets([
      { name: 'RAW MATERIAL RISKS', rows: intactBlockPlusPositionNumberDegradedSecondBlock() },
    ])
    const result = await parseRmrWorksheet(wb.worksheets[0])
    const reconciled = rmrRowsForReconciliation(result)

    expect(reconciled).not.toBeNull() // the fix: NOT the pre-fix blanket-null
    expect(reconciled).toHaveLength(12)
    expect(reconciled!.every((r) => !r.rawMaterialDesignation.startsWith('Second-Block'))).toBe(true)
    expect(reconciled!.every((r) => r.positionNumber !== '')).toBe(true)
  })
})

describe('parseRmrSheet — additive gate', () => {
  it('returns null when the workbook has no RMR sheet at all', async () => {
    const wb = await workbookWithSheets([{ name: 'SUMMARY', rows: [['x']] }])
    expect(await parseRmrSheet(wb)).toBeNull()
  })

  it('parses when an RMR sheet is present', async () => {
    const wb = await workbookWithSheets([{ name: 'RAW MATERIAL RISKS', rows: abbildung26MaterialRows() }])
    const result = await parseRmrSheet(wb)
    expect(result).not.toBeNull()
    expect(result!.length).toBe(12)
  })
})

describe('rmrRowsForReconciliation — tri-state (KAR-899-style contract, built in from day one)', () => {
  it('returns null for a null parse (no RMR sheet)', () => {
    expect(rmrRowsForReconciliation(null)).toBeNull()
  })

  // Deliberately the PRE-KAR-958/P3 meta shape (no `degradedRowRanges`/
  // `anyIntactBlockParsed` keys at all — `as unknown as ...`, not a direct
  // cast, since TS would otherwise reject the now-incomplete literal): this
  // is exactly what a g60_meta.rmr.parseMeta JSONB persisted BEFORE this fix
  // looks like at runtime. rmrRowsForReconciliation's backward-compat
  // fallback (degradedRowRanges === undefined) must still gate on the old
  // blanket `coreFieldsFound`.
  it('returns null for a degraded (coreFieldsFound: false) parse, NOT an empty confirmed array (legacy pre-KAR-958/P3 shape)', () => {
    const degraded = Object.assign([], {
      parseConfidence: 0,
      unmappedHeaders: ['x'],
      mappedFieldCount: 1,
      coreFieldsFound: false,
      possibleUnparsedBlockRow: null,
    }) as unknown as Parameters<typeof rmrRowsForReconciliation>[0]
    expect(rmrRowsForReconciliation(degraded)).toBeNull()
  })

  it('returns the rows array for a confirmed parse (even when legitimately empty) (legacy pre-KAR-958/P3 shape)', () => {
    const confirmed = Object.assign([], {
      parseConfidence: 1,
      unmappedHeaders: [],
      mappedFieldCount: 3,
      coreFieldsFound: true,
      possibleUnparsedBlockRow: null,
    }) as unknown as Parameters<typeof rmrRowsForReconciliation>[0]
    const rows = rmrRowsForReconciliation(confirmed)
    expect(rows).toEqual([])
    expect(rows).not.toBeNull()
  })

  // Current (post-KAR-958/P3) shape — same tri-state contract, but via the
  // per-block `degradedRowRanges` filter path instead of the legacy fallback.
  it('returns null for a degraded (coreFieldsFound: false, anyIntactBlockParsed: false) parse, NOT an empty confirmed array', () => {
    const degraded = Object.assign([], {
      parseConfidence: 0,
      unmappedHeaders: ['x'],
      mappedFieldCount: 1,
      coreFieldsFound: false,
      possibleUnparsedBlockRow: null,
      degradedRowRanges: [],
      anyIntactBlockParsed: false,
    }) as Parameters<typeof rmrRowsForReconciliation>[0]
    expect(rmrRowsForReconciliation(degraded)).toBeNull()
  })

  it('returns the rows array for a confirmed parse (even when legitimately empty)', () => {
    const confirmed = Object.assign([], {
      parseConfidence: 1,
      unmappedHeaders: [],
      mappedFieldCount: 3,
      coreFieldsFound: true,
      possibleUnparsedBlockRow: null,
      degradedRowRanges: [],
      anyIntactBlockParsed: true,
    }) as Parameters<typeof rmrRowsForReconciliation>[0]
    const rows = rmrRowsForReconciliation(confirmed)
    expect(rows).toEqual([])
    expect(rows).not.toBeNull()
  })
})

describe('rmrRowsFromPersistedMeta — JSONB rehydration tri-state (KAR-899 path)', () => {
  it('returns undefined when no rmr key was ever persisted (pre-KAR-902 file)', () => {
    expect(rmrRowsFromPersistedMeta(undefined)).toBeUndefined()
  })

  it('returns null when rmr was persisted as null (no RMR sheet in that file)', () => {
    expect(rmrRowsFromPersistedMeta(null)).toBeNull()
  })

  it('reconstructs rows from a persisted confirmed parse', () => {
    const row = blankRmrRow({ positionNumber: '1', rawMaterialDesignation: 'AL LME EU', rawMaterialQuotation: 1.99 })
    const rehydrated = rmrRowsFromPersistedMeta({
      rows: [row],
      parseMeta: {
        parseConfidence: 1,
        unmappedHeaders: [],
        mappedFieldCount: 3,
        coreFieldsFound: true,
        possibleUnparsedBlockRow: null,
        degradedRowRanges: [],
        anyIntactBlockParsed: true,
      },
    })
    expect(rehydrated).toEqual([row])
  })

  it('collapses a persisted degraded parse to null (not a confirmed-empty array)', () => {
    const rehydrated = rmrRowsFromPersistedMeta({
      rows: [],
      parseMeta: {
        parseConfidence: 0,
        unmappedHeaders: ['x'],
        mappedFieldCount: 1,
        coreFieldsFound: false,
        possibleUnparsedBlockRow: null,
        degradedRowRanges: [],
        anyIntactBlockParsed: false,
      },
    })
    expect(rehydrated).toBeNull()
  })

  // Legacy pre-KAR-958/P3 shape (no `degradedRowRanges`/`anyIntactBlockParsed`
  // keys) — a g60_meta.rmr.parseMeta JSONB persisted before this fix. Must
  // still rehydrate via the backward-compat fallback, not throw/misbehave.
  it('reconstructs rows from a persisted confirmed parse (legacy pre-KAR-958/P3 shape)', () => {
    const row = blankRmrRow({ positionNumber: '1', rawMaterialDesignation: 'AL LME EU', rawMaterialQuotation: 1.99 })
    const rehydrated = rmrRowsFromPersistedMeta({
      rows: [row],
      parseMeta: {
        parseConfidence: 1,
        unmappedHeaders: [],
        mappedFieldCount: 3,
        coreFieldsFound: true,
        possibleUnparsedBlockRow: null,
      } as unknown as PersistedRmrMeta['parseMeta'],
    })
    expect(rehydrated).toEqual([row])
  })

  it('collapses a persisted degraded parse to null (not a confirmed-empty array) (legacy pre-KAR-958/P3 shape)', () => {
    const rehydrated = rmrRowsFromPersistedMeta({
      rows: [],
      parseMeta: {
        parseConfidence: 0,
        unmappedHeaders: ['x'],
        mappedFieldCount: 1,
        coreFieldsFound: false,
        possibleUnparsedBlockRow: null,
      } as unknown as PersistedRmrMeta['parseMeta'],
    })
    expect(rehydrated).toBeNull()
  })
})

describe('validateRmrRawMaterialSurcharge — RoZ0 = Ro x Bezugsgewicht (Leitfaden [45])', () => {
  it('passes for every row of the Abbildung 26 worked example (all 12 rows)', async () => {
    const ws = await cleanRmrWorksheet()
    const parsed = await parseRmrWorksheet(ws)
    for (const [idx, row] of parsed.entries()) {
      const result = validateRmrRawMaterialSurcharge(row, idx, 'ALT')
      expect(result?.status, `row ${row.rawMaterialDesignation}`).toBe('bestanden')
    }
  })

  it('passes for the negative-quotation Steel Scrap Coil row specifically (no false negative-cost error)', () => {
    const row = blankRmrRow({
      rawMaterialDesignation: 'Steel Scrap Coil EU',
      referenceWeight: 1.0,
      rawMaterialQuotation: -0.32,
      rawMaterialSurcharge: -0.32,
    })
    const result = validateRmrRawMaterialSurcharge(row, 0, 'ALT')
    expect(result?.status).toBe('bestanden')
    expect(result?.expected).toBeCloseTo(-0.32, 4)
  })

  it('returns null (gated, not nicht_pruefbar) when rawMaterialSurcharge is empty', () => {
    const row = blankRmrRow({ rawMaterialDesignation: 'AL LME EU', referenceWeight: 15, rawMaterialQuotation: 1.99 })
    expect(validateRmrRawMaterialSurcharge(row, 0, 'ALT')).toBeNull()
  })

  it('flags a genuine deviation outside tolerance', () => {
    const row = blankRmrRow({
      rawMaterialDesignation: 'AL LME EU',
      referenceWeight: 15,
      rawMaterialQuotation: 1.99,
      rawMaterialSurcharge: 999,
    })
    const result = validateRmrRawMaterialSurcharge(row, 0, 'ALT')
    expect(result?.status).toBe('abweichung')
    expect(result?.expected).toBeCloseTo(29.85, 2)
  })

  it('reports nicht_pruefbar when Rohstoffnotierung Ro is missing but RoZ0 is filled', () => {
    const row = blankRmrRow({ rawMaterialDesignation: 'AL LME EU', referenceWeight: 15, rawMaterialSurcharge: 29.85 })
    const result = validateRmrRawMaterialSurcharge(row, 0, 'ALT')
    expect(result?.status).toBe('nicht_pruefbar')
    expect(result?.reason).toContain('Rohstoffnotierung')
  })

  it('reports nicht_pruefbar when Bezugsgewicht is missing but RoZ0 is filled', () => {
    const row = blankRmrRow({ rawMaterialDesignation: 'AL LME EU', rawMaterialQuotation: 1.99, rawMaterialSurcharge: 29.85 })
    const result = validateRmrRawMaterialSurcharge(row, 0, 'ALT')
    expect(result?.status).toBe('nicht_pruefbar')
    expect(result?.reason).toContain('Bezugsgewicht')
  })

  it('never treats a missing field as 0 (NIE null-als-0)', () => {
    const row = blankRmrRow({ rawMaterialDesignation: 'X', rawMaterialSurcharge: 0 })
    const result = validateRmrRawMaterialSurcharge(row, 0, 'ALT')
    expect(result?.status).toBe('nicht_pruefbar')
  })
})

// KAR-906/P3.2: rmr_* issues (abweichung AND nicht_pruefbar) carry an EN
// counterpart, field names sourced from the RMR canonical registry.
describe('checkRmrValidation — bilingual (KAR-906)', () => {
  it('an "abweichung" issue carries explanationEn distinct from explanation', () => {
    const row = blankRmrRow({
      rawMaterialDesignation: 'AL LME EU',
      referenceWeight: 15,
      rawMaterialQuotation: 1.99,
      rawMaterialSurcharge: 999,
    })
    const issue = checkRmrValidation({ side: 'ALT', rmrRows: [row] }).find((i) => i.type === 'rmr_raw_material_surcharge')
    expect(issue?.explanationEn).toBeTruthy()
    expect(issue?.explanationEn).not.toBe(issue?.explanation)
    expect(issue?.explanationEn).toMatch(/raw material surcharge/i)
  })

  it('a row-level "nicht_pruefbar" issue names the missing field in English', () => {
    const row = blankRmrRow({ rawMaterialDesignation: 'AL LME EU', referenceWeight: 15, rawMaterialSurcharge: 29.85 })
    const issue = checkRmrValidation({ side: 'ALT', rmrRows: [row] }).find(
      (i) => i.type === 'rmr_raw_material_surcharge_nicht_pruefbar',
    )
    expect(issue?.explanationEn).toMatch(/quotation/i)
  })

  it('the file-level "no sheet" nicht_pruefbar has an English reason', () => {
    const issue = checkRmrValidation({ side: 'ALT', rmrRows: null }).find(
      (i) => i.type === 'rmr_raw_material_surcharge_nicht_pruefbar',
    )
    expect(issue?.explanationEn).toMatch(/No RAW MATERIAL RISKS sheet detected/)
  })
})

describe('rmrParseMetaToPlausibilityIssue — bilingual (KAR-906)', () => {
  it('carries an English explanation naming the same side and row', () => {
    const issue = rmrParseMetaToPlausibilityIssue({ possibleUnparsedBlockRow: 42 }, 'NEU')
    expect(issue?.explanationEn).toContain('42')
    expect(issue?.explanationEn).toMatch(/unparsed block/i)
  })
})

describe('evaluateRmrValidation — tri-state orchestrator', () => {
  it('returns no results at all when rmrRows is undefined (no parse attempted)', () => {
    expect(evaluateRmrValidation({ side: 'ALT', rmrRows: undefined })).toEqual([])
  })

  it('returns one file-level nicht_pruefbar when rmrRows is null (no RMR sheet found)', () => {
    const results = evaluateRmrValidation({ side: 'ALT', rmrRows: null })
    expect(results).toHaveLength(1)
    expect(results[0].status).toBe('nicht_pruefbar')
    expect(results[0].rowIndex).toBe(-1)
  })

  it('evaluates every row when rmrRows is a confirmed (possibly empty) array', () => {
    const rows = [
      blankRmrRow({ rawMaterialDesignation: 'A', referenceWeight: 2, rawMaterialQuotation: 3, rawMaterialSurcharge: 6 }),
      blankRmrRow({ rawMaterialDesignation: 'B', referenceWeight: 1, rawMaterialQuotation: -1, rawMaterialSurcharge: -1 }),
    ]
    const results = evaluateRmrValidation({ side: 'NEU', rmrRows: rows })
    expect(results).toHaveLength(2)
    expect(results.every((r) => r.status === 'bestanden')).toBe(true)
  })

  it('returns an empty array for a confirmed-empty rmrRows array (nothing to check, no file-level issue)', () => {
    expect(evaluateRmrValidation({ side: 'ALT', rmrRows: [] })).toEqual([])
  })
})

describe('rmrValidationResultToPlausibilityIssue / checkRmrValidation — rmr_* namespace', () => {
  it('produces no issue for a passing result', () => {
    const row = blankRmrRow({
      rawMaterialDesignation: 'AL LME EU',
      referenceWeight: 15,
      rawMaterialQuotation: 1.99,
      rawMaterialSurcharge: 29.85,
    })
    const result = validateRmrRawMaterialSurcharge(row, 0, 'ALT')!
    expect(rmrValidationResultToPlausibilityIssue(result)).toBeNull()
  })

  it('produces a rmr_raw_material_surcharge issue with severity pruefen for a deviation', () => {
    const row = blankRmrRow({
      rawMaterialDesignation: 'AL LME EU',
      referenceWeight: 15,
      rawMaterialQuotation: 1.99,
      rawMaterialSurcharge: 999,
    })
    const result = validateRmrRawMaterialSurcharge(row, 0, 'ALT')!
    const issue = rmrValidationResultToPlausibilityIssue(result)
    expect(issue?.type).toBe('rmr_raw_material_surcharge')
    expect(issue?.severity).toBe('pruefen')
  })

  it('produces a rmr_raw_material_surcharge_nicht_pruefbar issue with severity hinweis for a missing basis', () => {
    const row = blankRmrRow({ rawMaterialDesignation: 'AL LME EU', rawMaterialSurcharge: 29.85 })
    const result = validateRmrRawMaterialSurcharge(row, 0, 'ALT')!
    const issue = rmrValidationResultToPlausibilityIssue(result)
    expect(issue?.type).toBe('rmr_raw_material_surcharge_nicht_pruefbar')
    expect(issue?.severity).toBe('hinweis')
  })

  it('checkRmrValidation end-to-end: passing rows produce zero issues, a deviation produces one', () => {
    const rows = [
      blankRmrRow({ rawMaterialDesignation: 'A', referenceWeight: 2, rawMaterialQuotation: 3, rawMaterialSurcharge: 6 }),
      blankRmrRow({ rawMaterialDesignation: 'B', referenceWeight: 2, rawMaterialQuotation: 3, rawMaterialSurcharge: 999 }),
    ]
    const issues = checkRmrValidation({ side: 'ALT', rmrRows: rows })
    expect(issues).toHaveLength(1)
    expect(issues[0].type).toBe('rmr_raw_material_surcharge')
  })
})

describe('RMR_VALIDATION_CONFIG — STARTWERT sanity', () => {
  it('has a positive relative tolerance and a non-negative absolute floor', () => {
    expect(RMR_VALIDATION_CONFIG.relativeTolerance).toBeGreaterThan(0)
    expect(RMR_VALIDATION_CONFIG.absoluteToleranceMinor).toBeGreaterThanOrEqual(0)
  })
})
