// Template fingerprint + known/modified/unknown classification tests (KAR-895/P1.4).

import { describe, expect, it } from 'vitest'
import {
  MANUFACTURING_EXPECTED_CANONICAL_IDS,
  MATERIAL_EXPECTED_CANONICAL_IDS,
  QAF_LEGACY_DE_SUMMARY_PROFILE,
  QAF_V9_SUMMARY_PROFILE,
  G60_DETAIL_PROFILE,
  SUMMARY_EXPECTED_CANONICAL_IDS_BY_TEMPLATE,
  SUMMARY_OPTIONAL_CANONICAL_IDS,
  buildTemplateFingerprint,
  classifyTemplateFingerprint,
  computeTemplateFingerprintStructure,
  hashTemplateFingerprintStructure,
  summaryLocatedKeys,
  templateFingerprintToPlausibilityIssue,
  type TemplateFingerprintInput,
} from '../template-fingerprint'
import { SUMMARY_METRIC_KEYS, type SummaryMetricKey, type SummaryMetricsParse } from '../summary-metrics'
import { HEADER_TO_KEY, type QAFFieldKey } from '@/lib/qaf-parser'
import { MATERIAL_FIELD_KEY_TO_CANONICAL, SBM_FIELD_KEY_TO_CANONICAL, RMR_FIELD_KEY_TO_CANONICAL } from '../canonical-fields'
import type { MaterialFieldKey } from '../material-parser'
import type { SbmFieldKey } from '../sbm-parser'
import type { RmrFieldKey } from '../rmr-parser'
import { SBM_EXPECTED_CANONICAL_IDS, RMR_EXPECTED_CANONICAL_IDS } from '../template-fingerprint'

const ALL_MANUFACTURING_KEYS = [...new Set(Object.values(HEADER_TO_KEY))] as QAFFieldKey[]
const ALL_MATERIAL_KEYS = Object.keys(MATERIAL_FIELD_KEY_TO_CANONICAL) as MaterialFieldKey[]
const ALL_SBM_KEYS = Object.keys(SBM_FIELD_KEY_TO_CANONICAL) as SbmFieldKey[]
const ALL_RMR_KEYS = Object.keys(RMR_FIELD_KEY_TO_CANONICAL) as RmrFieldKey[]

function fullMaterialInput(): TemplateFingerprintInput['material'] {
  return { matchedFieldKeys: ALL_MATERIAL_KEYS }
}

function fullSbmInput(): TemplateFingerprintInput['sbm'] {
  return { matchedFieldKeys: ALL_SBM_KEYS }
}

function fullRmrInput(): TemplateFingerprintInput['rmr'] {
  return { matchedFieldKeys: ALL_RMR_KEYS }
}

function fullSummaryInput(template: 'QAF_V9_SUMMARY' | 'QAF_LEGACY_DE_SUMMARY'): TemplateFingerprintInput['summary'] {
  const keys: SummaryMetricKey[] =
    template === 'QAF_LEGACY_DE_SUMMARY'
      ? SUMMARY_METRIC_KEYS.filter((k) => k !== 'rawMaterialPriceShareEnergy')
      : [...SUMMARY_METRIC_KEYS]
  return { template, locatedMetricKeys: keys }
}

/**
 * Builds a SummaryMetricsParse['metrics']-shaped record for testing the
 * structure-vs-value distinction (adversarial-review fix, confidence 85):
 * `located` keys get a labelMatch (confidence 1) — value defaults to null
 * (label found, cell legitimately blank, the everyday case for optional
 * metrics); `missing` keys get a labelCollision (confidence 0, genuinely not
 * located). Every other SummaryMetricKey defaults to "missing" too so every
 * call produces a complete Record.
 */
