// Unit tests for material-differ.ts (KAR-938 / Multi-QAF-Programm P2.4).
//
// FIXTURE-DATEN-REGEL (same discipline as container-differ.test.ts's own
// header comment, KAR-929 adversarial review F4 — merge-blocker class):
// every code, number, label, and cell reference below is FREE INVENTION, not
// lifted from any real analyzed workbook.
//
// Synthetic, one scenario per KAR-938 task-spec requirement, plus
// determinism. Real-file regression lives in material-differ.real-files.test.ts
// (env-gated).

import { describe, it, expect } from 'vitest'
import type { MultiQafContainer, VariantMatrixRow } from '../types'
import type { VariantMatchResult } from '../variant-matcher'
import {
  DEFAULT_MATERIAL_DIFFER_CONFIG,
  diffMaterial,
  type MaterialDifferConfig,
} from '../material-differ'

// ── Minimal fixture builders ────────────────────────────────────────────────

function emptyContainer(overrides: Partial<MultiQafContainer> = {}): MultiQafContainer {
  return {
    sourceWorkbook: { fileName: null, fileHash: null, sheetNames: [], hiddenSheetNames: [] },
    detectedTemplateFamily: 'unknown',
    multiQafVersion: null,
    underlyingQafVersion: null,
    language: 'unknown',
    currencies: [],
    sharedMetadata: {},
    variantDimensions: [],
    activeVariants: [],
    inactiveVariants: [],
    auxiliaryScenarios: [],
    helperColumns: [],
    sharedMaterialMaster: [],
    sharedManufacturingProfiles: [],
    sharedToolingData: [],
    setupCostProfiles: [],
    variantProfileBindings: [],
    summaryAggregation: { perVariant: [], formulaLineageNotes: [] },
    templateFingerprint: { family: 'unknown', structuralHash: null, variantColumnCount: 0, headerRowCount: 0, knownProfile: null, classification: null },
    warnings: [],
    confidence: 0.5,
    summaryMoneyRowsByVariant: {},
    ...overrides,
  }
}

function materialRow(overrides: Partial<VariantMatrixRow> = {}): VariantMatrixRow {
  return {
    canonicalComponentIdentity: overrides.canonicalComponentIdentity ?? '10::bracket',
    sourceRow: overrides.sourceRow ?? 10,
    unitCost: overrides.unitCost ?? { value: 1, currency: 'EUR' },
    procurementCurrency: overrides.procurementCurrency ?? 'EUR',
    offerCurrency: overrides.offerCurrency ?? 'EUR',
    exchangeRate: overrides.exchangeRate ?? null,
    logisticsOrDuty: overrides.logisticsOrDuty ?? null,
    materialOverhead: overrides.materialOverhead ?? null,
    quantityFactorByVariant: overrides.quantityFactorByVariant ?? {},
    effectiveCostByVariant: overrides.effectiveCostByVariant ?? {},
    formulaAndCachedValue: overrides.formulaAndCachedValue ?? null,
    sourceCells: overrides.sourceCells ?? [],
    validationStatus: overrides.validationStatus ?? 'ok',
  }
}

function matched(leftId: string, rightId: string): VariantMatchResult {
  return {
    kind: 'matched',
    leftIndex: 0,
    leftId,
    rightIndex: 0,
    rightId,
    stage: 'raw_exact',
    confidence: 1,
    evidence: [],
    explanation: 'test fixture',
    matchedDimensionCount: 1,
  }
}

function ambiguous(leftIds: string[], rightIds: string[]): VariantMatchResult {
  return {
    kind: 'ambiguous',
    leftIndices: leftIds.map((_, i) => i),
    leftIds,
    rightIndices: rightIds.map((_, i) => i),
    rightIds,
    candidates: [],
    reviewRelevant: true,
    explanation: 'test fixture ambiguous',
  }
}

// ── 11.1 Shared component level ─────────────────────────────────────────────

