import { describe, it, expect } from 'vitest'
import type { QAFRow } from '@/lib/qaf-parser'
import type { QafSummary, QafSummaryKey } from '../types'
import { compareQafPair, type QafFileParsed } from '../compare'
import { metricsParse } from './summary-fixtures'
import { DEFAULT_ENGINE_CONFIG, type EngineConfig } from '../engine-config'
import { buildFormulaProvenance } from '../formula-engine'

function summary(values: Partial<Record<QafSummaryKey, string | null>>): QafSummary {
  const keys: QafSummaryKey[] = [
    'partNumber', 'quotationDate', 'supplier', 'partName', 'variant',
    'project', 'requestVersion', 'changeIndex', 'supplierNo',
    'peakVolumeYear', 'productionStartSop', 'deliverySite', 'shiftsPerWeek', // KAR-910
    'plannedCapacity', 'lotSize', // QVS-P4
  ]
  const out = {} as QafSummary
  for (const k of keys) out[k] = { value: values[k] ?? null, cell: null }
  return out
}

function mkRow(o: Partial<QAFRow> = {}): QAFRow {
  return {
    positionsnummer: '1', teilebenennung: 'Teil', prozessbezeichnung: 'Montage',
    bezeichnungAnlage: 'Anlage', standort: 'Werk', beschaffungswaehrung: 'EUR', angebotswaehrung: 'EUR',
    zykluszeit: 10, teileProZyklus: 1, anzahlMA: 1, lohnkosten: 30, lohnzuschlagssaetze: 10, mss: 50,
    ruestkosten: 0, fek: 0, rfgk: 0, fk: 100, wechselkurs: 1, anzahlProAngebotsteil: 1, fkAW: 100,
    // ausschuss/ausschusskosten default to a business-rule-consistent pair
    // (KAR-901/P2.2: fkAW * (1/(1-ausschuss/100)-1) = 100 * 0 = 0) so the
    // "clean pair" smoke tests below stay genuinely clean under the new
    // rule_calc_ausschuss_fertigung row check — individual tests still
    // override both fields together where they need a specific, deliberately
    // inconsistent pair (see the Summen-Rekonziliation describe block).
    ausschuss: 0, ausschusskosten: 0, ...o,
  }
}

function file(id: string, date: string, steps: QAFRow[], pn = '7490365'): QafFileParsed {
  return {
    ref: { id, fileName: `${id}.xlsx`, quotationDate: date },
    summary: summary({ partNumber: pn, quotationDate: date, partName: 'Blende' }),
    steps,
  }
}

describe('compareQafPair', () => {
  const alt = file('old', '2023-01-01', [
    mkRow({ positionsnummer: '1', prozessbezeichnung: 'Montage', fk: 100 }),
    mkRow({ positionsnummer: '2', prozessbezeichnung: 'Schweissen', bezeichnungAnlage: 'Schweisszelle', fk: 200 }),
  ])
  const neu = file('new', '2024-01-01', [
    mkRow({ positionsnummer: '1', prozessbezeichnung: 'Montage', fk: 130 }),
    mkRow({ positionsnummer: '2', prozessbezeichnung: 'Schweissen', bezeichnungAnlage: 'Schweisszelle', fk: 500 }),
    mkRow({ positionsnummer: '3', prozessbezeichnung: 'Pruefen', bezeichnungAnlage: 'Pruefstand', fk: 40 }),
  ])
  const r = compareQafPair(alt, neu)

  it('carries the part number and ALT/NEU refs', () => {
    expect(r.partNumber).toBe('7490365')
    expect(r.altRef.id).toBe('old')
    expect(r.neuRef.id).toBe('new')
  })

  it('produces a comparison per step incl. the new step', () => {
    expect(r.stepComparisons.length).toBe(3)
    expect(r.structureChanges.new).toContain('3 Pruefen')
  })

  it('feeds matched deltas into root-cause (biggest driver = Schweissen)', () => {
    expect(r.rootCause.topAbsoluteDrivers[0].stepLabel).toBe('2 Schweissen')
    expect(r.rootCause.topAbsoluteDrivers[0].deltaAbsolute).toBe(300)
  })

  it('runs plausibility (clean pair → no critical issues)', () => {
    expect(r.plausibility.every((i) => i.severity !== 'kritisch')).toBe(true)
  })

  it('excludes a different part number from comparison via plausibility critical', () => {
    const bad = compareQafPair(alt, file('x', '2024-01-01', neu.steps, '9999999'))
    expect(bad.plausibility.some((i) => i.type === 'part_number_mismatch' && i.severity === 'kritisch')).toBe(true)
  })

  it('returns empty summaryDiffs when no summary metrics were parsed', () => {
    expect(r.summaryDiffs).toEqual([])
  })
})

describe('compareQafPair — summary level', () => {
  const alt = {
    ...file('old', '2023-01-01', [mkRow()]),
    summaryMetrics: metricsParse({ materialCosts: 10, quotationPrice: 20 }),
  }
  const neu = {
    ...file('new', '2024-01-01', [mkRow()]),
    summaryMetrics: metricsParse({ materialCosts: 16, quotationPrice: 20 }),
  }
  const r = compareQafPair(alt, neu)

  it('diffs the summary metrics of both files', () => {
    const mat = r.summaryDiffs.find((d) => d.metricKey === 'materialCosts')
    expect(mat?.deltaAbsolute).toBe(6)
    expect(mat?.status).toBe('kritisch_50')
    expect(r.summaryDiffs.find((d) => d.metricKey === 'quotationPrice')?.status).toBe('konstant')
  })

  it('skips metrics missing on both sides', () => {
    expect(r.summaryDiffs.some((d) => d.metricKey === 'customsIncluded')).toBe(false)
  })
})

