// Template fingerprint + known/modified/unknown classification (KAR-895 / P1.4).
//
// Problem this closes (backlog 05-backlog-phasenplan.md [P1.4]): today's
// "which template is this?" logic is a set of independent binary heuristics —
// summary-metrics.ts's pickTemplate() is a sheet-name regex that ALWAYS
// returns one of two template constants (never "I don't recognize this"),
// qaf-parser.ts's findHeaderRow() either finds ≥5 known headers or throws,
// and g60/parser.ts's detectG60() is a yes/no gate. None of them can say
// "this looks like a known template but three fields moved" versus "I have
// genuinely never seen a structure like this" (Master-Prompt §7's silent
// template modification risk). This module adds that middle ground without
// touching any of the three existing recognizers — see "Scope discipline".
//
// Scope discipline (backlog P1.4 + task instructions, deliberately followed):
//   - Foundation stage only. Built-in profile CONSTANTS (QAF_V9_SUMMARY,
//     QAF_LEGACY_DE_SUMMARY, G60_DETAIL), no qaf_template_profile DB table,
//     no profile-onboarding/auto-adaptation workflow (that is P5, out of
//     scope here per the task brief overriding the backlog item's original
//     "Migrations-Impact: ja" note — this PR persists the RESULT, not a
//     profile registry).
//   - Deterministic, inspectable set/hash comparison — no ML/fuzzy scoring.
//   - Pure. No I/O, no Excel access, no DB access. Callers (workbook-adapter.ts
//     / actions.ts) assemble TemplateFingerprintInput from data they already
//     parsed; this module only classifies it.
//   - Additive: does not change what qaf-parser.ts, summary-metrics.ts or
//     g60/structure-guard.ts return — it reads their OUTPUT (matched field
//     keys, template type, structure-guard findings) and never re-parses a
//     workbook itself.
//
// Provenance discipline (rehydrate/export determinism, task instruction):
// the fingerprint is computed ONCE at ingest (actions.ts ingestQafUpload)
// and persisted (qaf_file.g60_meta.templateFingerprint — see PR body for why
// that JSONB field, not a new column). Rehydrate/render code must always
// READ the persisted TemplateFingerprintResult back, never call
// buildTemplateFingerprint() again on old data — recomputing later would
// silently reinterpret an already-reviewed comparison if this module's
// classification thresholds change (same reproducibility reasoning as
// ENGINE_VERSION.g60Parser, see types.ts).
//
// tdd-guard:skip for the profile/config CONSTANTS below (pure data, same
// category as summary-metrics.ts's TEMPLATE_CONFIG) — the functions
// (computeTemplateFingerprintStructure/hashTemplateFingerprintStructure/
// classifyTemplateFingerprint/buildTemplateFingerprint/
// templateFingerprintToPlausibilityIssue) are covered by
// __tests__/template-fingerprint.test.ts.

import { createHash } from 'node:crypto'
import type { QAFFieldKey } from '@/lib/qaf-parser'
import {
  QAF_FIELD_KEY_TO_CANONICAL,
  SUMMARY_METRIC_KEY_TO_CANONICAL,
  MATERIAL_FIELD_KEY_TO_CANONICAL,
  SBM_FIELD_KEY_TO_CANONICAL,
  RMR_FIELD_KEY_TO_CANONICAL,
  LCCN_FIELD_KEY_TO_CANONICAL,
  CO2E_FIELD_KEY_TO_CANONICAL,
} from './canonical-fields'
import { SUMMARY_METRIC_KEYS, type SummaryMetricKey, type SummaryMetricsParse, type SummaryTemplateType } from './summary-metrics'
import type { PlausibilityIssue } from './plausibility'
import type { MaterialFieldKey } from './material-parser'
import type { SbmFieldKey } from './sbm-parser'
import type { RmrFieldKey } from './rmr-parser'
import type { LccnFieldKey } from './lccn-parser'
import type { Co2eFieldKey } from './co2e-parser'
import { detectQafFileLanguage, type QafFileLanguageResult } from './language-detection'

// ── Input (assembled by the caller from data already parsed) ────────────────

export interface TemplateFingerprintSheetSummary {
  name: string
  rowCount: number
  colCount: number
}

export interface TemplateFingerprintSummaryInput {
  /** pickTemplate()'s result (summary-metrics.ts) — always one of the two
   * known constants, never null; the "is this actually one of them" question
   * is answered by coverage below, not by this field. */
  template: SummaryTemplateType
  /** SummaryMetricKeys whose LABEL was structurally located by
   * parseSummaryMetrics — see summaryLocatedKeys() below. Deliberately NOT
   * "has a non-null value": most summary money metrics are legitimately
   * optional/blank on a real quotation (no customs, no one-time payments,
   * ...) — using value presence here would misclassify every normal file
   * with an unused optional block as "modified" (adversarial-review finding
   * on the original KAR-895 PR, confidence 85). Structure (was the label
   * found?) and content (is the cell filled?) are independent questions;
   * this facet only asks the first one. */
  locatedMetricKeys: readonly SummaryMetricKey[]
}

/**
 * Which SummaryMetricKeys had their label structurally located by
 * parseSummaryMetrics (summary-metrics.ts) — confidence > 0, i.e.
 * `howLocated` is `labelMatch` (1), `fixedRow` fallback (0.6) or `aggregate`
 * derived from a located anchor (≤0.9). `labelCollision` (ambiguous/duplicate
 * label match) and the never-computed `ABSENT` default (key not part of this
 * template variant's row priors, e.g. rawMaterialPriceShareEnergy on
 * QAF_LEGACY_DE_SUMMARY) both carry confidence 0 — the only cases that
 * genuinely mean "not structurally present". A field's VALUE can legitimately
 * be null while its label is still confidence>0-located (the label text is
 * static template content printed regardless of whether the value cell was
 * filled in) — this function must not be confused with "has a value".
 *
 * Pure — takes the already-parsed metrics record, no I/O.
 */
export function summaryLocatedKeys(metrics: SummaryMetricsParse['metrics']): SummaryMetricKey[] {
  return SUMMARY_METRIC_KEYS.filter((k) => (metrics[k]?.confidence ?? 0) > 0)
}