describe('diffMaterial — unit cost value change (shared component level)', () => {
  it('reports ONE finding for a shared unit-cost change, with impacts for every affected matched variant', () => {
    const row = { canonicalComponentIdentity: '10::bracket', quantityFactorByVariant: { V1: 2, V2: 3 }, effectiveCostByVariant: { V1: 2, V2: 3 } }
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ ...row, unitCost: { value: 1, currency: 'EUR' } })] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ ...row, canonicalComponentIdentity: '10::bracket', quantityFactorByVariant: { W1: 2, W2: 3 }, effectiveCostByVariant: { W1: 2.2, W2: 3.3 }, unitCost: { value: 1.1, currency: 'EUR' } })] })
    const matchResult = [matched('V1', 'W1'), matched('V2', 'W2')]

    const diff = diffMaterial(alt, neu, matchResult)

    expect(diff.sharedComponents.unitCostValueChanges).toHaveLength(1)
    const finding = diff.sharedComponents.unitCostValueChanges[0]!
    expect(finding.altValue).toBe(1)
    expect(finding.neuValue).toBe(1.1)
    expect(finding.deltaAbsolute).toBeCloseTo(0.1, 4)
    expect(finding.impact.affectedVariantIds).toEqual(['W1', 'W2'])
    expect(finding.impact.impacts).toHaveLength(2)
    const w1 = finding.impact.impacts.find((i) => i.variantId === 'W1')!
    expect(w1.factor).toBe(2)
    expect(w1.impact).toBeCloseTo(0.2, 4)
    const w2 = finding.impact.impacts.find((i) => i.variantId === 'W2')!
    expect(w2.factor).toBe(3)
    expect(w2.impact).toBeCloseTo(0.3, 4)
    expect(finding.impact.aggregates).toEqual([{ currency: 'EUR', totalImpact: expect.closeTo(0.5, 3), variantCount: 2 }])

    // Never duplicated as N independent variant-level findings (KERN-REGEL).
    expect(diff.sharedComponents.unitCostValueChanges).toHaveLength(1)
  })

  it('reports unit-cost currency change SEPARATELY from a value change', () => {
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ unitCost: { value: 5, currency: 'EUR' } })] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ unitCost: { value: 5, currency: 'USD' } })] })

    const diff = diffMaterial(alt, neu, [])

    expect(diff.sharedComponents.unitCostValueChanges).toEqual([])
    expect(diff.sharedComponents.unitCostCurrencyChanges).toHaveLength(1)
    expect(diff.sharedComponents.unitCostCurrencyChanges[0]!.altCurrency).toBe('EUR')
    expect(diff.sharedComponents.unitCostCurrencyChanges[0]!.neuCurrency).toBe('USD')
  })
})

describe('diffMaterial — exchange rate + money fields', () => {
  it('reports an exchange-rate change without fabricating a numeric impact', () => {
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ exchangeRate: 1.1 })] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ exchangeRate: 1.25 })] })

    const diff = diffMaterial(alt, neu, [])

    expect(diff.sharedComponents.exchangeRateChanges).toHaveLength(1)
    const finding = diff.sharedComponents.exchangeRateChanges[0]!
    expect(finding.altValue).toBe(1.1)
    expect(finding.neuValue).toBe(1.25)
    expect(finding.impact.impacts).toEqual([])
  })

  it('reports a logistics/duty value change with a computed impact', () => {
    const row = { canonicalComponentIdentity: '20::gasket', quantityFactorByVariant: { V1: 4 }, effectiveCostByVariant: { V1: 4 } }
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ ...row, logisticsOrDuty: { value: 0.5, currency: 'EUR' } })] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ ...row, logisticsOrDuty: { value: 0.8, currency: 'EUR' } })] })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1')])

    expect(diff.sharedComponents.logisticsOrDutyChanges).toHaveLength(1)
    const finding = diff.sharedComponents.logisticsOrDutyChanges[0]!
    expect(finding.valueChanged).toBe(true)
    expect(finding.currencyChanged).toBe(false)
    expect(finding.impact.impacts[0]!.impact).toBeCloseTo(1.2, 4) // 4 * 0.3
  })

  it('reports a material-overhead change', () => {
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ materialOverhead: { value: 0.1, currency: 'EUR' } })] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ materialOverhead: { value: 0.2, currency: 'EUR' } })] })

    const diff = diffMaterial(alt, neu, [])

    expect(diff.sharedComponents.materialOverheadChanges).toHaveLength(1)
    expect(diff.sharedComponents.materialOverheadChanges[0]!.valueChanged).toBe(true)
  })
})