describe('compareQafPair — Fehlerreport-Regel-Engine R1-R6 (KAR-889)', () => {
  it('merges rule-engine violations into the same plausibility array (R2, mandatory field blank)', () => {
    const alt = file('old', '2023-01-01', [mkRow()])
    const neu = file('new', '2024-01-01', [mkRow({ positionsnummer: '' })])
    const r = compareQafPair(alt, neu)
    const r2 = r.plausibility.find((i) => i.type === 'rule_r2_mandatory_warn')
    expect(r2).toBeDefined()
    expect(r2?.severity).toBe('kritisch')
  })

  it('stays warn-only by default (weich starten) — issue present but does not remove/alter step diffs', () => {
    const alt = file('old', '2023-01-01', [mkRow()])
    const neu = file('new', '2024-01-01', [mkRow({ positionsnummer: '' })])
    const r = compareQafPair(alt, neu)
    expect(r.stepComparisons.length).toBe(1)
    expect(r.plausibility.some((i) => i.type === 'rule_r2_mandatory_warn')).toBe(true)
  })

  it('clean pair produces no rule-engine violations', () => {
    const alt = file('old', '2023-01-01', [mkRow()])
    const neu = file('new', '2024-01-01', [mkRow()])
    const r = compareQafPair(alt, neu)
    // 'rule_r' (not the broader 'rule_') scopes this to the R1-R6 Fehlerreport-
    // Regel-Engine namespace specifically (rule_r2_*/rule_r3_*/rule_r4_*) —
    // KAR-901/P2.2 added a SIBLING rule namespace (rule_calc_*, the row-level
    // Business-Rule-Reconciliation-Engine) that legitimately also starts with
    // "rule_"; this test's intent has always been "no R1-R6 violations", not
    // "no issue whose type happens to start with rule_".
    expect(r.plausibility.some((i) => i.type.startsWith('rule_r'))).toBe(false)
  })

  it('result.ruleEnforcement reflects the resolved config (default warn)', () => {
    const alt = file('old', '2023-01-01', [mkRow()])
    const neu = file('new', '2024-01-01', [mkRow()])
    const r = compareQafPair(alt, neu)
    expect(r.ruleEnforcement).toBe('warn')
  })

  it('result.ruleEnforcement reflects an explicit block override', () => {
    const alt = file('old', '2023-01-01', [mkRow()])
    const neu = file('new', '2024-01-01', [mkRow()])
    const r = compareQafPair(alt, neu, { ruleEngineConfig: { ruleEnforcement: 'block' } })
    expect(r.ruleEnforcement).toBe('block')
  })
})

// Reviewer finding (KAR-889 adversarial review, "block-Modus blockt nicht
// wirklich"): in block mode, the specific FieldDiff for a mandatory field
// flagged invalid/empty by R2 must show a blocked state instead of a
// (possibly wrong) computed delta — not just an issue_type suffix nobody
// reads before the number. Only the affected field/step, not the whole
// comparison (Master-Prompt §8).
describe('compareQafPair — R2 block mode actually gates the affected FieldDiff', () => {
  it('block mode: the specific blocked field gets status "blockiert" with nulled deltas', () => {
    const alt = file('old', '2023-01-01', [mkRow({ fkAW: 100 })])
    const neu = file('new', '2024-01-01', [mkRow({ fkAW: null })]) // mandatory numeric field, genuinely blank
    const r = compareQafPair(alt, neu, { ruleEngineConfig: { ruleEnforcement: 'block' } })
    const fkAWDiff = r.stepComparisons[0].fieldDiffs.find((d) => d.field === 'fkAW')
    expect(fkAWDiff?.status).toBe('blockiert')
    expect(fkAWDiff?.deltaAbsolute).toBeNull()
    expect(fkAWDiff?.deltaPercent).toBeNull()
    expect(fkAWDiff?.deltaPercentagePoints).toBeNull()
    // altValue/neuValue stay as-is (nicht_anwendbar pattern, KAR-891) — the
    // still-known side (ALT=100) is not hidden, only the delta is suppressed.
    expect(fkAWDiff?.altValue).toBe(100)
    expect(fkAWDiff?.neuValue).toBeNull()
  })

  it('block mode: only the specific blocked field is affected, sibling fields on the same step stay normal', () => {
    const alt = file('old', '2023-01-01', [mkRow({ fkAW: 100, fk: 90 })])
    const neu = file('new', '2024-01-01', [mkRow({ fkAW: null, fk: 120 })])
    const r = compareQafPair(alt, neu, { ruleEngineConfig: { ruleEnforcement: 'block' } })
    const fkDiff = r.stepComparisons[0].fieldDiffs.find((d) => d.field === 'fk')
    expect(fkDiff?.status).not.toBe('blockiert')
    expect(fkDiff?.deltaAbsolute).toBe(30)
  })

  it('warn mode (default): the same scenario does NOT block the FieldDiff, keeps the computed status', () => {
    const alt = file('old', '2023-01-01', [mkRow({ fkAW: 100 })])
    const neu = file('new', '2024-01-01', [mkRow({ fkAW: null })])
    const r = compareQafPair(alt, neu) // default warn
    const fkAWDiff = r.stepComparisons[0].fieldDiffs.find((d) => d.field === 'fkAW')
    expect(fkAWDiff?.status).not.toBe('blockiert')
    expect(fkAWDiff?.status).toBe('entfallen')
  })

  it('block mode: a blocked field is excluded from root-cause drivers (deltaAbsolute is null)', () => {
    const alt = file('old', '2023-01-01', [mkRow({ fkAW: 100 })])
    const neu = file('new', '2024-01-01', [mkRow({ fkAW: null })])
    const r = compareQafPair(alt, neu, { ruleEngineConfig: { ruleEnforcement: 'block' } })
    expect(r.rootCause.topAbsoluteDrivers.some((d) => d.field === 'fkAW')).toBe(false)
  })
})