export interface TemplateFingerprintManufacturingInput {
  /** Union of QAFFieldKey columns qaf-parser.ts's HEADER_TO_KEY located in
   * the Fertigungskosten header row (union of every parsed QAFRow's
   * `sourceCells` keys — set unconditionally per matched column regardless
   * of that row's value, see qaf-parser.ts parseQAFTemplate colMap loop).
   * Deliberately reads this off the already-parsed rows instead of changing
   * qaf-parser.ts to return colMap — keeps that module untouched. */
  matchedFieldKeys: readonly QAFFieldKey[]
  /** Best-effort only (lowest data-row sheet number found in sourceCells,
   * minus 1) — NOT used for classification (see classifyTemplateFingerprint),
   * purely descriptive metadata for the persisted structure. Null when no
   * step carried provenance (empty sheet, or rows predating KAR-886). */
  approxHeaderRow: number | null
}

/**
 * MATERIAL facet input (KAR-897/P1.6), analog to
 * TemplateFingerprintManufacturingInput above. Optional on
 * TemplateFingerprintInput (not `| null` required-field like manufacturing)
 * so the ~11 existing call sites/tests built before P1.6 do not need to be
 * touched — `undefined` and `null` are treated identically (facet omitted).
 */
export interface TemplateFingerprintMaterialInput {
  /** Union of MaterialFieldKey columns material-parser.ts located in the
   * MATERIAL header row (union of every parsed MaterialRow's `sourceCells`
   * keys) — same "read it off the already-parsed rows" pattern as
   * TemplateFingerprintManufacturingInput.matchedFieldKeys. */
  matchedFieldKeys: readonly MaterialFieldKey[]
}

/**
 * SBM facet input (KAR-898/P1.7), analog to TemplateFingerprintMaterialInput
 * above — same "optional, undefined and null equivalent" contract so
 * pre-P1.7 call sites/tests do not need to be touched.
 */
export interface TemplateFingerprintSbmInput {
  /** Union of SbmFieldKey columns sbm-parser.ts located in the SBM-DEVICES-FWZ
   * header row (union of every parsed SbmRow's `sourceCells` keys) — same
   * "read it off the already-parsed rows" pattern as
   * TemplateFingerprintMaterialInput.matchedFieldKeys. */
  matchedFieldKeys: readonly SbmFieldKey[]
}

/**
 * RMR facet input (KAR-902/P2.3) — optional analog to
 * TemplateFingerprintMaterialInput/TemplateFingerprintSbmInput above, per
 * task instruction ("Fingerprint: RMR-Facette optional analog den anderen").
 * Same "undefined and null equivalent" contract so pre-P2.3 call sites/tests
 * do not need to be touched.
 */
export interface TemplateFingerprintRmrInput {
  /** Union of RmrFieldKey columns rmr-parser.ts located in the RAW MATERIAL
   * RISKS header row (union of every parsed RmrRow's `sourceCells` keys) —
   * same "read it off the already-parsed rows" pattern as the material/SBM
   * facets above. */
  matchedFieldKeys: readonly RmrFieldKey[]
}

/**
 * LC-CN facet input (KAR-904/P2.5) — optional analog to
 * TemplateFingerprintRmrInput above, per task instruction ("Fingerprint-
 * Facetten optional — diesmal MIT — Konsistenz zu MATERIAL/SBM/RMR"). Same
 * "undefined and null equivalent" contract so pre-P2.5 call sites/tests do
 * not need to be touched.
 */
export interface TemplateFingerprintLccnInput {
  /** LccnFieldKey labels lccn-parser.ts located on the LC-CN Zusammenfassungs-
   * blatt (union of the single parsed record's `sourceCells` keys) — same
   * "read it off the already-parsed data" pattern as the material/SBM/RMR
   * facets above. */
  matchedFieldKeys: readonly LccnFieldKey[]
}

/**
 * CO2e facet input (KAR-904/P2.5) — same optional/undefined-null-equivalent
 * contract as `lccn` above. Combines BOTH sub-parts co2e-parser.ts extracts
 * (the "CO2e CONTENT" summary panel + the CO2e-Material row block, see that
 * module's own header "Struktur-Entscheidung") into one flat matchedFieldKeys
 * union — Co2eFieldKey already spans both.
 */
export interface TemplateFingerprintCo2eInput {
  matchedFieldKeys: readonly Co2eFieldKey[]
}

export interface TemplateFingerprintG60Input {
  /** Number of detail cost tabs that survived parseG60WorkbookGuarded (i.e.
   * were not hard-excluded by structure-guard.ts). */
  tabCount: number
  /** structure-guard.ts's INPUT!B22-B29 rate-card finding — false when hard
   * OR soft mismatched (both are a deviation from the clean G60_DETAIL
   * shape; classifyTemplateFingerprint treats a SOFT mismatch as "modified",
   * not "unknown" — see there. A HARD mismatch is classified via
   * `inputRatesHardBroken` below instead, at "unknown" severity). */
  inputStructureOk: boolean
  /** Tabs with a soft (single-anchor) header mismatch — value kept, but flagged. */
  softMismatchTabCount: number
  /** Tabs hard-excluded by structure-guard.ts (≥2 header anchors gone). */
  excludedTabCount: number
  /**
   * PR #328 review fix (finding [6]): true when structure-guard.ts's INPUT
   * rate-card check is HARD-broken (`inputStructure.confidence === 0`,
   * `parseG60WorkbookGuarded`'s `rateCardBroken`) — every surviving tab's
   * SGA/Profit is withheld (null) on this file, see structure-guard.ts
   * `rateDegradation` doc comment. Before KAR-961/P4 this condition emptied
   * `.tabs` entirely, so it was already caught by the `tabCount === 0`
   * branch below at 'unknown' (red) severity; since KAR-961/P4 `.tabs` stays
   * populated, so this field is now the ONLY way that severity is preserved
   * for the same underlying condition — without it, a hard-broken rate card
   * would silently fall through to the generic 'modified' (yellow) path via
   * `inputStructureOk`, understating a case where two full fields are
   * unusable on EVERY cost tab of the file.
   */
  inputRatesHardBroken: boolean
}