describe('diffMaterial — formula changes (formula-engine.ts reuse)', () => {
  it('flags formula-to-constant (formel_zu_konstante) as its own finding', () => {
    const alt = emptyContainer({
      sharedMaterialMaster: [materialRow({ unitCost: { value: 2, currency: 'EUR' }, formulaAndCachedValue: { formula: 'Material!R10', cachedValue: 2 } })],
    })
    const neu = emptyContainer({
      sharedMaterialMaster: [materialRow({ unitCost: { value: 2, currency: 'EUR' }, formulaAndCachedValue: { formula: null, cachedValue: 2 } })],
    })

    const diff = diffMaterial(alt, neu, [])

    expect(diff.sharedComponents.formulaChanges).toHaveLength(1)
    expect(diff.sharedComponents.formulaChanges[0]!.comparisonKind).toBe('formel_zu_konstante')
    // No accompanying value-change finding — the cached value did not move.
    expect(diff.sharedComponents.unitCostValueChanges).toEqual([])
  })

  it('flags a changed formula with an UNCHANGED cached value as its own finding (silent-risk case)', () => {
    const alt = emptyContainer({
      sharedMaterialMaster: [materialRow({ unitCost: { value: 3, currency: 'EUR' }, formulaAndCachedValue: { formula: 'Material!R10', cachedValue: 3 } })],
    })
    const neu = emptyContainer({
      sharedMaterialMaster: [materialRow({ unitCost: { value: 3, currency: 'EUR' }, formulaAndCachedValue: { formula: 'Material!R11', cachedValue: 3 } })],
    })

    const diff = diffMaterial(alt, neu, [])

    expect(diff.sharedComponents.formulaChanges).toHaveLength(1)
    expect(diff.sharedComponents.formulaChanges[0]!.comparisonKind).toBe('formel_geaendert_wert_gleich')
  })

  it('does not flag a benign added formula (no formula -> formula, same value)', () => {
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ unitCost: { value: 3, currency: 'EUR' }, formulaAndCachedValue: null })] })
    const neu = emptyContainer({
      sharedMaterialMaster: [materialRow({ unitCost: { value: 3, currency: 'EUR' }, formulaAndCachedValue: { formula: 'Material!R10', cachedValue: 3 } })],
    })

    const diff = diffMaterial(alt, neu, [])

    expect(diff.sharedComponents.formulaChanges).toEqual([])
  })

  // Adversarial review KAR-938/#310 F4: a sub-0.0001 (raw) value delta used
  // to diverge between compareFormulaPair's raw-epsilon(1e-9) comparison and
  // computeNumericDelta's round-to-4dp-THEN-epsilon comparison on the SAME
  // altRow.unitCost.value/neuRow.unitCost.value pair — formulaChanges would
  // report 'formel_geaendert_wert_geaendert' while unitCostValueChanges (the
  // finding formulaChanges' own doc comment says carries the real number)
  // stayed empty because ucDelta.status was 'konstant', losing the
  // commercial-impact number everywhere. Both comparisons must now agree.
  it('harmonizes formula-vs-value comparison precision — a sub-rounding-threshold delta never produces a formula "wert_geaendert" finding with zero impact anywhere', () => {
    const alt = emptyContainer({
      sharedMaterialMaster: [materialRow({ unitCost: { value: 1, currency: 'EUR' }, formulaAndCachedValue: { formula: 'Material!R10', cachedValue: 1 } })],
    })
    const neu = emptyContainer({
      // Raw delta 0.00003 — compareFormulaPair's own raw epsilon (1e-9) would
      // see this as "changed"; computeNumericDelta rounds it to 0.0000 first
      // ('konstant'). The two must not disagree.
      sharedMaterialMaster: [materialRow({ unitCost: { value: 1.00003, currency: 'EUR' }, formulaAndCachedValue: { formula: 'Material!R11', cachedValue: 1.00003 } })],
    })

    const diff = diffMaterial(alt, neu, [])

    // computeNumericDelta's own verdict for this pair (source of truth).
    expect(diff.sharedComponents.unitCostValueChanges).toEqual([])

    // The formula finding must now AGREE — "value unchanged", not "value
    // changed" — instead of silently contradicting unitCostValueChanges.
    expect(diff.sharedComponents.formulaChanges).toHaveLength(1)
    expect(diff.sharedComponents.formulaChanges[0]!.comparisonKind).toBe('formel_geaendert_wert_gleich')
  })

  // Companion case: a delta that clears the rounding threshold on BOTH sides
  // must still be reported as a real value change (the harmonization must not
  // suppress genuine changes, only align the boundary).
  it('still reports formel_geaendert_wert_geaendert for a delta that clears the rounding threshold', () => {
    const alt = emptyContainer({
      sharedMaterialMaster: [materialRow({ unitCost: { value: 1, currency: 'EUR' }, formulaAndCachedValue: { formula: 'Material!R10', cachedValue: 1 } })],
    })
    const neu = emptyContainer({
      sharedMaterialMaster: [materialRow({ unitCost: { value: 1.5, currency: 'EUR' }, formulaAndCachedValue: { formula: 'Material!R11', cachedValue: 1.5 } })],
    })

    const diff = diffMaterial(alt, neu, [])

    expect(diff.sharedComponents.unitCostValueChanges).toHaveLength(1)
    expect(diff.sharedComponents.formulaChanges).toHaveLength(1)
    expect(diff.sharedComponents.formulaChanges[0]!.comparisonKind).toBe('formel_geaendert_wert_geaendert')
  })
})