// Summen-Rekonziliation Summary <-> Fertigungskosten-Zeilen (KAR-887/P0.2):
// wired into compareQafPair alongside checkPlausibility + rule-engine, same
// qaf_plausibility_issue path (issue_type namespace recon_*).
describe('compareQafPair — Summen-Rekonziliation (KAR-887)', () => {
  it('runs per-file reconciliation and surfaces a broken cascade as a kritisch issue', () => {
    const alt = {
      ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]),
      summaryMetrics: metricsParse({ materialCosts: 400, manufacturingCosts: 300, totalProductionCosts: 900 }), // broken: should be 700
    }
    const neu = {
      ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]),
      summaryMetrics: metricsParse({ materialCosts: 400, manufacturingCosts: 300, totalProductionCosts: 700 }),
    }
    const r = compareQafPair(alt, neu)
    const issue = r.plausibility.find((i) => i.type === 'recon_herstellkosten_cascade' && i.step === 'ALT')
    expect(issue?.severity).toBe('kritisch')
    expect(r.plausibility.some((i) => i.type === 'recon_herstellkosten_cascade' && i.step === 'NEU')).toBe(false)
  })

  it('flags a Fertigungskosten detail-sum mismatch as pruefen', () => {
    const alt = {
      ...file('old', '2023-01-01', [mkRow({ fkAW: 100 })]),
      summaryMetrics: metricsParse({ manufacturingCosts: 500 }), // rows sum to 100, way off
    }
    const neu = { ...file('new', '2024-01-01', [mkRow({ fkAW: 100 })]), summaryMetrics: metricsParse({ manufacturingCosts: 100 }) }
    const r = compareQafPair(alt, neu)
    const issue = r.plausibility.find((i) => i.type === 'recon_fk_detail_sum' && i.step === 'ALT')
    expect(issue?.severity).toBe('pruefen')
  })

  it('a fully specified, internally consistent summary produces no reconciliation issues at all (not even nicht_pruefbar)', () => {
    const fullMetrics = metricsParse({
      materialCosts: 400,
      manufacturingCosts: 300,
      totalProductionCosts: 700,
      devicesAndTools: 50,
      scrapMaterial: 10,
      scrapManufacturing: 5,
      totalCosts: 765,
      otherSurcharges: 35,
      quotationBasePrice: 800,
      rawMaterialPriceShareMaterial: 10,
      rawMaterialPriceShareEnergy: 5,
      customsSupplierToBMW: 2,
      transportSupplierToBMW: 3,
      quotationPrice: 820,
    })
    const alt = { ...file('old', '2023-01-01', [mkRow({ fkAW: 300, ausschusskosten: 5 })]), summaryMetrics: fullMetrics }
    const neu = { ...file('new', '2024-01-01', [mkRow({ fkAW: 300, ausschusskosten: 5 })]), summaryMetrics: fullMetrics }
    const r = compareQafPair(alt, neu)
    expect(r.plausibility.some((i) => i.type.startsWith('recon_'))).toBe(false)
  })

  const materialRow = (materialCost: number): NonNullable<QafFileParsed['materialRows']>[number] => ({
      positionNumber: '1',
      partDesignation: 'Teil',
      materialDesignation: 'Kunststoffgehaeuse',
      supplier: '',
      technicalFunction: '',
      countryOfOrigin: '',
      htsCode: '',
      unitOfMeasure: 'Unit_(purchased_part)',
      procurementCurrency: 'EUR',
      costPerUnitBw: null,
      quotationCurrency: 'EUR',
      exchangeRate: null,
      packagingCostPerUnit: null,
      transportCostPerUnit: null,
      customsCbamCostPerUnit: null,
      overheadCost: null,
      costPerUnitAw: null,
      referenceQuantity: null,
      netQuantity: null,
      rebate: null,
      quantityPerQuotedPart: null,
      materialCost,
      scrapRate: null,
      scrapCost: null,
      rawMaterialDesignation: '',
      referenceWeight: null,
      rawMaterialQuotation: null,
      rawMaterialSurcharge: null,
      packagingCostOffer: null, // KAR-910
      sourceCells: {},
      normalized: {},
      rawText: {},
    })

  it('KAR-897/P1.6: materialRows threads through to material_detail_sum reconciliation', () => {
    const alt = {
      ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]),
      summaryMetrics: metricsParse({ materialCosts: 999 }), // way off
      materialRows: [materialRow(100)],
    }
    const neu = {
      ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]),
      summaryMetrics: metricsParse({ materialCosts: 100 }),
      materialRows: [materialRow(100)],
    }
    const r = compareQafPair(alt, neu)
    const issue = r.plausibility.find((i) => i.type === 'recon_material_detail_sum')
    expect(issue?.step).toBe('ALT')
    expect(issue?.severity).toBe('pruefen')
    expect(r.plausibility.some((i) => i.type === 'recon_material_detail_sum' && i.step === 'NEU')).toBe(false)
  })

  it('materialRows omitted (undefined) on both sides produces no material_detail_sum issue at all', () => {
    const alt = { ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]), summaryMetrics: metricsParse({ materialCosts: 100 }) }
    const neu = { ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]), summaryMetrics: metricsParse({ materialCosts: 100 }) }
    const r = compareQafPair(alt, neu)
    expect(r.plausibility.some((i) => i.type.startsWith('recon_material_'))).toBe(false)
  })

  it('Block 2: führt Material-Zuordnungen samt Reconciliation, wenn beide Seiten Materialzeilen tragen', () => {
    // Die Blattzeile kommt aus der Zell-Provenienz — ohne sie fällt die
    // Position aus der Zuordnung (keine stabile Sortiergrundlage).
    const mit = (cost: number, zeile: number) => ({
      ...materialRow(cost),
      sourceCells: { materialCost: `MATERIAL!W${zeile}` },
    })
    const alt = {
      ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]),
      summaryMetrics: metricsParse({ materialCosts: 100 }),
      materialRows: [mit(100, 11)],
    }
    const neu = {
      ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]),
      summaryMetrics: metricsParse({ materialCosts: 150 }),
      materialRows: [mit(150, 11)],
    }
    const r = compareQafPair(alt, neu)
    expect(r.materialEffects).toBeDefined()
    expect(r.materialEffects!.buckets.price_change.count).toBe(1)
    expect(r.materialEffects!.buckets.price_change.delta).toBeCloseTo(50, 6)
    // Das Blattdelta der Positionsebenen-Reconciliation ist der
    // Summary-Materialkosten-Diff; hier erklärt die eine Position ihn exakt.
    expect(r.materialEffects!.reconciliation.sheetDelta).toBeCloseTo(50, 6)
    expect(r.materialEffects!.reconciliation.passed).toBe(true)
  })

  it('Block 2: lässt materialEffects weg, wenn eine Seite keinen Material-Parse hat', () => {
    const alt = file('old', '2023-01-01', [mkRow({ fkAW: 300 })])
    const neu = {
      ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]),
      materialRows: [materialRow(100)],
    }
    expect(compareQafPair(alt, neu).materialEffects).toBeUndefined()
  })

  it('KAR-898/P1.7: sbmRows threads through to sbm_detail_sum reconciliation', () => {
    const sbmRow = (totalToolFixtureCostAw: number): NonNullable<QafFileParsed['sbmRows']>[number] => ({
      positionNumber: '1',
      verrechnungsform: 'SBM',
      toolFixtureType: 'Spritzgießwerkzeuge',
      componentDesignation: '',
      cavityConfiguration: '',
      stageCount: null,
      stageCountActive: null, // KAR-910
      stageCountEmpty: null, // KAR-910
      serviceLifeCycles: null,
      toolDeploymentDate: '',
      orderLeadTimeWeeks: null,
      manufacturerLocation: '',
      procurementCurrency: 'EUR',
      toolFixtureCostBw: null,
      bmwPartNumber: '',
      componentIndex: '',
      length: null,
      width: null,
      height: null,
      sheetThickness: null,
      componentSurface: '',
      toolDimensions: '',
      toolWeight: null,
      toolConcept: '',
      connectionConcept: '',
      slidersGeneral: null,
      slidersHydraulic: null,
      slidersAngled: null,
      slidersJaw: null,
      slidersTotal: null,
      remarks: '',
      quotationCurrency: 'EUR',
      exchangeRate: null,
      toolFixtureCount: null,
      totalToolFixtureCostAw,
      totalSbmToolCostAw: null,
      devicesFollowupMaintenancePerUnit: null,
      sourceCells: {},
      normalized: {},
      rawText: {},
      deviceClassification: null,
    })
    const alt = {
      ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]),
      summaryMetrics: metricsParse({ devicesAndTools: 999 }), // way off
      sbmRows: [sbmRow(100)],
    }
    const neu = {
      ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]),
      summaryMetrics: metricsParse({ devicesAndTools: 100 }),
      sbmRows: [sbmRow(100)],
    }
    const r = compareQafPair(alt, neu)
    const issue = r.plausibility.find((i) => i.type === 'recon_sbm_detail_sum')
    expect(issue?.step).toBe('ALT')
    expect(issue?.severity).toBe('pruefen')
    expect(r.plausibility.some((i) => i.type === 'recon_sbm_detail_sum' && i.step === 'NEU')).toBe(false)
  })

  it('sbmRows omitted (undefined) on both sides produces no sbm_detail_sum issue at all', () => {
    const alt = { ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]), summaryMetrics: metricsParse({ devicesAndTools: 100 }) }
    const neu = { ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]), summaryMetrics: metricsParse({ devicesAndTools: 100 }) }
    const r = compareQafPair(alt, neu)
    expect(r.plausibility.some((i) => i.type.startsWith('recon_sbm_'))).toBe(false)
  })

  it('KAR-903/P2.4: logisticsRows threads through to logistics_transport_detail_sum reconciliation (transport-only sum, post-merge adversarial-review fix confidence 82 — see reconciliation.ts module header)', () => {
    // transportCostPerPart is the field actually summed (fix); logisticsCostPer
    // DeliverySite deliberately carries a DIFFERENT value (180, the combined
    // Transport+Verpackung+Vorverpackung) so this test cannot pass by
    // accidentally summing the wrong field.
    const logisticsRow = (transportCostPerPart: number): NonNullable<QafFileParsed['logisticsRows']>[number] => ({
      positionNumber: '1',
      deliverySite: 'Spartanburg',
      totalVolume: 50000,
      quotationCurrency: 'EUR',
      transportCostPerPart,
      deliveryTerms: 'FCA',
      packagingCostPerPart: 50,
      surchargeKeyPackaging: 'VERP',
      prePackagingCostPerPart: 30,
      surchargeKeyPrePackaging: 'LVVV',
      logisticsCostPerDeliverySite: 180,
      customsCostPerPart: 0.4,
      customsTariffNumber: '8708299000',
      customsCostPerDeliverySite: 0.4,
      sourceCells: {},
      normalized: {},
      rawText: {},
    })
    const alt = {
      ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]),
      summaryMetrics: metricsParse({ transportSupplierToBMW: 999 }), // way off
      logisticsRows: [logisticsRow(100)],
    }
    const neu = {
      ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]),
      summaryMetrics: metricsParse({ transportSupplierToBMW: 100 }),
      logisticsRows: [logisticsRow(100)],
    }
    const r = compareQafPair(alt, neu)
    const issue = r.plausibility.find((i) => i.type === 'recon_logistics_transport_detail_sum')
    expect(issue?.step).toBe('ALT')
    expect(issue?.severity).toBe('pruefen')
    expect(r.plausibility.some((i) => i.type === 'recon_logistics_transport_detail_sum' && i.step === 'NEU')).toBe(false)
  })

  it('logisticsRows omitted (undefined) on both sides produces no logistics_* reconciliation issue at all', () => {
    const alt = { ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]), summaryMetrics: metricsParse({ transportSupplierToBMW: 100 }) }
    const neu = { ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]), summaryMetrics: metricsParse({ transportSupplierToBMW: 100 }) }
    const r = compareQafPair(alt, neu)
    expect(r.plausibility.some((i) => i.type.startsWith('recon_logistics_'))).toBe(false)
  })

  it('an internally consistent summary with only partial metrics reports the ungrounded stages as hinweis, never a false pass or violation', () => {
    const alt = {
      ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]),
      summaryMetrics: metricsParse({ materialCosts: 400, manufacturingCosts: 300, totalProductionCosts: 700 }),
    }
    const neu = {
      ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]),
      summaryMetrics: metricsParse({ materialCosts: 400, manufacturingCosts: 300, totalProductionCosts: 700 }),
    }
    const r = compareQafPair(alt, neu)
    const reconIssues = r.plausibility.filter((i) => i.type.startsWith('recon_'))
    expect(reconIssues.length).toBeGreaterThan(0)
    expect(reconIssues.every((i) => i.type.endsWith('_nicht_pruefbar') && i.severity === 'hinweis')).toBe(true)
  })

  it('no summaryMetrics on either side (rehydrate-without-override shape) -> no crash, no reconciliation issues', () => {
    const alt = file('old', '2023-01-01', [mkRow()])
    const neu = file('new', '2024-01-01', [mkRow()])
    const r = compareQafPair(alt, neu)
    expect(r.plausibility.some((i) => i.type.startsWith('recon_'))).toBe(false)
  })

  it('is deterministic: two runs on identical input produce identical reconciliation issues', () => {
    const alt = {
      ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]),
      summaryMetrics: metricsParse({ materialCosts: 400, manufacturingCosts: 300, totalProductionCosts: 900 }),
    }
    const neu = {
      ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]),
      summaryMetrics: metricsParse({ materialCosts: 400, manufacturingCosts: 300, totalProductionCosts: 700 }),
    }
    const r1 = compareQafPair(alt, neu).plausibility.filter((i) => i.type.startsWith('recon_'))
    const r2 = compareQafPair(alt, neu).plausibility.filter((i) => i.type.startsWith('recon_'))
    expect(r1).toEqual(r2)
  })
})