export interface TemplateFingerprintInput {
  /** Every worksheet in the workbook — computeTemplateFingerprintStructure
   * sorts by name; caller does not need to pre-sort. */
  sheets: readonly TemplateFingerprintSheetSummary[]
  /** Null when no Zusammenfassung/Summary sheet was found. */
  summary: TemplateFingerprintSummaryInput | null
  /** Null when no Fertigungskosten/Manufacturing-costs header row was found. */
  manufacturing: TemplateFingerprintManufacturingInput | null
  /**
   * MATERIAL facet (KAR-897/P1.6) — optional (unlike summary/manufacturing/
   * g60, which are required-but-nullable) so existing pre-P1.6 callers don't
   * need updating. Undefined and null are equivalent ("no MATERIAL sheet /
   * not attempted"), see TemplateFingerprintMaterialInput doc comment.
   */
  material?: TemplateFingerprintMaterialInput | null
  /**
   * SBM facet (KAR-898/P1.7) — same optional/undefined-null-equivalent
   * contract as `material` above.
   */
  sbm?: TemplateFingerprintSbmInput | null
  /**
   * RMR facet (KAR-902/P2.3) — same optional/undefined-null-equivalent
   * contract as `material`/`sbm` above.
   */
  rmr?: TemplateFingerprintRmrInput | null
  /**
   * LC-CN facet (KAR-904/P2.5) — same optional/undefined-null-equivalent
   * contract as `material`/`sbm`/`rmr` above.
   */
  lccn?: TemplateFingerprintLccnInput | null
  /**
   * CO2e facet (KAR-904/P2.5) — same optional/undefined-null-equivalent
   * contract as `lccn` above.
   */
  co2e?: TemplateFingerprintCo2eInput | null
  /** Null for non-G60 files (detectG60() returned false). */
  g60: TemplateFingerprintG60Input | null
}

// ── Structure (the actual "fingerprint" — sorted, hashable) ─────────────────

export interface TemplateFingerprintSummaryFacet {
  templateType: SummaryTemplateType
  /** Canonical SUMMARY ids found (sorted) — see SUMMARY_METRIC_KEY_TO_CANONICAL. */
  coveredCanonicalIds: readonly string[]
  /** Canonical ids a clean file of this templateType is expected to cover
   * (sorted) — see SUMMARY_EXPECTED_CANONICAL_IDS_BY_TEMPLATE below for why
   * this differs per template (legacy structurally lacks one metric). */
  expectedCanonicalIds: readonly string[]
  /** coveredCanonicalIds ∩ expectedCanonicalIds .length / expectedCanonicalIds.length. */
  coverageRatio: number
}

export interface TemplateFingerprintManufacturingFacet {
  approxHeaderRow: number | null
  /** Canonical MANUFACTURING ids found (sorted) — see QAF_FIELD_KEY_TO_CANONICAL. */
  coveredCanonicalIds: readonly string[]
  expectedCanonicalIds: readonly string[]
  coverageRatio: number
}

export interface TemplateFingerprintMaterialFacet {
  /** Canonical MATERIAL ids found (sorted) — see MATERIAL_FIELD_KEY_TO_CANONICAL. */
  coveredCanonicalIds: readonly string[]
  expectedCanonicalIds: readonly string[]
  coverageRatio: number
}

export interface TemplateFingerprintSbmFacet {
  /** Canonical SBM ids found (sorted) — see SBM_FIELD_KEY_TO_CANONICAL. */
  coveredCanonicalIds: readonly string[]
  expectedCanonicalIds: readonly string[]
  coverageRatio: number
}

export interface TemplateFingerprintRmrFacet {
  /** Canonical RMR ids found (sorted) — see RMR_FIELD_KEY_TO_CANONICAL. */
  coveredCanonicalIds: readonly string[]
  expectedCanonicalIds: readonly string[]
  coverageRatio: number
}

export interface TemplateFingerprintLccnFacet {
  /** Canonical LC_CN ids found (sorted) — see LCCN_FIELD_KEY_TO_CANONICAL. */
  coveredCanonicalIds: readonly string[]
  expectedCanonicalIds: readonly string[]
  coverageRatio: number
}

export interface TemplateFingerprintCo2eFacet {
  /** Canonical CO2E ids found (sorted) — see CO2E_FIELD_KEY_TO_CANONICAL. */
  coveredCanonicalIds: readonly string[]
  expectedCanonicalIds: readonly string[]
  coverageRatio: number
}

export interface TemplateFingerprintG60Facet {
  tabCount: number
  inputStructureOk: boolean
  softMismatchTabCount: number
  excludedTabCount: number
  /** PR #328 review fix (finding [6]) — see TemplateFingerprintG60Input's
   * doc comment. */
  inputRatesHardBroken: boolean
}

export interface TemplateFingerprintStructure {
  sheetNames: readonly string[]
  sheets: readonly TemplateFingerprintSheetSummary[]
  summary: TemplateFingerprintSummaryFacet | null
  manufacturing: TemplateFingerprintManufacturingFacet | null
  /** Null when the input carried no MATERIAL facet (undefined or null — see
   * TemplateFingerprintInput.material doc comment). */
  material: TemplateFingerprintMaterialFacet | null
  /** Null when the input carried no SBM facet (undefined or null — see
   * TemplateFingerprintInput.sbm doc comment). */
  sbm: TemplateFingerprintSbmFacet | null
  /** Null when the input carried no RMR facet (undefined or null — see
   * TemplateFingerprintInput.rmr doc comment). */
  rmr: TemplateFingerprintRmrFacet | null
  /** Null when the input carried no LC-CN facet (undefined or null — see
   * TemplateFingerprintInput.lccn doc comment). */
  lccn: TemplateFingerprintLccnFacet | null
  /** Null when the input carried no CO2e facet (undefined or null — see
   * TemplateFingerprintInput.co2e doc comment). */
  co2e: TemplateFingerprintCo2eFacet | null
  g60: TemplateFingerprintG60Facet | null
}

// ── Expected-coverage sets ────────────────────────────────────────────────

/** All 22 MANUFACTURING canonical ids (sorted) — same for both DE/EN template
 * variants, HEADER_TO_KEY maps both label sets onto the same 22 QAFFieldKeys. */
export const MANUFACTURING_EXPECTED_CANONICAL_IDS: readonly string[] = Object.values(
  QAF_FIELD_KEY_TO_CANONICAL,
).sort()

/** All 28 MATERIAL canonical ids (sorted, KAR-897/P1.6) — mirrors
 * MANUFACTURING_EXPECTED_CANONICAL_IDS above. */