describe('diffMaterial — row identity change (label changed, same position)', () => {
  it('pairs a renamed row via its stable position and reports ONE row-identity finding, never add+remove', () => {
    // Labels are a minor revision of the SAME component (sub-variant letter
    // only) — normalizedSimilarity('bracket aluminium type a', 'bracket
    // aluminium type b') ≈ 0.958, well above rowRenameLabelSimilarityThreshold
    // (0.8 default) — a genuine "label geändert bei gleicher Position" case.
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '10::bracket aluminium type a' })] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '10::bracket aluminium type b' })] })

    const diff = diffMaterial(alt, neu, [])

    expect(diff.sharedComponents.rowIdentityChanges).toHaveLength(1)
    const finding = diff.sharedComponents.rowIdentityChanges[0]!
    expect(finding.position).toBe('10')
    expect(finding.altLabel).toBe('bracket aluminium type a')
    expect(finding.neuLabel).toBe('bracket aluminium type b')

    // Not ALSO reported as a wholly added/removed component.
    expect(diff.variantAllocation.findings.filter((f) => f.kind === 'component_added' || f.kind === 'component_removed')).toEqual([])
  })

  it('does NOT pair a same-position rename when the position is ambiguous (2 candidates on either side)', () => {
    const alt = emptyContainer({
      sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '10::bracket alu' }), materialRow({ canonicalComponentIdentity: '10::bracket alu#2', sourceRow: 11 })],
    })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '10::bracket steel' })] })

    const diff = diffMaterial(alt, neu, [])

    expect(diff.sharedComponents.rowIdentityChanges).toEqual([])
  })

  // Adversarial review KAR-938/#310 F1: a reused position number alone must
  // NEVER be enough to pair two rows as a "rename" — two genuinely different
  // components (a bracket replaced by an unrelated sensor) can share a
  // position by coincidence (real split-rows disambiguated only by an
  // appended #N ordinal). Below rowRenameLabelSimilarityThreshold the pair
  // must fall through to the ordinary removed+added path and become eligible
  // for detectSubstitutions instead of a false, unflagged "label rename".
  it('does NOT pair a same-position rename when the labels are dissimilar — falls through to removed+added, eligible for substitution detection', () => {
    const alt = emptyContainer({
      sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '10::bracket mount type', quantityFactorByVariant: { V1: 1 }, effectiveCostByVariant: { V1: 1 } })],
    })
    const neu = emptyContainer({
      sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '10::sensor mount type', quantityFactorByVariant: { V1: 1 }, effectiveCostByVariant: { V1: 1 } })],
    })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1')])

    // Never silently merged into a harmless label rename.
    expect(diff.sharedComponents.rowIdentityChanges).toEqual([])

    // The real substitution fact is not lost: it surfaces as an ordinary
    // removed+added pair AND — because the labels still clear the (lower)
    // substitution bar — a reviewRelevant SubstitutionSuspectedFinding,
    // never silently absorbed as a same-component rename.
    expect(diff.variantAllocation.findings.some((f) => f.kind === 'component_removed' && f.canonicalComponentIdentity === '10::bracket mount type')).toBe(true)
    expect(diff.variantAllocation.findings.some((f) => f.kind === 'component_added' && f.canonicalComponentIdentity === '10::sensor mount type')).toBe(true)

    expect(diff.variantAllocation.substitutionsSuspected).toHaveLength(1)
    const finding = diff.variantAllocation.substitutionsSuspected[0]!
    expect(finding.removed.canonicalComponentIdentity).toBe('10::bracket mount type')
    expect(finding.addedCandidates.map((c) => c.canonicalComponentIdentity)).toEqual(['10::sensor mount type'])
    expect(finding.reviewRelevant).toBe(true)
  })

  // A truly unrelated pair sharing a position (e.g. bracket vs. sensor, zero
  // label overlap) must not even clear the (lower) substitution bar — it
  // stays a plain removed+added pair with no substitution hint at all,
  // rather than a fabricated "rename".
  it('a wholly unrelated pair sharing a position produces plain removed+added, no rename, no substitution hint', () => {
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '10::bracket' })] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '10::sensor' })] })

    const diff = diffMaterial(alt, neu, [])

    expect(diff.sharedComponents.rowIdentityChanges).toEqual([])
    expect(diff.variantAllocation.substitutionsSuspected).toEqual([])
  })
})

// ── 11.2 Variant allocation level ───────────────────────────────────────────

describe('diffMaterial — factor change (variant allocation level)', () => {
  it('reports a factor change for only the ONE variant whose factor moved, not for a sibling with an unchanged factor', () => {
    const row = { canonicalComponentIdentity: '30::screw', quantityFactorByVariant: { V1: 2, V2: 4 }, effectiveCostByVariant: { V1: 2, V2: 4 } }
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow(row)] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ ...row, quantityFactorByVariant: { V1: 3, V2: 4 }, effectiveCostByVariant: { V1: 3, V2: 4 } })] })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1'), matched('V2', 'V2')])

    expect(diff.variantAllocation.findings).toHaveLength(1)
    const finding = diff.variantAllocation.findings[0]!
    expect(finding.kind).toBe('factor_changed')
    expect(finding.neuVariantId).toBe('V1')
    expect(finding.altFactor).toBe(2)
    expect(finding.neuFactor).toBe(3)

    // The shared unit cost did not change -> excluded from the impact used
    // for the OTHER variant too (V2's factor is unchanged, no finding at all).
    expect(diff.variantAllocation.findings.some((f) => f.neuVariantId === 'V2')).toBe(false)
  })

  it('a simultaneous shared-price change on a row where one variant also changed its factor never fabricates that variant\'s Ebene-1 impact', () => {
    const alt = emptyContainer({
      sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '31::nut', unitCost: { value: 1, currency: 'EUR' }, quantityFactorByVariant: { V1: 2, V2: 2 }, effectiveCostByVariant: { V1: 2, V2: 2 } })],
    })
    const neu = emptyContainer({
      sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '31::nut', unitCost: { value: 1.5, currency: 'EUR' }, quantityFactorByVariant: { V1: 5, V2: 2 }, effectiveCostByVariant: { V1: 7.5, V2: 3 } })],
    })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1'), matched('V2', 'V2')])

    const ucFinding = diff.sharedComponents.unitCostValueChanges[0]!
    const v1Impact = ucFinding.impact.impacts.find((i) => i.variantId === 'V1')!
    expect(v1Impact.impact).toBeNull()
    expect(v1Impact.reason).toBe('factor_changed')
    const v2Impact = ucFinding.impact.impacts.find((i) => i.variantId === 'V2')!
    expect(v2Impact.impact).toBeCloseTo(1, 4) // factor 2 * delta 0.5
  })
})