function buildMetrics(located: readonly SummaryMetricKey[], missing: readonly SummaryMetricKey[] = []): SummaryMetricsParse['metrics'] {
  const missingSet = new Set(missing)
  const locatedSet = new Set(located)
  return Object.fromEntries(
    SUMMARY_METRIC_KEYS.map((k) => [
      k,
      locatedSet.has(k) && !missingSet.has(k)
        ? { value: null, cell: 'G18', howLocated: 'labelMatch' as const, confidence: 1 }
        : { value: null, cell: null, howLocated: 'labelCollision' as const, confidence: 0 },
    ]),
  ) as SummaryMetricsParse['metrics']
}

function fullManufacturingInput(): TemplateFingerprintInput['manufacturing'] {
  return { matchedFieldKeys: ALL_MANUFACTURING_KEYS, approxHeaderRow: 10 }
}

const emptyInput: TemplateFingerprintInput = { sheets: [], summary: null, manufacturing: null, g60: null }

describe('computeTemplateFingerprintStructure', () => {
  it('sorts sheet names deterministically regardless of input order', () => {
    const a = computeTemplateFingerprintStructure({
      ...emptyInput,
      sheets: [
        { name: 'Fertigungskosten', rowCount: 30, colCount: 22 },
        { name: 'Zusammenfassung', rowCount: 40, colCount: 20 },
      ],
    })
    const b = computeTemplateFingerprintStructure({
      ...emptyInput,
      sheets: [
        { name: 'Zusammenfassung', rowCount: 40, colCount: 20 },
        { name: 'Fertigungskosten', rowCount: 30, colCount: 22 },
      ],
    })
    expect(a.sheetNames).toEqual(['Fertigungskosten', 'Zusammenfassung'])
    expect(a.sheetNames).toEqual(b.sheetNames)
    expect(a.sheets).toEqual(b.sheets)
  })

  it('computes full coverage (ratio 1) for a fully-populated V9 summary', () => {
    const structure = computeTemplateFingerprintStructure({ ...emptyInput, summary: fullSummaryInput('QAF_V9_SUMMARY') })
    expect(structure.summary?.coverageRatio).toBe(1)
    // KAR-962/P5: `sum_cost_breakdown_aw1` is excluded from expectedCanonicalIds
    // (SUMMARY_OPTIONAL_CANONICAL_IDS — real-corpus "known"-threshold
    // calibration finding, see that constant's own doc comment) but a fixture
    // that locates EVERY SUMMARY_METRIC_KEY (this one) still legitimately
    // COVERS it — coveredCanonicalIds can be a superset of expectedCanonicalIds,
    // coverageRatio is unaffected (intersection-based, asserted above).
    expect(structure.summary?.expectedCanonicalIds).not.toContain('sum_cost_breakdown_aw1')
    // PR #329 review fix (finding [3], test-integrity): strict equality
    // against the EXACT, bounded expected-plus-optional superset — not
    // `expect.arrayContaining`, which would silently pass even if
    // coveredCanonicalIds grew an UNBOUNDED extra id some future regression
    // introduced (a false-positive label match). Only the ONE documented
    // optional id may legitimately be the extra.
    expect(structure.summary?.coveredCanonicalIds).toEqual(
      [...(structure.summary?.expectedCanonicalIds ?? []), ...SUMMARY_OPTIONAL_CANONICAL_IDS].sort(),
    )
  })

  it('computes full coverage for a fully-populated LEGACY summary despite missing the energy-share metric', () => {
    const structure = computeTemplateFingerprintStructure({
      ...emptyInput,
      summary: fullSummaryInput('QAF_LEGACY_DE_SUMMARY'),
    })
    expect(structure.summary?.coverageRatio).toBe(1)
    expect(structure.summary?.expectedCanonicalIds).not.toContain('sum_raw_material_price_share_energy')
  })

  it('computes partial coverage when some summary metrics are missing', () => {
    const structure = computeTemplateFingerprintStructure({
      ...emptyInput,
      summary: { template: 'QAF_V9_SUMMARY', locatedMetricKeys: ['materialCosts', 'manufacturingCosts'] },
    })
    // KAR-910: SUMMARY_METRIC_KEYS grew from 19 to 20 (costBreakdownAw1
    // added) — QAF_V9_SUMMARY's expected set is the full ALL_SUMMARY_
    // CANONICAL_IDS set, so the denominator grows with it. KAR-962/P5:
    // costBreakdownAw1 is excluded again (SUMMARY_OPTIONAL_CANONICAL_IDS
    // — real-corpus "known"-threshold calibration finding), bringing the
    // denominator back to 19.
    expect(structure.summary?.coverageRatio).toBeCloseTo(2 / 19, 5)
  })

  it('computes full coverage for all 22 canonical manufacturing fields', () => {
    const structure = computeTemplateFingerprintStructure({ ...emptyInput, manufacturing: fullManufacturingInput() })
    expect(structure.manufacturing?.coveredCanonicalIds).toEqual(MANUFACTURING_EXPECTED_CANONICAL_IDS)
    expect(structure.manufacturing?.coverageRatio).toBe(1)
  })

  it('deduplicates matchedFieldKeys (DE+EN header both mapping to the same key)', () => {
    const structure = computeTemplateFingerprintStructure({
      ...emptyInput,
      manufacturing: { matchedFieldKeys: ['fk', 'fk', 'lohnkosten'], approxHeaderRow: 10 },
    })
    expect(structure.manufacturing?.coveredCanonicalIds).toEqual(['mfg_direct_labor_cost', 'mfg_manufacturing_cost_bw'])
  })

  it('carries g60 facet fields through unchanged', () => {
    const structure = computeTemplateFingerprintStructure({
      ...emptyInput,
      g60: { tabCount: 12, inputStructureOk: true, softMismatchTabCount: 0, excludedTabCount: 0, inputRatesHardBroken: false },
    })
    expect(structure.g60).toEqual({
      tabCount: 12,
      inputStructureOk: true,
      softMismatchTabCount: 0,
      excludedTabCount: 0,
      inputRatesHardBroken: false,
    })
  })
})