// KAR-902/P2.3: rmr-parser.ts's Rohstoffzuschlag-Validierung threads through
// compareQafPair exactly like materialRows/sbmRows above, but with its OWN
// `rmr_*` issue namespace (not `recon_*`/`rule_calc_*`) — see rmr-parser.ts
// module header for why it is not folded into reconciliation.ts/
// business-rules.ts.
describe('compareQafPair — Rohstoffzuschlag-Validierung (KAR-902/P2.3)', () => {
  const rmrRow = (
    overrides: Partial<NonNullable<QafFileParsed['rmrRows']>[number]> = {},
  ): NonNullable<QafFileParsed['rmrRows']>[number] => ({
    positionNumber: '1',
    rawMaterialDesignation: 'AL LME EU',
    referenceWeight: 15,
    rawMaterialKey: 'A013',
    rawMaterialQuotation: 1.99,
    rawMaterialSurcharge: 29.85,
    settlementModel: '',
    indexDesignation: '',
    indexQuotation: null,
    bmwParticipationRate: null, // allow-customer-string
    threshold: null,
    remark: '',
    sourceCells: {},
    normalized: {},
    rawText: {},
    blockType: 'unbekannt',
    ...overrides,
  })

  it('KAR-902/P2.3: rmrRows threads through to the rmr_raw_material_surcharge check', () => {
    const alt = { ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]), rmrRows: [rmrRow({ rawMaterialSurcharge: 999 })] }
    const neu = { ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]), rmrRows: [rmrRow()] }
    const r = compareQafPair(alt, neu)
    const issue = r.plausibility.find((i) => i.type === 'rmr_raw_material_surcharge')
    expect(issue?.step).toBe('ALT')
    expect(issue?.severity).toBe('pruefen')
    expect(r.plausibility.some((i) => i.type === 'rmr_raw_material_surcharge' && i.step === 'NEU')).toBe(false)
  })

  it('a negative Rohstoffnotierung (Steel Scrap Coil-style credit) produces no false deviation', () => {
    const scrapRow = rmrRow({
      rawMaterialDesignation: 'Steel Scrap Coil EU',
      referenceWeight: 1,
      rawMaterialQuotation: -0.32,
      rawMaterialSurcharge: -0.32,
    })
    const alt = { ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]), rmrRows: [scrapRow] }
    const neu = { ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]), rmrRows: [scrapRow] }
    const r = compareQafPair(alt, neu)
    expect(r.plausibility.some((i) => i.type.startsWith('rmr_'))).toBe(false)
  })

  it('rmrRows omitted (undefined) on both sides produces no rmr_* issue at all — additivity for files without an RMR sheet', () => {
    const alt = file('old', '2023-01-01', [mkRow({ fkAW: 300 })])
    const neu = file('new', '2024-01-01', [mkRow({ fkAW: 300 })])
    const r = compareQafPair(alt, neu)
    expect(r.plausibility.some((i) => i.type.startsWith('rmr_'))).toBe(false)
  })

  it('rmrRows: null (RMR sheet detected but header too degraded) reports a file-level hinweis, not a silent skip', () => {
    const alt = { ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]), rmrRows: null }
    const neu = { ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]), rmrRows: undefined }
    const r = compareQafPair(alt, neu)
    const issue = r.plausibility.find((i) => i.type === 'rmr_raw_material_surcharge_nicht_pruefbar' && i.step === 'ALT')
    expect(issue?.severity).toBe('hinweis')
    expect(r.plausibility.some((i) => i.type.startsWith('rmr_') && i.step === 'NEU')).toBe(false)
  })

  // KAR-902 follow-up (adversarial-review finding, confidence 85, "Zwei-
  // Block-Layout erzeugt stillen Datenverlust/Korruption"): rmrParseMeta
  // threads through to the rmr_possible_unparsed_block Sicherheitsnetz issue.
  it('rmrParseMeta.possibleUnparsedBlockRow threads through to a pruefen-severity rmr_possible_unparsed_block issue', () => {
    const alt = {
      ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]),
      rmrParseMeta: {
        parseConfidence: 1,
        unmappedHeaders: [],
        mappedFieldCount: 12,
        coreFieldsFound: true,
        possibleUnparsedBlockRow: 39,
        degradedRowRanges: [],
        anyIntactBlockParsed: true,
      },
    }
    const neu = { ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]) }
    const r = compareQafPair(alt, neu)
    const issue = r.plausibility.find((i) => i.type === 'rmr_possible_unparsed_block')
    expect(issue?.step).toBe('ALT')
    expect(issue?.severity).toBe('pruefen')
    expect(issue?.explanation).toContain('39')
  })

  it('rmrParseMeta omitted (undefined) produces no rmr_possible_unparsed_block issue — additivity for pre-fix/rehydrated callers', () => {
    const alt = file('old', '2023-01-01', [mkRow({ fkAW: 300 })])
    const neu = file('new', '2024-01-01', [mkRow({ fkAW: 300 })])
    const r = compareQafPair(alt, neu)
    expect(r.plausibility.some((i) => i.type === 'rmr_possible_unparsed_block')).toBe(false)
  })

  it('rmrParseMeta with possibleUnparsedBlockRow: null produces no issue (clean multi-block parse)', () => {
    const alt = {
      ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]),
      rmrParseMeta: {
        parseConfidence: 1,
        unmappedHeaders: [],
        mappedFieldCount: 12,
        coreFieldsFound: true,
        possibleUnparsedBlockRow: null,
        degradedRowRanges: [],
        anyIntactBlockParsed: true,
      },
    }
    const neu = file('new', '2024-01-01', [mkRow({ fkAW: 300 })])
    const r = compareQafPair(alt, neu)
    expect(r.plausibility.some((i) => i.type === 'rmr_possible_unparsed_block')).toBe(false)
  })
})