describe('diffMaterial — blank/not-applicable vs explicit-zero transitions (all 4 combinations)', () => {
  it('blank -> explicit 0: reports "included" (not conflated with a factor change)', () => {
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ quantityFactorByVariant: {}, effectiveCostByVariant: {} })] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ quantityFactorByVariant: { V1: 0 }, effectiveCostByVariant: { V1: 0 } })] })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1')])

    expect(diff.variantAllocation.findings).toHaveLength(1)
    expect(diff.variantAllocation.findings[0]!.kind).toBe('included')
    expect(diff.variantAllocation.findings[0]!.neuFactor).toBe(0)
    expect(diff.variantAllocation.findings[0]!.altApplicable).toBe(false)
  })

  it('explicit 0 -> blank: reports "excluded"', () => {
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ quantityFactorByVariant: { V1: 0 }, effectiveCostByVariant: { V1: 0 } })] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ quantityFactorByVariant: {}, effectiveCostByVariant: {} })] })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1')])

    expect(diff.variantAllocation.findings).toHaveLength(1)
    expect(diff.variantAllocation.findings[0]!.kind).toBe('excluded')
    expect(diff.variantAllocation.findings[0]!.altFactor).toBe(0)
    expect(diff.variantAllocation.findings[0]!.neuApplicable).toBe(false)
  })

  it('blank -> nonzero factor: reports "included"', () => {
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ quantityFactorByVariant: {}, effectiveCostByVariant: {} })] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ quantityFactorByVariant: { V1: 5 }, effectiveCostByVariant: { V1: 5 } })] })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1')])

    expect(diff.variantAllocation.findings[0]!.kind).toBe('included')
    expect(diff.variantAllocation.findings[0]!.neuFactor).toBe(5)
  })

  it('nonzero factor -> blank: reports "excluded"', () => {
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ quantityFactorByVariant: { V1: 5 }, effectiveCostByVariant: { V1: 5 } })] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ quantityFactorByVariant: {}, effectiveCostByVariant: {} })] })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1')])

    expect(diff.variantAllocation.findings[0]!.kind).toBe('excluded')
    expect(diff.variantAllocation.findings[0]!.altFactor).toBe(5)
  })

  it('explicit 0 -> nonzero factor (both applicable): reports "factor_changed", never "included"', () => {
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ quantityFactorByVariant: { V1: 0 }, effectiveCostByVariant: { V1: 0 } })] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ quantityFactorByVariant: { V1: 5 }, effectiveCostByVariant: { V1: 5 } })] })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1')])

    expect(diff.variantAllocation.findings[0]!.kind).toBe('factor_changed')
  })
})

describe('diffMaterial — added / removed components', () => {
  it('reports a wholly new row as component_added for every matched variant referencing it', () => {
    const alt = emptyContainer({ sharedMaterialMaster: [] })
    const neu = emptyContainer({
      sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '40::clip', quantityFactorByVariant: { V1: 1 }, effectiveCostByVariant: { V1: 1 } })],
    })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1'), matched('V2', 'V2')])

    expect(diff.variantAllocation.findings).toHaveLength(1)
    expect(diff.variantAllocation.findings[0]!.kind).toBe('component_added')
    expect(diff.variantAllocation.findings[0]!.neuVariantId).toBe('V1')
    expect(diff.variantAllocation.findings[0]!.alt).toBeNull()
  })

  it('reports a wholly removed row as component_removed for every matched variant that had it', () => {
    const alt = emptyContainer({
      sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '41::pin', quantityFactorByVariant: { V1: 1 }, effectiveCostByVariant: { V1: 1 } })],
    })
    const neu = emptyContainer({ sharedMaterialMaster: [] })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1')])

    expect(diff.variantAllocation.findings).toHaveLength(1)
    expect(diff.variantAllocation.findings[0]!.kind).toBe('component_removed')
    expect(diff.variantAllocation.findings[0]!.neu).toBeNull()
  })
})