export const MATERIAL_EXPECTED_CANONICAL_IDS: readonly string[] = Object.values(
  MATERIAL_FIELD_KEY_TO_CANONICAL,
).sort()

/** All 35 SBM canonical ids (sorted, KAR-898/P1.7) — mirrors
 * MATERIAL_EXPECTED_CANONICAL_IDS above. */
export const SBM_EXPECTED_CANONICAL_IDS: readonly string[] = Object.values(SBM_FIELD_KEY_TO_CANONICAL).sort()

/** All 12 RMR canonical ids (sorted, KAR-902/P2.3) — mirrors
 * MATERIAL_EXPECTED_CANONICAL_IDS/SBM_EXPECTED_CANONICAL_IDS above. */
export const RMR_EXPECTED_CANONICAL_IDS: readonly string[] = Object.values(RMR_FIELD_KEY_TO_CANONICAL).sort()

/** All 15 LC-CN canonical ids lccn-parser.ts actually extracts (sorted,
 * KAR-904/P2.5) — mirrors RMR_EXPECTED_CANONICAL_IDS above. Deliberately the
 * 15 LCCN_FIELD_KEY_TO_CANONICAL values, NOT all 17 registered lccn_* ids —
 * the 2 row-level ids (lccn_am_cif_share/lccn_am_process_clustering) are out
 * of lccn-parser.ts's sheet scope (see that module's header), so requiring
 * them here would make every genuinely intact LC-CN file permanently
 * classified as "modified". */
export const LCCN_EXPECTED_CANONICAL_IDS: readonly string[] = Object.values(LCCN_FIELD_KEY_TO_CANONICAL).sort()

/** All 14 CO2E canonical ids (sorted, KAR-904/P2.5) — mirrors
 * LCCN_EXPECTED_CANONICAL_IDS above. */
export const CO2E_EXPECTED_CANONICAL_IDS: readonly string[] = Object.values(CO2E_FIELD_KEY_TO_CANONICAL).sort()

const ALL_SUMMARY_CANONICAL_IDS: readonly string[] = Object.values(SUMMARY_METRIC_KEY_TO_CANONICAL).sort()

/** QAF_LEGACY_DE_SUMMARY's TEMPLATE_CONFIG.rows (summary-metrics.ts) has no
 * entry for rawMaterialPriceShareEnergy — the legacy template structurally
 * lacks that row, it is not a data gap. A clean legacy file therefore never
 * covers sum_raw_material_price_share_energy; requiring it would permanently
 * misclassify every intact legacy file as "modified". Kept as an explicit,
 * documented exclusion rather than silently only requiring "most" fields —
 * revisit if a future real legacy file legitimately has this row after all. */
const LEGACY_EXCLUDED_SUMMARY_CANONICAL_IDS: readonly string[] = ['sum_raw_material_price_share_energy']

/** KAR-962/P5 §26/§35 "known"-threshold calibration finding, REFINED by the
 * PR #329 review (finding [1]): `sum_cost_breakdown_aw1` (canonical-fields.ts's
 * own doc comment — "NOT documented anywhere in the Leitfaden's exhaustive
 * SUMMARY cost-block enumeration ... the only evidence is the real
 * Fehlerreport row itself", KAR-910 Fall #23) is not a structural member of
 * EITHER summary template generation on the official blank/unfilled QAF
 * template (incoming/templates/) or on any of the 227 dev-split files —
 * confirmed empirically by template-fingerprint-known-calibration.real-files.
 * test.ts: every OTHER expected SUMMARY id was located (coverageRatio 19/20 =
 * 0.95, not 1.0) on the pristine reference file, `sum_cost_breakdown_aw1` was
 * the SOLE gap.
 *
 * The review correctly flagged that a blanket "globally excluded, pretend it
 * never exists" framing overstates the evidence: canonical-fields.ts's OWN
 * registry entry for this id already concedes it DOES appear in at least one
 * real supplier file (KAR-910 Fall #23's Fehlerreport row) — the calibration
 * sample (blank template + 227-file dev split) never having it is evidence
 * of RARITY, not proof of universal absence. Naming this constant "optional"
 * (not "excluded") is deliberate: the id is excluded from the REQUIRED
 * expected set for one concrete, structural reason that holds regardless of
 * sampling — summary-metrics.ts locates it via `locateRowUnanchored` (no
 * row-position prior exists for it, unlike every other SUMMARY metric, see
 * that module's own comment), so its absence can never be verified as a
 * genuine deviation (only its opportunistic presence can be verified) for
 * ANY file of EITHER template. Requiring an unanchored, unverifiable field
 * would make `known` unreachable for every intact file (batch-report.md's
 * original finding) purely because this one id cannot be reliably attested
 * as present OR absent — that would be the actual over-fit, not this
 * exclusion.
 *
 * "known nicht blockiert, aber modified NICHT verhindert wenn andere
 * Abweichungen existieren" (review's requested behavior): this id's absence
 * NEVER by itself blocks `known` (excluded from the denominator, unchanged
 * from before this comment) — but its exclusion also never masks anything
 * else: `coveredCanonicalIds` (computed from ALL locatedMetricKeys, never
 * filtered against `expectedCanonicalIds`) still faithfully reports it when
 * a file DOES have it, and any OTHER genuinely missing required SUMMARY id
 * still independently drags that file's facet status to 'modified'/'unknown'
 * exactly as before — this exclusion only ever affects this ONE id's own
 * contribution to the ratio, never any other id's. See
 * template-fingerprint.test.ts's "superset" test for the committed proof
 * that a file covering this id is not treated specially.
 *
 * Exported (not module-private) so tests can assert the exact, bounded
 * superset `coveredCanonicalIds` may legitimately carry beyond
 * `expectedCanonicalIds` — template-fingerprint.test.ts's strict, not
 * `arrayContaining`, coverage assertion (PR #329 review finding [3]) needs
 * the precise id list, not just a documented count. */
export const SUMMARY_OPTIONAL_CANONICAL_IDS: readonly string[] = ['sum_cost_breakdown_aw1']