// KAR-903/P2.4: logistics-parser.ts's LOGISTICS&CUSTOM cost-formula +
// Incoterm-domain validation threads through compareQafPair exactly like
// rmr-parser.ts's Rohstoffzuschlag-Validierung above, but with its OWN
// `log_*` issue namespace — see logistics-parser.ts module header for why it
// is not folded into reconciliation.ts/business-rules.ts.
describe('compareQafPair — LOGISTICS&CUSTOM-Validierung (KAR-903/P2.4)', () => {
  const logisticsRow = (
    overrides: Partial<NonNullable<QafFileParsed['logisticsRows']>[number]> = {},
  ): NonNullable<QafFileParsed['logisticsRows']>[number] => ({
    positionNumber: '1',
    deliverySite: 'Spartanburg',
    totalVolume: 50000,
    quotationCurrency: 'EUR',
    transportCostPerPart: 2,
    deliveryTerms: 'FCA',
    packagingCostPerPart: 0.5,
    surchargeKeyPackaging: 'VERP',
    prePackagingCostPerPart: 0.3,
    surchargeKeyPrePackaging: 'LVVV',
    logisticsCostPerDeliverySite: 2.8,
    customsCostPerPart: 0.4,
    customsTariffNumber: '8708299000',
    customsCostPerDeliverySite: 0.4,
    sourceCells: {},
    normalized: {},
    rawText: {},
    ...overrides,
  })

  it('KAR-903/P2.4: logisticsRows threads through to the log_calc_cost_per_delivery_site check', () => {
    const alt = { ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]), logisticsRows: [logisticsRow({ logisticsCostPerDeliverySite: 999 })] }
    const neu = { ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]), logisticsRows: [logisticsRow()] }
    const r = compareQafPair(alt, neu)
    const issue = r.plausibility.find((i) => i.type === 'log_calc_cost_per_delivery_site')
    expect(issue?.step).toBe('ALT')
    expect(issue?.severity).toBe('pruefen')
    expect(r.plausibility.some((i) => i.type === 'log_calc_cost_per_delivery_site' && i.step === 'NEU')).toBe(false)
  })

  it('an unknown Incoterm produces a log_incoterm_domain pruefen-hint, never a hard error', () => {
    const alt = { ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]), logisticsRows: [logisticsRow({ deliveryTerms: 'EXW' })] }
    const neu = { ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]), logisticsRows: [logisticsRow()] }
    const r = compareQafPair(alt, neu)
    const issue = r.plausibility.find((i) => i.type === 'log_incoterm_domain')
    expect(issue?.step).toBe('ALT')
    expect(issue?.severity).toBe('pruefen')
    expect(issue?.explanation).toContain('EXW')
  })

  it('logisticsRows omitted (undefined) on both sides produces no log_* issue at all — additivity for files without a LOGISTICS sheet', () => {
    const alt = file('old', '2023-01-01', [mkRow({ fkAW: 300 })])
    const neu = file('new', '2024-01-01', [mkRow({ fkAW: 300 })])
    const r = compareQafPair(alt, neu)
    expect(r.plausibility.some((i) => i.type.startsWith('log_'))).toBe(false)
  })

  it('logisticsRows: null (LOGISTICS sheet detected but header too degraded) reports file-level hinweis issues, not a silent skip', () => {
    const alt = { ...file('old', '2023-01-01', [mkRow({ fkAW: 300 })]), logisticsRows: null }
    const neu = { ...file('new', '2024-01-01', [mkRow({ fkAW: 300 })]), logisticsRows: undefined }
    const r = compareQafPair(alt, neu)
    const issues = r.plausibility.filter((i) => i.type.startsWith('log_') && i.step === 'ALT')
    expect(issues.length).toBeGreaterThan(0)
    expect(issues.every((i) => i.severity === 'hinweis')).toBe(true)
    expect(r.plausibility.some((i) => i.type.startsWith('log_') && i.step === 'NEU')).toBe(false)
  })
})