describe('diffMaterial — substitution suspected', () => {
  it('flags a removed + added row with similar labels as a review-only substitution candidate', () => {
    const alt = emptyContainer({
      sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '50::bracket aluminium left', quantityFactorByVariant: { V1: 1 }, effectiveCostByVariant: { V1: 1 } })],
    })
    const neu = emptyContainer({
      sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '99::bracket aluminium lft', quantityFactorByVariant: { V1: 1 }, effectiveCostByVariant: { V1: 1 } })],
    })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1')])

    expect(diff.variantAllocation.substitutionsSuspected).toHaveLength(1)
    const finding = diff.variantAllocation.substitutionsSuspected[0]!
    expect(finding.reviewRelevant).toBe(true)
    expect(finding.labelSimilarity).toBeGreaterThan(0.6)
    expect(finding.affectedVariantPairs).toEqual([{ altVariantId: 'V1', neuVariantId: 'V1' }])

    // Never auto-resolved into a match — still also visible as ordinary
    // added/removed component findings.
    expect(diff.variantAllocation.findings.some((f) => f.kind === 'component_added')).toBe(true)
    expect(diff.variantAllocation.findings.some((f) => f.kind === 'component_removed')).toBe(true)
  })

  it('does not flag a substitution for dissimilar labels', () => {
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '60::bracket' })] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '61::gasket seal ring' })] })

    const diff = diffMaterial(alt, neu, [])

    expect(diff.variantAllocation.substitutionsSuspected).toEqual([])
  })

  // Adversarial review KAR-938/#310 F5: best-match restriction, not an
  // unbounded altOnly×neuOnly cross-product.
  it('restricts each altOnly row to its single best-matching neuOnly candidate, not every candidate above threshold', () => {
    const alt = emptyContainer({
      sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '50::alpha bracket', quantityFactorByVariant: { V1: 1 }, effectiveCostByVariant: { V1: 1 } })],
    })
    const neu = emptyContainer({
      sharedMaterialMaster: [
        // Best match (similarity ≈0.929, clearly highest).
        materialRow({ canonicalComponentIdentity: '51::alpha bracketx', quantityFactorByVariant: { V1: 1 }, effectiveCostByVariant: { V1: 1 } }),
        // Also clears substitutionLabelSimilarityThreshold (≈0.643) but is
        // NOT the best match — must NOT produce a second finding (the old
        // unbounded cross-product would have reported both).
        materialRow({ canonicalComponentIdentity: '52::gamma bracketx', quantityFactorByVariant: { V1: 1 }, effectiveCostByVariant: { V1: 1 } }),
      ],
    })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1')])

    expect(diff.variantAllocation.substitutionsSuspected).toHaveLength(1)
    const finding = diff.variantAllocation.substitutionsSuspected[0]!
    expect(finding.addedCandidates.map((c) => c.canonicalComponentIdentity)).toEqual(['51::alpha bracketx'])
  })

  it('collapses a TIE for the best match into ONE collective finding listing every tied candidate', () => {
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '10::bracket type' })] })
    const neu = emptyContainer({
      sharedMaterialMaster: [
        // Both ≈0.833 similarity to 'bracket type' — a genuine tie.
        materialRow({ canonicalComponentIdentity: '20::rocket type' }),
        materialRow({ canonicalComponentIdentity: '21::bucket type' }),
      ],
    })

    const diff = diffMaterial(alt, neu, [])

    expect(diff.variantAllocation.substitutionsSuspected).toHaveLength(1)
    const finding = diff.variantAllocation.substitutionsSuspected[0]!
    expect(finding.addedCandidates.map((c) => c.canonicalComponentIdentity)).toEqual(['20::rocket type', '21::bucket type'])
    expect(finding.labelSimilarity).toBeCloseTo(0.8333, 3)
  })

  it('caps the total substitution-suspected findings and reports the suppressed count as a warning', () => {
    const alt = emptyContainer({
      sharedMaterialMaster: [
        materialRow({ canonicalComponentIdentity: '10::alpha bracket' }),
        materialRow({ canonicalComponentIdentity: '11::beta bracket' }),
        materialRow({ canonicalComponentIdentity: '12::gamma bracket' }),
      ],
    })
    const neu = emptyContainer({
      sharedMaterialMaster: [
        materialRow({ canonicalComponentIdentity: '20::alpha bracketx' }),
        materialRow({ canonicalComponentIdentity: '21::beta bracketx' }),
        materialRow({ canonicalComponentIdentity: '22::gamma bracketx' }),
      ],
    })
    const config: MaterialDifferConfig = { ...DEFAULT_MATERIAL_DIFFER_CONFIG, substitutionFindingsCap: 1 }

    const diff = diffMaterial(alt, neu, [], { config })

    expect(diff.variantAllocation.substitutionsSuspected).toHaveLength(1)
    const capWarning = diff.warnings.find((w) => w.code === 'substitution_candidates_capped')
    expect(capWarning).toBeDefined()
    expect(capWarning!.message).toContain('2')
  })
})