export const SUMMARY_EXPECTED_CANONICAL_IDS_BY_TEMPLATE: Record<SummaryTemplateType, readonly string[]> = {
  QAF_V9_SUMMARY: ALL_SUMMARY_CANONICAL_IDS.filter((id) => !SUMMARY_OPTIONAL_CANONICAL_IDS.includes(id)),
  QAF_LEGACY_DE_SUMMARY: ALL_SUMMARY_CANONICAL_IDS.filter(
    (id) => !LEGACY_EXCLUDED_SUMMARY_CANONICAL_IDS.includes(id) && !SUMMARY_OPTIONAL_CANONICAL_IDS.includes(id),
  ),
}

function coverageRatio(covered: readonly string[], expected: readonly string[]): number {
  if (expected.length === 0) return 1
  const coveredSet = new Set(covered)
  const hit = expected.filter((id) => coveredSet.has(id)).length
  return hit / expected.length
}

// ── Structure construction ───────────────────────────────────────────────

/** Pure. Builds the sorted, hashable structural fingerprint from already-
 * parsed data — never touches a workbook. */
export function computeTemplateFingerprintStructure(input: TemplateFingerprintInput): TemplateFingerprintStructure {
  const sheets = [...input.sheets].sort((a, b) => a.name.localeCompare(b.name))

  const summary: TemplateFingerprintSummaryFacet | null = input.summary
    ? (() => {
        const coveredCanonicalIds = [
          ...new Set(input.summary!.locatedMetricKeys.map((k) => SUMMARY_METRIC_KEY_TO_CANONICAL[k])),
        ].sort()
        const expectedCanonicalIds = SUMMARY_EXPECTED_CANONICAL_IDS_BY_TEMPLATE[input.summary!.template]
        return {
          templateType: input.summary!.template,
          coveredCanonicalIds,
          expectedCanonicalIds,
          coverageRatio: coverageRatio(coveredCanonicalIds, expectedCanonicalIds),
        }
      })()
    : null

  const manufacturing: TemplateFingerprintManufacturingFacet | null = input.manufacturing
    ? (() => {
        const coveredCanonicalIds = [
          ...new Set(input.manufacturing!.matchedFieldKeys.map((k) => QAF_FIELD_KEY_TO_CANONICAL[k])),
        ].sort()
        return {
          approxHeaderRow: input.manufacturing!.approxHeaderRow,
          coveredCanonicalIds,
          expectedCanonicalIds: MANUFACTURING_EXPECTED_CANONICAL_IDS,
          coverageRatio: coverageRatio(coveredCanonicalIds, MANUFACTURING_EXPECTED_CANONICAL_IDS),
        }
      })()
    : null

  const material: TemplateFingerprintMaterialFacet | null = input.material
    ? (() => {
        const coveredCanonicalIds = [
          ...new Set(input.material!.matchedFieldKeys.map((k) => MATERIAL_FIELD_KEY_TO_CANONICAL[k])),
        ].sort()
        return {
          coveredCanonicalIds,
          expectedCanonicalIds: MATERIAL_EXPECTED_CANONICAL_IDS,
          coverageRatio: coverageRatio(coveredCanonicalIds, MATERIAL_EXPECTED_CANONICAL_IDS),
        }
      })()
    : null

  const sbm: TemplateFingerprintSbmFacet | null = input.sbm
    ? (() => {
        const coveredCanonicalIds = [
          ...new Set(input.sbm!.matchedFieldKeys.map((k) => SBM_FIELD_KEY_TO_CANONICAL[k])),
        ].sort()
        return {
          coveredCanonicalIds,
          expectedCanonicalIds: SBM_EXPECTED_CANONICAL_IDS,
          coverageRatio: coverageRatio(coveredCanonicalIds, SBM_EXPECTED_CANONICAL_IDS),
        }
      })()
    : null

  const rmr: TemplateFingerprintRmrFacet | null = input.rmr
    ? (() => {
        const coveredCanonicalIds = [
          ...new Set(input.rmr!.matchedFieldKeys.map((k) => RMR_FIELD_KEY_TO_CANONICAL[k])),
        ].sort()
        return {
          coveredCanonicalIds,
          expectedCanonicalIds: RMR_EXPECTED_CANONICAL_IDS,
          coverageRatio: coverageRatio(coveredCanonicalIds, RMR_EXPECTED_CANONICAL_IDS),
        }
      })()
    : null

  const lccn: TemplateFingerprintLccnFacet | null = input.lccn
    ? (() => {
        const coveredCanonicalIds = [
          ...new Set(input.lccn!.matchedFieldKeys.map((k) => LCCN_FIELD_KEY_TO_CANONICAL[k])),
        ].sort()
        return {
          coveredCanonicalIds,
          expectedCanonicalIds: LCCN_EXPECTED_CANONICAL_IDS,
          coverageRatio: coverageRatio(coveredCanonicalIds, LCCN_EXPECTED_CANONICAL_IDS),
        }
      })()
    : null

  const co2e: TemplateFingerprintCo2eFacet | null = input.co2e
    ? (() => {
        const coveredCanonicalIds = [
          ...new Set(input.co2e!.matchedFieldKeys.map((k) => CO2E_FIELD_KEY_TO_CANONICAL[k])),
        ].sort()
        return {
          coveredCanonicalIds,
          expectedCanonicalIds: CO2E_EXPECTED_CANONICAL_IDS,
          coverageRatio: coverageRatio(coveredCanonicalIds, CO2E_EXPECTED_CANONICAL_IDS),
        }
      })()
    : null

  const g60: TemplateFingerprintG60Facet | null = input.g60
    ? {
        tabCount: input.g60.tabCount,
        inputStructureOk: input.g60.inputStructureOk,
        softMismatchTabCount: input.g60.softMismatchTabCount,
        excludedTabCount: input.g60.excludedTabCount,
        inputRatesHardBroken: input.g60.inputRatesHardBroken,
      }
    : null

  return {
    sheetNames: sheets.map((s) => s.name),
    sheets,
    summary,
    manufacturing,
    material,
    sbm,
    rmr,
    lccn,
    co2e,
    g60,
  }
}

// ── Deterministic hash ────────────────────────────────────────────────────

/** JSON.stringify with recursively sorted object keys — arrays keep their
 * (already-sorted, see computeTemplateFingerprintStructure) order. Required
 * because JS object key insertion order is otherwise part of JSON.stringify's
 * output and would make the hash depend on incidental construction order.
 * Exported (KAR-934/P1.6) so multi-qaf/template-fingerprint.ts's own
 * deterministic structural hash reuses this exact helper instead of
 * duplicating it — no behavior change for any existing caller. */