// P1.5/KAR-896: options.engineConfig generalizes the KAR-889 ruleEngineConfig
// pattern to every config section. Default behaviour (no options.engineConfig
// at all) must stay byte-identical — the acceptance criterion of the backlog
// item — and the two pre-existing discrete options (ruleEngineConfig/
// reconciliationConfig) must keep taking precedence for backward compat.
describe('compareQafPair — options.engineConfig (KAR-896)', () => {
  const alt = file('old', '2023-01-01', [mkRow({ fk: 100 })])
  const neu = file('new', '2024-01-01', [mkRow({ fk: 122 })])

  it('result.engineConfig defaults to DEFAULT_ENGINE_CONFIG when no options are passed', () => {
    const r = compareQafPair(alt, neu)
    expect(r.engineConfig).toEqual(DEFAULT_ENGINE_CONFIG)
  })

  it('a custom differBands section changes the resulting FieldDiff status (regression-proof injection)', () => {
    // +22% is 'auffaellig_10' under the default bands (>10%, <=25%).
    const withDefault = compareQafPair(alt, neu)
    expect(withDefault.stepComparisons[0].fieldDiffs.find((d) => d.field === 'fk')?.status).toBe('auffaellig_10')

    const wideBands: EngineConfig = {
      ...DEFAULT_ENGINE_CONFIG,
      differBands: { auffaellig10: 0.3, auffaellig25: 0.4, kritisch50: 0.6 },
    }
    const withWideBands = compareQafPair(alt, neu, { engineConfig: wideBands })
    expect(withWideBands.stepComparisons[0].fieldDiffs.find((d) => d.field === 'fk')?.status).toBe('anstieg')
  })

  it('a custom ruleEngine section inside engineConfig is honoured when no discrete ruleEngineConfig is passed', () => {
    const altBlank = file('old', '2023-01-01', [mkRow({ fkAW: 100 })])
    const neuBlank = file('new', '2024-01-01', [mkRow({ fkAW: null })])
    const blockViaEngineConfig: EngineConfig = { ...DEFAULT_ENGINE_CONFIG, ruleEngine: { ruleEnforcement: 'block' } }
    const r = compareQafPair(altBlank, neuBlank, { engineConfig: blockViaEngineConfig })
    expect(r.ruleEnforcement).toBe('block')
    expect(r.stepComparisons[0].fieldDiffs.find((d) => d.field === 'fkAW')?.status).toBe('blockiert')
  })

  it('a discrete ruleEngineConfig option wins over engineConfig.ruleEngine (backward-compat precedence)', () => {
    const altBlank = file('old', '2023-01-01', [mkRow({ fkAW: 100 })])
    const neuBlank = file('new', '2024-01-01', [mkRow({ fkAW: null })])
    const warnViaEngineConfig: EngineConfig = { ...DEFAULT_ENGINE_CONFIG, ruleEngine: { ruleEnforcement: 'block' } }
    const r = compareQafPair(altBlank, neuBlank, {
      engineConfig: warnViaEngineConfig,
      ruleEngineConfig: { ruleEnforcement: 'warn' },
    })
    expect(r.ruleEnforcement).toBe('warn')
    expect(r.engineConfig.ruleEngine.ruleEnforcement).toBe('warn')
  })

  it('result.engineConfig reflects the effective config actually used, including discrete overrides', () => {
    const r = compareQafPair(alt, neu, { ruleEngineConfig: { ruleEnforcement: 'block' } })
    expect(r.engineConfig.ruleEngine).toEqual({ ruleEnforcement: 'block' })
    // untouched sections stay at their DEFAULT_ENGINE_CONFIG value
    expect(r.engineConfig.matching).toEqual(DEFAULT_ENGINE_CONFIG.matching)
  })

  it('a custom matching section changes match cascade behaviour (threaded into matchStepsWithPins)', () => {
    // Different position numbers, process/machine names 0.75-similar (6/8
    // shared tokens, Jaccard) — above the default candidateThreshold (0.7)
    // but below a stricter custom one (0.9).
    const altFuzzy = file('old2', '2023-01-01', [
      mkRow({ positionsnummer: '20', prozessbezeichnung: 'Presse Zelle Eins Zwei Drei Vier Fuenf', bezeichnungAnlage: 'Presse Zelle Eins Zwei Drei Vier Fuenf' }),
    ])
    const neuFuzzy = file('new2', '2024-01-01', [
      mkRow({ positionsnummer: '21', prozessbezeichnung: 'Presse Zelle Eins Zwei Drei Vier Sechs', bezeichnungAnlage: 'Presse Zelle Eins Zwei Drei Vier Sechs' }),
    ])

    const withDefault = compareQafPair(altFuzzy, neuFuzzy)
    expect(withDefault.stepComparisons).toHaveLength(1)
    expect(withDefault.stepComparisons[0].match.matchStatus).toBe('candidate_match')

    const strictMatching: EngineConfig = {
      ...DEFAULT_ENGINE_CONFIG,
      matching: { similarityThreshold: 0.6, candidateThreshold: 0.9, costDivergenceReviewThreshold: 0.25 },
    }
    const withStrict = compareQafPair(altFuzzy, neuFuzzy, { engineConfig: strictMatching })
    // 0.75 similarity no longer clears the stricter 0.9 candidateThreshold —
    // the pair falls apart into an unmatched new_step + removed_step instead
    // of one candidate_match comparison.
    expect(withStrict.stepComparisons).toHaveLength(2)
    expect(withStrict.stepComparisons.map((s) => s.match.matchStatus).sort()).toEqual(['new_step', 'removed_step'])
  })
})