describe('diffMaterial — mixed-currency doctrine', () => {
  it('never fabricates an impact across a currency mismatch, and attaches a warning', () => {
    const row = { canonicalComponentIdentity: '70::sensor', quantityFactorByVariant: { V1: 2 }, effectiveCostByVariant: { V1: 2 } }
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ ...row, unitCost: { value: 1, currency: 'EUR' } })] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ ...row, unitCost: { value: 1.3, currency: 'USD' } })] })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1')])

    expect(diff.sharedComponents.unitCostValueChanges).toHaveLength(1)
    const impact = diff.sharedComponents.unitCostValueChanges[0]!.impact.impacts[0]!
    expect(impact.impact).toBeNull()
    expect(impact.reason).toBe('mixed_currency')
    expect(diff.sharedComponents.unitCostValueChanges[0]!.impact.aggregates).toEqual([])

    expect(diff.warnings.some((w) => w.code === 'mixed_currency_material_impact')).toBe(true)
  })

  // Adversarial review KAR-938/#310 F3: currency missing on BOTH sides must
  // never collapse into the same "Währungswechsel" claim a genuine swap
  // produces — it gets its own reason/code/message ('currency_unknown'),
  // since no currency change ever actually happened.
  it('distinguishes "currency never captured on either side" from a genuine swap — own reason, own warning, no false swap claim', () => {
    const row = { canonicalComponentIdentity: '71::coil', quantityFactorByVariant: { V1: 2 }, effectiveCostByVariant: { V1: 2 } }
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ ...row, unitCost: { value: 100, currency: null } })] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ ...row, unitCost: { value: 150, currency: null } })] })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1')])

    expect(diff.sharedComponents.unitCostValueChanges).toHaveLength(1)
    const impact = diff.sharedComponents.unitCostValueChanges[0]!.impact.impacts[0]!
    expect(impact.impact).toBeNull()
    expect(impact.reason).toBe('currency_unknown')
    expect(diff.sharedComponents.unitCostValueChanges[0]!.impact.aggregates).toEqual([])

    const warning = diff.warnings.find((w) => w.code === 'currency_unknown_material_impact')
    expect(warning).toBeDefined()
    expect(warning!.message).toContain('nicht erfasst')
    // Never ALSO claims a swap happened.
    expect(diff.warnings.some((w) => w.code === 'mixed_currency_material_impact')).toBe(false)
  })

  // Adversarial review KAR-938/#310 F2: the 11.2 variant-allocation-level
  // effective-cost delta must honor the SAME currency gate as the 11.1
  // shared-component impact — never a fabricated cross-currency number.
  it('never fabricates an effective-cost delta at the variant-allocation level across a currency mismatch', () => {
    const alt = emptyContainer({
      sharedMaterialMaster: [
        materialRow({ canonicalComponentIdentity: '80::connector', unitCost: { value: 10, currency: 'EUR' }, quantityFactorByVariant: { V1: 2 }, effectiveCostByVariant: { V1: 20 } }),
      ],
    })
    const neu = emptyContainer({
      sharedMaterialMaster: [
        materialRow({ canonicalComponentIdentity: '80::connector', unitCost: { value: 10, currency: 'USD' }, quantityFactorByVariant: { V1: 3 }, effectiveCostByVariant: { V1: 30 } }),
      ],
    })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1')])

    expect(diff.variantAllocation.findings).toHaveLength(1)
    const finding = diff.variantAllocation.findings[0]!
    expect(finding.kind).toBe('factor_changed')
    // Raw figures stay visible (never hidden)...
    expect(finding.altEffectiveCost).toBe(20)
    expect(finding.neuEffectiveCost).toBe(30)
    // ...but the DELTA between them is never fabricated across currencies.
    expect(finding.effectiveCostDeltaAbsolute).toBeNull()
    expect(finding.effectiveCostDeltaPercent).toBeNull()
    expect(finding.effectiveCostStatus).toBe('nicht_berechenbar')
    expect(finding.effectiveCostDeltaReason).toBe('mixed_currency')
    expect(finding.reviewRelevant).toBe(true)
  })
})

describe('diffMaterial — reorder invariance', () => {
  it('produces zero findings when rows/variants are reordered but identity and values are unchanged', () => {
    const rowA = materialRow({ canonicalComponentIdentity: '10::a', sourceRow: 10, quantityFactorByVariant: { V1: 1 }, effectiveCostByVariant: { V1: 1 } })
    const rowB = materialRow({ canonicalComponentIdentity: '20::b', sourceRow: 20, quantityFactorByVariant: { V1: 2 }, effectiveCostByVariant: { V1: 2 } })
    const alt = emptyContainer({ sharedMaterialMaster: [rowA, rowB] })
    const neu = emptyContainer({
      sharedMaterialMaster: [
        materialRow({ ...rowB, sourceRow: 21 }),
        materialRow({ ...rowA, sourceRow: 11 }),
      ],
    })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1')])

    expect(diff.sharedComponents.unitCostValueChanges).toEqual([])
    expect(diff.sharedComponents.unitCostCurrencyChanges).toEqual([])
    expect(diff.sharedComponents.exchangeRateChanges).toEqual([])
    expect(diff.sharedComponents.logisticsOrDutyChanges).toEqual([])
    expect(diff.sharedComponents.materialOverheadChanges).toEqual([])
    expect(diff.sharedComponents.formulaChanges).toEqual([])
    expect(diff.sharedComponents.rowIdentityChanges).toEqual([])
    expect(diff.variantAllocation.findings).toEqual([])
    expect(diff.variantAllocation.substitutionsSuspected).toEqual([])
  })
})