describe('summaryLocatedKeys (structure vs. value, adversarial-review fix)', () => {
  it('counts a labelMatch-located key as located even when its value is null (legitimately optional/blank)', () => {
    const metrics = buildMetrics(['customsIncluded'])
    expect(summaryLocatedKeys(metrics)).toContain('customsIncluded')
  })

  it('counts a fixedRow-fallback (confidence 0.6) key as located', () => {
    const metrics = buildMetrics([])
    metrics.materialCosts = { value: null, cell: null, howLocated: 'fixedRow', confidence: 0.6, labelFile: null, labelVerified: null }
    expect(summaryLocatedKeys(metrics)).toContain('materialCosts')
  })

  it('does not count a labelCollision (confidence 0) key as located', () => {
    const metrics = buildMetrics(['materialCosts'], ['materialCosts'])
    expect(summaryLocatedKeys(metrics)).not.toContain('materialCosts')
  })

  it('does not count a key with a populated value but confidence 0 as located (value presence is not the signal)', () => {
    const metrics = buildMetrics([])
    metrics.quotationPrice = { value: 12345, cell: null, howLocated: 'labelCollision', confidence: 0, labelFile: null, labelVerified: null }
    expect(summaryLocatedKeys(metrics)).not.toContain('quotationPrice')
  })
})