// KAR-900/P2.1: end-to-end wiring — formula-derived FieldDiff overrides
// surface both on the step comparison AND as a qaf_plausibility_issue-shaped
// finding, gated by engineConfig.formulaEngine.enabled.
describe('compareQafPair — formula engine wiring (KAR-900/P2.1)', () => {
  const P = (raw: string) => buildFormulaProvenance(raw)

  it('a formula-changed-value-unchanged step field surfaces as formel_geaendert on the FieldDiff AND a pruefen plausibility issue', () => {
    const alt = file('old', '2023-01-01', [mkRow({ fk: 100, formulas: { fk: P('B2+D2') } })])
    const neu = file('new', '2024-01-01', [mkRow({ fk: 100, formulas: { fk: P('B2+E2') } })])
    const r = compareQafPair(alt, neu)

    const fk = r.stepComparisons[0].fieldDiffs.find((d) => d.field === 'fk')
    expect(fk?.status).toBe('formel_geaendert')

    const issue = r.plausibility.find((i) => i.type === 'formel_geaendert_wert_gleich')
    expect(issue?.severity).toBe('pruefen')
    expect(issue?.field).toBe('fk')
  })

  it('a formula replaced by a hardcoded constant surfaces as a kritisch plausibility issue', () => {
    const alt = file('old', '2023-01-01', [mkRow({ fk: 100, formulas: { fk: P('B2+D2') } })])
    const neu = file('new', '2024-01-01', [mkRow({ fk: 999 })])
    const r = compareQafPair(alt, neu)

    const fk = r.stepComparisons[0].fieldDiffs.find((d) => d.field === 'fk')
    expect(fk?.status).toBe('formel_zu_konstante')

    const issue = r.plausibility.find((i) => i.type === 'formel_zu_konstante')
    expect(issue?.severity).toBe('kritisch')
  })

  it('a formula-changed summary metric surfaces a plausibility issue too', () => {
    const altMetrics = { ...metricsParse({ totalProductionCosts: 16 }), formulas: { totalProductionCosts: P('N11+N12') } }
    const neuMetrics = { ...metricsParse({ totalProductionCosts: 16 }), formulas: { totalProductionCosts: P('N11+N12+0') } }
    const alt = { ...file('old', '2023-01-01', [mkRow()]), summaryMetrics: altMetrics }
    const neu = { ...file('new', '2024-01-01', [mkRow()]), summaryMetrics: neuMetrics }
    const r = compareQafPair(alt, neu)

    const diff = r.summaryDiffs.find((d) => d.metricKey === 'totalProductionCosts')
    expect(diff?.status).toBe('formel_geaendert')
    expect(r.plausibility.some((i) => i.type === 'formel_geaendert_wert_gleich' && i.field === 'totalProductionCosts')).toBe(true)
  })

  it('engineConfig.formulaEngine.enabled=false disables every formula-derived finding', () => {
    const alt = file('old', '2023-01-01', [mkRow({ fk: 100, formulas: { fk: P('B2+D2') } })])
    const neu = file('new', '2024-01-01', [mkRow({ fk: 100, formulas: { fk: P('B2+E2') } })])
    const disabled: EngineConfig = { ...DEFAULT_ENGINE_CONFIG, formulaEngine: { enabled: false } }
    const r = compareQafPair(alt, neu, { engineConfig: disabled })

    const fk = r.stepComparisons[0].fieldDiffs.find((d) => d.field === 'fk')
    expect(fk?.status).toBe('konstant')
    expect(r.plausibility.some((i) => i.type.startsWith('formel_'))).toBe(false)
  })

  it('rows without any formula provenance produce no formula-engine findings (no regression)', () => {
    const alt = file('old', '2023-01-01', [mkRow({ fk: 100 })])
    const neu = file('new', '2024-01-01', [mkRow({ fk: 100 })])
    const r = compareQafPair(alt, neu)
    expect(r.plausibility.some((i) => i.type.startsWith('formel_'))).toBe(false)
  })
})