describe('diffMaterial — fail-closed validation status', () => {
  it('marks a finding on a needs_review row reviewRelevant and excludes it from aggregates', () => {
    const row = { canonicalComponentIdentity: '80::coil', quantityFactorByVariant: { V1: 1 }, effectiveCostByVariant: { V1: 1 } }
    const alt = emptyContainer({ sharedMaterialMaster: [materialRow({ ...row, unitCost: { value: 1, currency: 'EUR' }, validationStatus: 'needs_review' })] })
    const neu = emptyContainer({ sharedMaterialMaster: [materialRow({ ...row, unitCost: { value: 2, currency: 'EUR' } })] })

    const diff = diffMaterial(alt, neu, [matched('V1', 'V1')])

    const finding = diff.sharedComponents.unitCostValueChanges[0]!
    expect(finding.reviewRelevant).toBe(true)
    expect(finding.impact.aggregates).toEqual([])
    // Impact numbers themselves are still visible (not hidden), just not aggregated.
    expect(finding.impact.impacts[0]!.impact).not.toBeNull()
  })
})

describe('diffMaterial — uncertain matches (KAR-936 gate)', () => {
  it('passes ambiguous matches through verbatim and never diffs their allocation', () => {
    const alt = emptyContainer({
      sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '90::plate', quantityFactorByVariant: { V1: 1, V2: 1 }, effectiveCostByVariant: { V1: 1, V2: 1 } })],
    })
    const neu = emptyContainer({
      sharedMaterialMaster: [materialRow({ canonicalComponentIdentity: '90::plate', quantityFactorByVariant: { W1: 3, W2: 3 }, effectiveCostByVariant: { W1: 3, W2: 3 } })],
    })
    const matchResult = [ambiguous(['V1', 'V2'], ['W1', 'W2'])]

    const diff = diffMaterial(alt, neu, matchResult)

    expect(diff.uncertainMatches).toHaveLength(1)
    expect(diff.uncertainMatches[0]!.kind).toBe('ambiguous')
    // No matched pairs at all -> no allocation findings can be derived.
    expect(diff.variantAllocation.findings).toEqual([])
  })
})

// ── Determinism ──────────────────────────────────────────────────────────

describe('diffMaterial — determinism', () => {
  it('produces a byte-identical result for the same inputs regardless of call order', () => {
    const alt = emptyContainer({
      sharedMaterialMaster: [
        materialRow({ canonicalComponentIdentity: '10::a', unitCost: { value: 1, currency: 'EUR' }, quantityFactorByVariant: { V2: 2, V1: 1 }, effectiveCostByVariant: { V2: 2, V1: 1 } }),
        materialRow({ canonicalComponentIdentity: '20::b', unitCost: { value: 2, currency: 'EUR' }, quantityFactorByVariant: { V1: 1 }, effectiveCostByVariant: { V1: 2 } }),
      ],
    })
    const neu = emptyContainer({
      sharedMaterialMaster: [
        materialRow({ canonicalComponentIdentity: '20::b', unitCost: { value: 2.5, currency: 'EUR' }, quantityFactorByVariant: { V1: 1 }, effectiveCostByVariant: { V1: 2.5 } }),
        materialRow({ canonicalComponentIdentity: '10::a', unitCost: { value: 1.1, currency: 'EUR' }, quantityFactorByVariant: { V1: 2, V2: 2 }, effectiveCostByVariant: { V1: 2.2, V2: 2.2 } }),
      ],
    })
    const matchResult = [matched('V2', 'V2'), matched('V1', 'V1')]

    const r1 = diffMaterial(alt, neu, matchResult)
    const r2 = diffMaterial(alt, neu, [...matchResult].reverse())

    expect(JSON.stringify(r1)).toBe(JSON.stringify(r2))
  })
})

describe('diffMaterial — default config export', () => {
  it('exposes a positive-threshold default config', () => {
    const config: MaterialDifferConfig = DEFAULT_MATERIAL_DIFFER_CONFIG
    expect(config.substitutionLabelSimilarityThreshold).toBeGreaterThan(0)
    expect(config.substitutionLabelSimilarityThreshold).toBeLessThanOrEqual(1)
    expect(config.bands.auffaellig10).toBeGreaterThan(0)
    // F1: rename requires strictly higher similarity than a substitution hint.
    expect(config.rowRenameLabelSimilarityThreshold).toBeGreaterThan(config.substitutionLabelSimilarityThreshold)
    expect(config.rowRenameLabelSimilarityThreshold).toBeLessThanOrEqual(1)
    // F5: a positive, finite cap.
    expect(config.substitutionFindingsCap).toBeGreaterThan(0)
  })
})