describe('hashTemplateFingerprintStructure', () => {
  it('is deterministic for the same structure', () => {
    const structure = computeTemplateFingerprintStructure({
      ...emptyInput,
      sheets: [{ name: 'Zusammenfassung', rowCount: 40, colCount: 20 }],
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
    })
    expect(hashTemplateFingerprintStructure(structure)).toBe(hashTemplateFingerprintStructure(structure))
  })

  it('is independent of object key construction order (stable-stringify)', () => {
    const a = { z: 1, a: { y: 2, b: 1 } }
    const b = { a: { b: 1, y: 2 }, z: 1 }
    expect(hashTemplateFingerprintStructure(a as never)).toBe(hashTemplateFingerprintStructure(b as never))
  })

  it('changes when the covered field set changes', () => {
    const clean = computeTemplateFingerprintStructure({ ...emptyInput, manufacturing: fullManufacturingInput() })
    const shifted = computeTemplateFingerprintStructure({
      ...emptyInput,
      manufacturing: { matchedFieldKeys: ALL_MANUFACTURING_KEYS.slice(0, 20), approxHeaderRow: 10 },
    })
    expect(hashTemplateFingerprintStructure(clean)).not.toBe(hashTemplateFingerprintStructure(shifted))
  })

  it('produces a 64-char hex sha256 digest', () => {
    const structure = computeTemplateFingerprintStructure(emptyInput)
    expect(hashTemplateFingerprintStructure(structure)).toMatch(/^[0-9a-f]{64}$/)
  })
})