describe('compareQafPair — Untrusted-Excel-Hardening plausibility bridge (KAR-914/P4.4)', () => {
  it('surfaces a security_macro_present issue per side when workbookSafety.macroPresent is set', () => {
    const alt = { ...file('old', '2023-01-01', [mkRow()]), workbookSafety: { macroPresent: true, externalLinksPresent: false } }
    const neu = file('new', '2024-01-01', [mkRow()])
    const r = compareQafPair(alt, neu)
    const altIssue = r.plausibility.find((i) => i.type === 'security_macro_present')
    expect(altIssue?.severity).toBe('pruefen')
    expect(altIssue?.step).toContain('ALT')
    expect(altIssue?.step).toContain('old.xlsx')
    expect(altIssue?.explanationEn).toBeTruthy()
  })

  it('surfaces a security_external_links issue for NEU when set', () => {
    const alt = file('old', '2023-01-01', [mkRow()])
    const neu = { ...file('new', '2024-01-01', [mkRow()]), workbookSafety: { macroPresent: false, externalLinksPresent: true } }
    const r = compareQafPair(alt, neu)
    const neuIssue = r.plausibility.find((i) => i.type === 'security_external_links')
    expect(neuIssue?.step).toContain('NEU')
    expect(neuIssue?.step).toContain('new.xlsx')
  })

  it('emits no security_* issues when workbookSafety is absent (pre-KAR-914/rehydrated callers, no regression)', () => {
    const alt = file('old', '2023-01-01', [mkRow()])
    const neu = file('new', '2024-01-01', [mkRow()])
    const r = compareQafPair(alt, neu)
    expect(r.plausibility.some((i) => i.type.startsWith('security_'))).toBe(false)
  })

  it('emits no security_* issues when both flags are false', () => {
    const alt = { ...file('old', '2023-01-01', [mkRow()]), workbookSafety: { macroPresent: false, externalLinksPresent: false } }
    const neu = file('new', '2024-01-01', [mkRow()])
    const r = compareQafPair(alt, neu)
    expect(r.plausibility.some((i) => i.type.startsWith('security_'))).toBe(false)
  })
})

describe('compareQafPair — MANUFACTURING candidate-sheet visibility (KAR-927/P0.2)', () => {
  it('surfaces a parser_ignored_manufacturing_candidate_sheets issue per side when ignoredCandidateSheets is set', () => {
    const alt = {
      ...file('old', '2023-01-01', [mkRow()]),
      manufacturingParseMeta: { parseConfidence: 1, unmappedHeaders: [], mappedFieldCount: 22, ignoredCandidateSheets: ['Manufacturing costs'] },
    }
    const neu = file('new', '2024-01-01', [mkRow()])
    const r = compareQafPair(alt, neu)
    const issue = r.plausibility.find((i) => i.type === 'parser_ignored_manufacturing_candidate_sheets')
    expect(issue?.severity).toBe('pruefen')
    expect(issue?.step).toBe('ALT')
    expect(issue?.explanation).toContain('Manufacturing costs')
  })

  it('emits no parser_ignored_manufacturing_candidate_sheets issue when manufacturingParseMeta is absent (rehydrated/pre-KAR-927 callers)', () => {
    const alt = file('old', '2023-01-01', [mkRow()])
    const neu = file('new', '2024-01-01', [mkRow()])
    const r = compareQafPair(alt, neu)
    expect(r.plausibility.some((i) => i.type === 'parser_ignored_manufacturing_candidate_sheets')).toBe(false)
  })

  it('emits no parser_ignored_manufacturing_candidate_sheets issue when ignoredCandidateSheets is empty (standard QAF — no spam)', () => {
    const alt = {
      ...file('old', '2023-01-01', [mkRow()]),
      manufacturingParseMeta: { parseConfidence: 1, unmappedHeaders: [], mappedFieldCount: 22 },
    }
    const neu = file('new', '2024-01-01', [mkRow()])
    const r = compareQafPair(alt, neu)
    expect(r.plausibility.some((i) => i.type === 'parser_ignored_manufacturing_candidate_sheets')).toBe(false)
  })

  it('surfaces a parser_ignored_material_candidate_sheets issue per side when materialParseMeta.ignoredCandidateSheets is set', () => {
    const alt = { ...file('old', '2023-01-01', [mkRow()]), materialParseMeta: { ignoredCandidateSheets: ['Material '] } }
    const neu = file('new', '2024-01-01', [mkRow()])
    const r = compareQafPair(alt, neu)
    const issue = r.plausibility.find((i) => i.type === 'parser_ignored_material_candidate_sheets')
    expect(issue?.severity).toBe('pruefen')
    expect(issue?.step).toBe('ALT')
    expect(issue?.explanation).toContain('Material ')
  })

  it('emits no parser_ignored_material_candidate_sheets issue when materialParseMeta is absent or empty', () => {
    const altAbsent = file('old', '2023-01-01', [mkRow()])
    const altEmpty = { ...file('old', '2023-01-01', [mkRow()]), materialParseMeta: {} }
    const neu = file('new', '2024-01-01', [mkRow()])
    expect(compareQafPair(altAbsent, neu).plausibility.some((i) => i.type === 'parser_ignored_material_candidate_sheets')).toBe(false)
    expect(compareQafPair(altEmpty, neu).plausibility.some((i) => i.type === 'parser_ignored_material_candidate_sheets')).toBe(false)
  })

  it('surfaces a parser_ignored_sbm_candidate_sheets issue per side when sbmParseMeta.ignoredCandidateSheets is set', () => {
    const alt = file('old', '2023-01-01', [mkRow()])
    const neu = { ...file('new', '2024-01-01', [mkRow()]), sbmParseMeta: { ignoredCandidateSheets: ['SBM-DEVICES-FWZ (EN)'] } } // allow-customer-string
    const r = compareQafPair(alt, neu)
    const issue = r.plausibility.find((i) => i.type === 'parser_ignored_sbm_candidate_sheets')
    expect(issue?.severity).toBe('pruefen')
    expect(issue?.step).toBe('NEU')
    expect(issue?.explanation).toContain('SBM-DEVICES-FWZ (EN)') // allow-customer-string
  })

  it('emits no parser_ignored_sbm_candidate_sheets issue when sbmParseMeta is absent or empty', () => {
    const alt = file('old', '2023-01-01', [mkRow()])
    const neuAbsent = file('new', '2024-01-01', [mkRow()])
    const neuEmpty = { ...file('new', '2024-01-01', [mkRow()]), sbmParseMeta: {} }
    expect(compareQafPair(alt, neuAbsent).plausibility.some((i) => i.type === 'parser_ignored_sbm_candidate_sheets')).toBe(false)
    expect(compareQafPair(alt, neuEmpty).plausibility.some((i) => i.type === 'parser_ignored_sbm_candidate_sheets')).toBe(false)
  })
})