export function stableStringify(value: unknown): string {
  if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
  if (value !== null && typeof value === 'object') {
    const obj = value as Record<string, unknown>
    const keys = Object.keys(obj).sort()
    return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`
  }
  return JSON.stringify(value)
}

/** sha256 hex digest of the structure's stable-sorted JSON. Two files that
 * parse to the same structural shape hash identically regardless of upload
 * order or object-construction order. */
export function hashTemplateFingerprintStructure(structure: TemplateFingerprintStructure): string {
  return createHash('sha256').update(stableStringify(structure)).digest('hex')
}

// ── Known profiles (built-in constants — no DB table in this PR, see header) ─

export interface TemplateProfile {
  id: string
  label: string
  kind: 'summary_manufacturing' | 'g60'
}

export const QAF_V9_SUMMARY_PROFILE: TemplateProfile = {
  id: 'QAF_V9_SUMMARY',
  label: 'QAF V9 Summary',
  kind: 'summary_manufacturing',
}
export const QAF_LEGACY_DE_SUMMARY_PROFILE: TemplateProfile = {
  id: 'QAF_LEGACY_DE_SUMMARY',
  label: 'QAF Legacy DE (Zusammenfassung)',
  kind: 'summary_manufacturing',
}
export const G60_DETAIL_PROFILE: TemplateProfile = {
  id: 'G60_DETAIL',
  label: 'G60 Detail-Kalkulation',
  kind: 'g60',
}

export const KNOWN_TEMPLATE_PROFILES: readonly TemplateProfile[] = [
  QAF_V9_SUMMARY_PROFILE,
  QAF_LEGACY_DE_SUMMARY_PROFILE,
  G60_DETAIL_PROFILE,
]

// ── Classification ────────────────────────────────────────────────────────

export type TemplateClassification = 'known' | 'modified' | 'unknown'

export interface TemplateClassificationResult {
  classification: TemplateClassification
  /** Id of the best-matching KNOWN_TEMPLATE_PROFILES entry, null when unknown. */
  matchedProfile: string | null
  /** Human-readable (German) deviation descriptions — empty when known. */
  deviations: string[]
  /** EN counterpart of `deviations` (KAR-906/P3.2) — same length/order,
   * field/facet names sourced from the canonical registry where applicable. */
  deviationsEn: string[]
}

export interface TemplateFingerprintClassificationConfig {
  /** Coverage ratio (0..1) at/above which a facet counts as a clean match —
   * 1.0 is intentional: HEADER_TO_KEY/SYNONYMS are exact-string dictionaries
   * already verified against real files (see qaf-parser.ts/summary-metrics.ts
   * headers), so a genuinely intact file always reaches full coverage of its
   * template-specific expected set. Any single missing/renamed field is
   * exactly the "silent template modification" signal (Master-Prompt §7)
   * this module exists to surface — it must not be smoothed away by a lenient
   * threshold. */
  knownCoverageThreshold: number
  /** Coverage ratio at/above which a facet is still "recognizable, but
   * modified" rather than "unknown" — 0.5 is a deliberately coarse first
   * default (no real BMW files with partial-but-non-trivial header drift in // allow-customer-string
   * the fixture set to calibrate against yet, backlog P1.4 risk note).
   * Revisit once a real near-miss file is observed. */
  modifiedCoverageThreshold: number
}

export const TEMPLATE_FINGERPRINT_CONFIG: TemplateFingerprintClassificationConfig = {
  knownCoverageThreshold: 1,
  modifiedCoverageThreshold: 0.5,
}

type FacetStatus = 'known' | 'modified' | 'unknown'

function facetStatus(ratio: number, config: TemplateFingerprintClassificationConfig): FacetStatus {
  if (ratio >= config.knownCoverageThreshold) return 'known'
  if (ratio >= config.modifiedCoverageThreshold) return 'modified'
  return 'unknown'
}

function combineFacetStatuses(statuses: readonly FacetStatus[]): TemplateClassification {
  if (statuses.length === 0) return 'unknown'
  if (statuses.every((s) => s === 'known')) return 'known'
  if (statuses.every((s) => s === 'unknown')) return 'unknown'
  return 'modified'
}

/** One (DE, EN) facet-coverage deviation sentence (KAR-906/P3.2) — the exact
 * shape every SUMMARY/MANUFACTURING/MATERIAL/SBM/RMR/LC-CN/CO2e branch below
 * used to hand-build in DE only. `unitDe`/`unitEn` differ per facet
 * ("Kernfelder"/"core fields" for SUMMARY, "Spalten"/"columns" for the
 * column-shaped sheets, "Felder"/"fields" for the record-shaped LC-CN/CO2e
 * facets) — everything else about the sentence is structurally identical. */
function facetDeviation(
  labelDe: string,
  labelEn: string,
  unitDe: string,
  unitEn: string,
  covered: number,
  expected: number,
  ratio: number,
): { de: string; en: string } {
  const missing = expected - covered
  const pct = Math.round(ratio * 100)
  return {
    de: `${labelDe}: ${covered}/${expected} ${unitDe} gefunden (${missing} fehlen, ${pct} % Abdeckung).`,
    en: `${labelEn}: ${covered}/${expected} ${unitEn} found (${missing} missing, ${pct}% coverage).`,
  }
}

/** Pure. Compares a structural fingerprint against the built-in known
 * profiles (see header — no DB lookup in this PR). Deterministic: same
 * structure + same config always yields the same classification. */
export function classifyTemplateFingerprint(
  structure: TemplateFingerprintStructure,
  config: TemplateFingerprintClassificationConfig = TEMPLATE_FINGERPRINT_CONFIG,
): TemplateClassificationResult {
  if (structure.g60) {
    const g = structure.g60
    if (g.tabCount === 0) {
      return {
        classification: 'unknown',
        matchedProfile: null,
        deviations: ['Keine G60-Kostenreiter erkannt.'],
        deviationsEn: ['No G60 cost tabs detected.'],
      }
    }
    // PR #328 review fix (finding [6]): a hard-broken INPUT rate-card used
    // to empty `.tabs` entirely (pre-KAR-961/P4), so it was always caught by
    // the `tabCount === 0` branch above at 'unknown' (red) severity. Since
    // KAR-961/P4 `.tabs` stays populated (only SGA/Profit withheld per tab),
    // so tabCount no longer signals this — without this explicit branch, a
    // hard-broken rate card would silently fall through to the generic
    // `!inputStructureOk` 'modified' (yellow) deviation below, a real,
    // undocumented severity downgrade for the same underlying condition.
    // Own deviation category, same 'unknown' severity as before this PR.
    if (g.inputRatesHardBroken) {
      return {
        classification: 'unknown',
        matchedProfile: null,
        deviations: [
          'INPUT-Ratenkarte (SG&A/Gewinn-Bezeichner B22-B29) hard-broken — SG&A/Profit für jeden Kostenreiter nicht verfügbar.',
        ],
        deviationsEn: ['INPUT rate card (SG&A/profit labels B22-B29) hard-broken — SG&A/profit unavailable for every cost tab.'],
      }
    }
    const deviations: string[] = []
    const deviationsEn: string[] = []
    if (!g.inputStructureOk) {
      deviations.push('INPUT-Ratenkarte (SG&A/Gewinn-Bezeichner B22-B29) weicht ab.')
      deviationsEn.push('INPUT rate card (SG&A/profit labels B22-B29) deviates.')
    }
    if (g.excludedTabCount > 0) {
      deviations.push(`${g.excludedTabCount} Kostenreiter durch Struktur-Guard ausgeschlossen (Header stark abweichend).`)
      deviationsEn.push(`${g.excludedTabCount} cost tab(s) excluded by the structure guard (header strongly deviates).`)
    }
    if (g.softMismatchTabCount > 0) {
      deviations.push(`${g.softMismatchTabCount} Kostenreiter mit leicht abweichendem Header (Wert übernommen, unsicher).`)
      deviationsEn.push(`${g.softMismatchTabCount} cost tab(s) with a slightly deviating header (value adopted, uncertain).`)
    }
    return {
      classification: deviations.length === 0 ? 'known' : 'modified',
      matchedProfile: G60_DETAIL_PROFILE.id,
      deviations,
      deviationsEn,
    }
  }

  if (!structure.summary && !structure.manufacturing) {
    return {
      classification: 'unknown',
      matchedProfile: null,
      deviations: ['Weder Zusammenfassung/Summary- noch Fertigungskosten-Sheet erkannt.'],
      deviationsEn: ['Neither a Zusammenfassung/Summary nor a Fertigungskosten sheet was detected.'],
    }
  }

  const deviations: string[] = []
  const deviationsEn: string[] = []
  const statuses: FacetStatus[] = []

  if (structure.summary) {
    const status = facetStatus(structure.summary.coverageRatio, config)
    statuses.push(status)
    if (status !== 'known') {
      const d = facetDeviation(
        'SUMMARY',
        'SUMMARY',
        'Kernfelder',
        'core fields',
        structure.summary.coveredCanonicalIds.length,
        structure.summary.expectedCanonicalIds.length,
        structure.summary.coverageRatio,
      )
      deviations.push(d.de)
      deviationsEn.push(d.en)
    }
  }

  if (structure.manufacturing) {
    const status = facetStatus(structure.manufacturing.coverageRatio, config)
    statuses.push(status)
    if (status !== 'known') {
      const d = facetDeviation(
        'MANUFACTURING',
        'MANUFACTURING',
        'Spalten',
        'columns',
        structure.manufacturing.coveredCanonicalIds.length,
        structure.manufacturing.expectedCanonicalIds.length,
        structure.manufacturing.coverageRatio,
      )
      deviations.push(d.de)
      deviationsEn.push(d.en)
    }
  }

  // MATERIAL (KAR-897/P1.6) — only contributes to the combined classification
  // when the caller actually attempted a MATERIAL parse (structure.material
  // is null for every pre-P1.6 caller/file, see TemplateFingerprintInput.
  // material doc comment); a file with no MATERIAL sheet at all therefore
  // stays classified purely on SUMMARY+MANUFACTURING exactly as before this
  // facet existed — MATERIAL absence is not itself a "modified" signal.
  // KAR-962/P5 note: "attempted" now means "at least one data row was
  // present" (see actions.ts buildTemplateFingerprint call site comment) —
  // a structurally present-but-genuinely-empty MATERIAL/SBM/RMR/LC-CN/CO2e
  // sheet (a blank/unfilled template's normal state) is `null` here, same as
  // true absence; a facet WITH data rows that are actually degraded still
  // correctly drags classification to 'modified' exactly as before.
  if (structure.material) {
    const status = facetStatus(structure.material.coverageRatio, config)
    statuses.push(status)
    if (status !== 'known') {
      const d = facetDeviation(
        'MATERIAL',
        'MATERIAL',
        'Spalten',
        'columns',
        structure.material.coveredCanonicalIds.length,
        structure.material.expectedCanonicalIds.length,
        structure.material.coverageRatio,
      )
      deviations.push(d.de)
      deviationsEn.push(d.en)
    }
  }

  if (structure.sbm) {
    const status = facetStatus(structure.sbm.coverageRatio, config)
    statuses.push(status)
    if (status !== 'known') {
      const d = facetDeviation(
        'SBM',
        'SBM',
        'Spalten',
        'columns',
        structure.sbm.coveredCanonicalIds.length,
        structure.sbm.expectedCanonicalIds.length,
        structure.sbm.coverageRatio,
      )
      deviations.push(d.de)
      deviationsEn.push(d.en)
    }
  }

  if (structure.rmr) {
    const status = facetStatus(structure.rmr.coverageRatio, config)
    statuses.push(status)
    if (status !== 'known') {
      const d = facetDeviation(
        'RMR',
        'RMR',
        'Spalten',
        'columns',
        structure.rmr.coveredCanonicalIds.length,
        structure.rmr.expectedCanonicalIds.length,
        structure.rmr.coverageRatio,
      )
      deviations.push(d.de)
      deviationsEn.push(d.en)
    }
  }

  if (structure.lccn) {
    const status = facetStatus(structure.lccn.coverageRatio, config)
    statuses.push(status)
    if (status !== 'known') {
      const d = facetDeviation(
        'LC-CN',
        'LC-CN',
        'Felder',
        'fields',
        structure.lccn.coveredCanonicalIds.length,
        structure.lccn.expectedCanonicalIds.length,
        structure.lccn.coverageRatio,
      )
      deviations.push(d.de)
      deviationsEn.push(d.en)
    }
  }

  if (structure.co2e) {
    const status = facetStatus(structure.co2e.coverageRatio, config)
    statuses.push(status)
    if (status !== 'known') {
      const d = facetDeviation(
        'CO2e',
        'CO2e',
        'Felder',
        'fields',
        structure.co2e.coveredCanonicalIds.length,
        structure.co2e.expectedCanonicalIds.length,
        structure.co2e.coverageRatio,
      )
      deviations.push(d.de)
      deviationsEn.push(d.en)
    }
  }

  const classification = combineFacetStatuses(statuses)
  const profileId =
    classification === 'unknown'
      ? null
      : (structure.summary?.templateType ?? 'QAF_V9_SUMMARY') === 'QAF_LEGACY_DE_SUMMARY'
        ? QAF_LEGACY_DE_SUMMARY_PROFILE.id
        : QAF_V9_SUMMARY_PROFILE.id

  return { classification, matchedProfile: profileId, deviations, deviationsEn }
}

// ── Combined result (what actually gets persisted) ───────────────────────

/** Schema version of the fingerprint shape itself — bump when
 * TemplateFingerprintStructure's fields change so a persisted fingerprint
 * stays distinguishable from a later, differently-shaped one (same
 * reproducibility pattern as ENGINE_VERSION.g60Parser/ruleEngine, types.ts). */
export const TEMPLATE_FINGERPRINT_VERSION = '1.0.0'

export interface TemplateFingerprintResult {
  version: string
  structure: TemplateFingerprintStructure
  hash: string
  classification: TemplateClassification
  matchedProfile: string | null
  deviations: string[]
  /** EN counterpart of `deviations` (KAR-906/P3.2). */
  deviationsEn: string[]
  /**
   * Per-file DE/EN/mixed language detection (KAR-905/P3.1, language-
   * detection.ts) — derived from `structure.sheetNames` +
   * `structure.summary?.templateType`, NOT persisted as a separate
   * TemplateFingerprintInput field (no new required input; every existing
   * buildTemplateFingerprint call site keeps working unchanged). Deliberately
   * NOT part of `structure`/`hash`/`classification` — language is a
   * descriptive facet, like `structure.manufacturing.approxHeaderRow`, and
   * must never affect known/modified/unknown classification or the
   * reproducibility hash.
   *
   * Optional on the TYPE (not on what buildTemplateFingerprint actually
   * returns, which always sets it) for the same reason `material`/`sbm`/etc.
   * are optional on TemplateFingerprintInput: a TemplateFingerprintResult
   * persisted to qaf_file.g60_meta.templateFingerprint BEFORE this PR has no
   * `language` key in its stored JSON at all — readers (page.tsx) must treat
   * a missing key as "not detected", never crash on it.
   */
  language?: QafFileLanguageResult
}

/** Pure. One-call convenience: structure → hash → classification. This is
 * the shape ingestQafUpload (actions.ts) persists into qaf_file.g60_meta.
 * templateFingerprint — computed ONCE at ingest, never recomputed at
 * rehydrate (see module header). */
export function buildTemplateFingerprint(
  input: TemplateFingerprintInput,
  config: TemplateFingerprintClassificationConfig = TEMPLATE_FINGERPRINT_CONFIG,
): TemplateFingerprintResult {
  const structure = computeTemplateFingerprintStructure(input)
  const hash = hashTemplateFingerprintStructure(structure)
  const { classification, matchedProfile, deviations, deviationsEn } = classifyTemplateFingerprint(structure, config)
  const language = detectQafFileLanguage({
    sheetNames: structure.sheetNames,
    summaryTemplate: input.summary?.template ?? null,
  })
  return { version: TEMPLATE_FINGERPRINT_VERSION, structure, hash, classification, matchedProfile, deviations, deviationsEn, language }
}

// ── Persistence bridge (PlausibilityIssue) ────────────────────────────────
//
// Reuses the same PlausibilityIssue shape as reconciliation.ts/rule-engine.ts/
// structure-guard.ts — no schema change, no new UI component (the existing
// "Section"/"Pill" plausibility rendering in qaf-comparison-detail.tsx already
// renders any issue_type it doesn't special-case with the plain severity
// label, so a 'pruefen'-severity template_fingerprint_* issue renders as a
// "Prüfen" badge with zero UI code changes). `known` never produces an issue —
// per task instruction, modified/unknown flag for review but block nothing
// (the P0 guards, e.g. structure-guard.ts's hard tab exclusion, already own
// blocking; this module must not duplicate that).

export function templateFingerprintToPlausibilityIssue(
  result: TemplateFingerprintResult,
  side: 'ALT' | 'NEU',
  fileLabel: string,
): PlausibilityIssue | null {
  if (result.classification === 'known') return null

  if (result.classification === 'unknown') {
    return {
      type: 'template_fingerprint_unknown',
      severity: 'pruefen',
      step: `${side} · ${fileLabel}`,
      explanation:
        `Unbekanntes Template: „${fileLabel}" entspricht keinem der bekannten QAF-Profile ` +
        `(${KNOWN_TEMPLATE_PROFILES.map((p) => p.label).join(', ')}). ${result.deviations.join(' ')}`.trim(),
      explanationEn:
        `Unknown template: "${fileLabel}" does not match any of the known QAF profiles ` +
        `(${KNOWN_TEMPLATE_PROFILES.map((p) => p.label).join(', ')}). ${result.deviationsEn.join(' ')}`.trim(),
    }
  }

  const profileLabel = KNOWN_TEMPLATE_PROFILES.find((p) => p.id === result.matchedProfile)?.label ?? result.matchedProfile ?? '—'
  return {
    type: 'template_fingerprint_modified',
    severity: 'pruefen',
    step: `${side} · ${fileLabel}`,
    explanation: `Template von „${fileLabel}" weicht ab (Profil „${profileLabel}" erkannt, aber Abweichungen): ${result.deviations.join(' ')}`,
    explanationEn: `Template of "${fileLabel}" deviates (profile "${profileLabel}" detected, but with deviations): ${result.deviationsEn.join(' ')}`,
  }
}