describe('classifyTemplateFingerprint', () => {
  it('classifies a fully-covered V9 summary + manufacturing pair as known', () => {
    const structure = computeTemplateFingerprintStructure({
      ...emptyInput,
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
      manufacturing: fullManufacturingInput(),
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('known')
    expect(result.matchedProfile).toBe(QAF_V9_SUMMARY_PROFILE.id)
    expect(result.deviations).toEqual([])
  })

  it('classifies a fully-covered LEGACY summary as known against the legacy profile', () => {
    const structure = computeTemplateFingerprintStructure({
      ...emptyInput,
      summary: fullSummaryInput('QAF_LEGACY_DE_SUMMARY'),
      manufacturing: fullManufacturingInput(),
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('known')
    expect(result.matchedProfile).toBe(QAF_LEGACY_DE_SUMMARY_PROFILE.id)
  })

  it('adversarial-review regression: an intact V9 file with legitimately empty optional summary metrics classifies as known (the everyday case)', () => {
    // 6 of the 19 metrics are legitimately blank on a normal quotation (no
    // customs pass-through, no one-time payments, no energy-share split) —
    // every LABEL is still structurally located (confidence 1). Before the
    // fix this fixture would have wrongly landed on "modified" because the
    // old coverage signal was `.value !== null` instead of structural
    // location (confidence 85 finding on the original KAR-895 PR).
    const OPTIONAL_AND_BLANK: SummaryMetricKey[] = [
      'customsIncluded',
      'devicesAndTools',
      'rawMaterialPriceShareEnergy',
      'oneTimeDevelopment',
      'oneTimeTools',
      'totalOneTimePayment',
    ]
    const metrics = Object.fromEntries(
      SUMMARY_METRIC_KEYS.map((k) => [
        k,
        {
          value: OPTIONAL_AND_BLANK.includes(k) ? null : 1000,
          cell: 'G18',
          howLocated: 'labelMatch' as const,
          confidence: 1,
        },
      ]),
    ) as SummaryMetricsParse['metrics']

    const structure = computeTemplateFingerprintStructure({
      ...emptyInput,
      summary: { template: 'QAF_V9_SUMMARY', locatedMetricKeys: summaryLocatedKeys(metrics) },
      manufacturing: fullManufacturingInput(),
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('known')
    expect(result.deviations).toEqual([])
  })

  it('a file with genuinely un-locatable summary labels still classifies as modified (real structural deviation stays detected)', () => {
    const metrics = buildMetrics(SUMMARY_METRIC_KEYS, [
      'materialCosts',
      'manufacturingCosts',
      'totalProductionCosts',
      'quotationPrice',
    ])
    const structure = computeTemplateFingerprintStructure({
      ...emptyInput,
      summary: { template: 'QAF_V9_SUMMARY', locatedMetricKeys: summaryLocatedKeys(metrics) },
      manufacturing: fullManufacturingInput(),
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('modified')
    expect(result.deviations.some((d) => d.startsWith('SUMMARY'))).toBe(true)
  })

  it('classifies a mostly-covered file with a handful of missing fields as modified', () => {
    const structure = computeTemplateFingerprintStructure({
      ...emptyInput,
      manufacturing: { matchedFieldKeys: ALL_MANUFACTURING_KEYS.slice(0, 20), approxHeaderRow: 10 },
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('modified')
    expect(result.matchedProfile).toBe(QAF_V9_SUMMARY_PROFILE.id)
    expect(result.deviations[0]).toMatch(/MANUFACTURING/)
  })

  it('classifies a file where almost nothing matches as unknown', () => {
    const structure = computeTemplateFingerprintStructure({
      ...emptyInput,
      manufacturing: { matchedFieldKeys: ['fk', 'lohnkosten'], approxHeaderRow: 10 },
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('unknown')
    expect(result.matchedProfile).toBeNull()
  })

  it('classifies a workbook with no recognized summary or manufacturing sheet as unknown', () => {
    const structure = computeTemplateFingerprintStructure(emptyInput)
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('unknown')
    expect(result.matchedProfile).toBeNull()
  })

  it('classifies an intact G60 structure as known against the G60_DETAIL profile', () => {
    const structure = computeTemplateFingerprintStructure({
      ...emptyInput,
      g60: { tabCount: 8, inputStructureOk: true, softMismatchTabCount: 0, excludedTabCount: 0, inputRatesHardBroken: false },
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('known')
    expect(result.matchedProfile).toBe(G60_DETAIL_PROFILE.id)
  })

  it('classifies a G60 structure with excluded tabs as modified, not unknown (P0 guard already blocked those tabs)', () => {
    const structure = computeTemplateFingerprintStructure({
      ...emptyInput,
      g60: { tabCount: 8, inputStructureOk: true, softMismatchTabCount: 0, excludedTabCount: 2, inputRatesHardBroken: false },
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('modified')
    expect(result.deviations[0]).toMatch(/ausgeschlossen/)
  })

  it('classifies a G60 workbook with zero surviving tabs as unknown', () => {
    const structure = computeTemplateFingerprintStructure({
      ...emptyInput,
      g60: { tabCount: 0, inputStructureOk: false, softMismatchTabCount: 0, excludedTabCount: 0, inputRatesHardBroken: false },
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('unknown')
  })

  // PR #328 review fix (finding [6]): a hard-broken INPUT rate-card used to
  // empty `.tabs` entirely (tabCount === 0 → 'unknown'/red). Since KAR-961/P4
  // `.tabs` stays populated (SGA/Profit only withheld per tab) — this test is
  // the regression anchor proving the severity is NOT silently downgraded to
  // 'modified'/yellow just because tabCount is now > 0 for this condition.
  it('classifies a G60 workbook with a hard-broken rate card as unknown even though tabs survived (red, not yellow)', () => {
    const structure = computeTemplateFingerprintStructure({
      ...emptyInput,
      g60: { tabCount: 8, inputStructureOk: false, softMismatchTabCount: 0, excludedTabCount: 0, inputRatesHardBroken: true },
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('unknown')
    expect(result.matchedProfile).toBeNull()
    expect(result.deviations[0]).toMatch(/hard-broken/)
  })

  it('respects an explicit config override', () => {
    const structure = computeTemplateFingerprintStructure({
      ...emptyInput,
      manufacturing: { matchedFieldKeys: ALL_MANUFACTURING_KEYS.slice(0, 20), approxHeaderRow: 10 },
    })
    const result = classifyTemplateFingerprint(structure, { knownCoverageThreshold: 0.85, modifiedCoverageThreshold: 0.5 })
    expect(result.classification).toBe('known')
  })
})

describe('MATERIAL facet (KAR-897/P1.6)', () => {
  it('computeTemplateFingerprintStructure: material stays null when the input omits it (pre-P1.6 callers unaffected)', () => {
    const structure = computeTemplateFingerprintStructure({
      sheets: [],
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
      manufacturing: fullManufacturingInput(),
      g60: null,
    })
    expect(structure.material).toBeNull()
  })

  it('computeTemplateFingerprintStructure: a full MATERIAL match covers all 28 canonical ids', () => {
    const structure = computeTemplateFingerprintStructure({
      sheets: [],
      summary: null,
      manufacturing: null,
      material: fullMaterialInput(),
      g60: null,
    })
    expect(structure.material?.coveredCanonicalIds).toEqual(MATERIAL_EXPECTED_CANONICAL_IDS)
    expect(structure.material?.coverageRatio).toBe(1)
  })

  it('classifyTemplateFingerprint: an intact MATERIAL facet alongside known SUMMARY+MANUFACTURING stays known', () => {
    const structure = computeTemplateFingerprintStructure({
      sheets: [],
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
      manufacturing: fullManufacturingInput(),
      material: fullMaterialInput(),
      g60: null,
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('known')
  })

  it('classifyTemplateFingerprint: a degraded MATERIAL facet drags an otherwise-known file to modified', () => {
    const structure = computeTemplateFingerprintStructure({
      sheets: [],
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
      manufacturing: fullManufacturingInput(),
      material: { matchedFieldKeys: ALL_MATERIAL_KEYS.slice(0, 20) },
      g60: null,
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('modified')
    expect(result.deviations.some((d) => d.startsWith('MATERIAL:'))).toBe(true)
  })

  it('classifyTemplateFingerprint: a file without a MATERIAL sheet at all is unaffected (material absence is not a "modified" signal)', () => {
    const structure = computeTemplateFingerprintStructure({
      sheets: [],
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
      manufacturing: fullManufacturingInput(),
      g60: null,
      // material omitted entirely
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('known')
  })
})

describe('SBM facet (KAR-898/P1.7)', () => {
  it('computeTemplateFingerprintStructure: sbm stays null when the input omits it (pre-P1.7 callers unaffected)', () => {
    const structure = computeTemplateFingerprintStructure({
      sheets: [],
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
      manufacturing: fullManufacturingInput(),
      g60: null,
    })
    expect(structure.sbm).toBeNull()
  })

  it('computeTemplateFingerprintStructure: a full SBM match covers all 35 canonical ids', () => {
    const structure = computeTemplateFingerprintStructure({
      sheets: [],
      summary: null,
      manufacturing: null,
      sbm: fullSbmInput(),
      g60: null,
    })
    expect(structure.sbm?.coveredCanonicalIds).toEqual(SBM_EXPECTED_CANONICAL_IDS)
    expect(structure.sbm?.coverageRatio).toBe(1)
  })

  it('classifyTemplateFingerprint: an intact SBM facet alongside known SUMMARY+MANUFACTURING stays known', () => {
    const structure = computeTemplateFingerprintStructure({
      sheets: [],
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
      manufacturing: fullManufacturingInput(),
      sbm: fullSbmInput(),
      g60: null,
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('known')
  })

  it('classifyTemplateFingerprint: a degraded SBM facet drags an otherwise-known file to modified', () => {
    const structure = computeTemplateFingerprintStructure({
      sheets: [],
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
      manufacturing: fullManufacturingInput(),
      sbm: { matchedFieldKeys: ALL_SBM_KEYS.slice(0, 20) },
      g60: null,
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('modified')
    expect(result.deviations.some((d) => d.startsWith('SBM:'))).toBe(true)
  })

  it('classifyTemplateFingerprint: a file without an SBM sheet at all is unaffected (SBM absence is not a "modified" signal)', () => {
    const structure = computeTemplateFingerprintStructure({
      sheets: [],
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
      manufacturing: fullManufacturingInput(),
      g60: null,
      // sbm omitted entirely
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('known')
  })
})

describe('RMR facet (KAR-902/P2.3)', () => {
  it('computeTemplateFingerprintStructure: rmr stays null when the input omits it (pre-P2.3 callers unaffected)', () => {
    const structure = computeTemplateFingerprintStructure({
      sheets: [],
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
      manufacturing: fullManufacturingInput(),
      g60: null,
    })
    expect(structure.rmr).toBeNull()
  })

  it('computeTemplateFingerprintStructure: a full RMR match covers all 12 canonical ids', () => {
    const structure = computeTemplateFingerprintStructure({
      sheets: [],
      summary: null,
      manufacturing: null,
      rmr: fullRmrInput(),
      g60: null,
    })
    expect(structure.rmr?.coveredCanonicalIds).toEqual(RMR_EXPECTED_CANONICAL_IDS)
    expect(structure.rmr?.coverageRatio).toBe(1)
  })

  it('classifyTemplateFingerprint: an intact RMR facet alongside known SUMMARY+MANUFACTURING stays known', () => {
    const structure = computeTemplateFingerprintStructure({
      sheets: [],
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
      manufacturing: fullManufacturingInput(),
      rmr: fullRmrInput(),
      g60: null,
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('known')
  })

  it('classifyTemplateFingerprint: a degraded RMR facet drags an otherwise-known file to modified', () => {
    const structure = computeTemplateFingerprintStructure({
      sheets: [],
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
      manufacturing: fullManufacturingInput(),
      rmr: { matchedFieldKeys: ALL_RMR_KEYS.slice(0, 3) },
      g60: null,
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('modified')
    expect(result.deviations.some((d) => d.startsWith('RMR:'))).toBe(true)
  })

  it('classifyTemplateFingerprint: a file without an RMR sheet at all is unaffected (RMR absence is not a "modified" signal)', () => {
    const structure = computeTemplateFingerprintStructure({
      sheets: [],
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
      manufacturing: fullManufacturingInput(),
      g60: null,
      // rmr omitted entirely
    })
    const result = classifyTemplateFingerprint(structure)
    expect(result.classification).toBe('known')
  })
})

describe('SUMMARY_EXPECTED_CANONICAL_IDS_BY_TEMPLATE', () => {
  it('legacy expects exactly one fewer id than V9 (the energy-share metric)', () => {
    expect(SUMMARY_EXPECTED_CANONICAL_IDS_BY_TEMPLATE.QAF_LEGACY_DE_SUMMARY.length).toBe(
      SUMMARY_EXPECTED_CANONICAL_IDS_BY_TEMPLATE.QAF_V9_SUMMARY.length - 1,
    )
  })
})

describe('buildTemplateFingerprint', () => {
  it('combines structure + hash + classification into one persisted-shape result', () => {
    const result = buildTemplateFingerprint({
      sheets: [{ name: 'Zusammenfassung', rowCount: 40, colCount: 20 }],
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
      manufacturing: fullManufacturingInput(),
      g60: null,
    })
    expect(result.classification).toBe('known')
    expect(result.hash).toMatch(/^[0-9a-f]{64}$/)
    expect(result.version).toBeTruthy()
  })

  it('is deterministic end-to-end for identical input', () => {
    const input: TemplateFingerprintInput = {
      sheets: [{ name: 'Fertigungskosten', rowCount: 30, colCount: 22 }],
      summary: null,
      manufacturing: fullManufacturingInput(),
      g60: null,
    }
    expect(buildTemplateFingerprint(input)).toEqual(buildTemplateFingerprint(input))
  })

  // KAR-905/P3.1 — language facet, derived purely from sheets/summary.template
  // (no new required TemplateFingerprintInput field).
  it('attaches a language facet detected from the sheet names, without affecting classification/hash', () => {
    const de = buildTemplateFingerprint({
      sheets: [{ name: 'Fertigungskosten', rowCount: 30, colCount: 22 }],
      summary: null,
      manufacturing: fullManufacturingInput(),
      g60: null,
    })
    expect(de.language?.language).toBe('de')

    const en = buildTemplateFingerprint({
      sheets: [{ name: 'Manufacturing costs', rowCount: 30, colCount: 22 }],
      summary: null,
      manufacturing: fullManufacturingInput(),
      g60: null,
    })
    expect(en.language?.language).toBe('en')

    // Same classification/hash-relevant structure (only the sheet name's
    // language-carrying casing differs) — language detection must not leak
    // into either.
    expect(de.classification).toBe(en.classification)
  })

  it('reports unknown language when no sheet name carries a DE/EN signal', () => {
    const result = buildTemplateFingerprint({
      sheets: [{ name: 'SUMMARY', rowCount: 40, colCount: 20 }],
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
      manufacturing: null,
      g60: null,
    })
    expect(result.language).toEqual({ language: 'unknown', signals: [] })
  })
})

describe('templateFingerprintToPlausibilityIssue', () => {
  it('returns null for a known classification (no issue for a clean file)', () => {
    const result = buildTemplateFingerprint({
      sheets: [],
      summary: fullSummaryInput('QAF_V9_SUMMARY'),
      manufacturing: fullManufacturingInput(),
      g60: null,
    })
    expect(templateFingerprintToPlausibilityIssue(result, 'ALT', 'alt.xlsx')).toBeNull()
  })

  it('returns a pruefen-severity issue namespaced template_fingerprint_modified for a modified file', () => {
    const result = buildTemplateFingerprint({
      sheets: [],
      summary: null,
      manufacturing: { matchedFieldKeys: ALL_MANUFACTURING_KEYS.slice(0, 20), approxHeaderRow: 10 },
      g60: null,
    })
    const issue = templateFingerprintToPlausibilityIssue(result, 'NEU', 'neu.xlsx')
    expect(issue).not.toBeNull()
    expect(issue?.type).toBe('template_fingerprint_modified')
    expect(issue?.severity).toBe('pruefen')
    expect(issue?.step).toBe('NEU · neu.xlsx')
    expect(issue?.explanation).toContain('neu.xlsx')
  })

  it('returns a pruefen-severity issue namespaced template_fingerprint_unknown for an unrecognized file', () => {
    const result = buildTemplateFingerprint(emptyInput)
    const issue = templateFingerprintToPlausibilityIssue(result, 'ALT', 'mystery.xlsx')
    expect(issue).not.toBeNull()
    expect(issue?.type).toBe('template_fingerprint_unknown')
    expect(issue?.severity).toBe('pruefen')
  })

  it('does not block — the return contract never carries a severity above pruefen', () => {
    const result = buildTemplateFingerprint(emptyInput)
    const issue = templateFingerprintToPlausibilityIssue(result, 'ALT', 'mystery.xlsx')
    expect(issue?.severity).not.toBe('kritisch')
  })
})

// KAR-906/P3.2: template_fingerprint_* issues carry an EN counterpart —
// deviations are built facet-by-facet, verify the join threads through.
describe('templateFingerprintToPlausibilityIssue — bilingual (KAR-906)', () => {
  it('template_fingerprint_modified carries explanationEn distinct from explanation, naming the same file', () => {
    const result = buildTemplateFingerprint({
      sheets: [],
      summary: null,
      manufacturing: { matchedFieldKeys: ALL_MANUFACTURING_KEYS.slice(0, 20), approxHeaderRow: 10 },
      g60: null,
    })
    const issue = templateFingerprintToPlausibilityIssue(result, 'NEU', 'neu.xlsx')
    expect(issue?.explanationEn).toBeTruthy()
    expect(issue?.explanationEn).not.toBe(issue?.explanation)
    expect(issue?.explanationEn).toContain('neu.xlsx')
    expect(issue?.explanationEn).toMatch(/deviates/)
    expect(issue?.explanationEn).toMatch(/MANUFACTURING: \d+\/\d+ columns found/)
  })

  it('template_fingerprint_unknown carries explanationEn distinct from explanation', () => {
    const result = buildTemplateFingerprint(emptyInput)
    const issue = templateFingerprintToPlausibilityIssue(result, 'ALT', 'mystery.xlsx')
    expect(issue?.explanationEn).toBeTruthy()
    expect(issue?.explanationEn).not.toBe(issue?.explanation)
    expect(issue?.explanationEn).toMatch(/Unknown template/)
  })
})
