// Core domain types for the QAF Diff Engine (KAR-799).
//
// The engine is UI-independent (batch/CLI/test-usable). These types model the
// auditable comparison of two QAFs of the SAME part number: provenance,
// normalized values, the 5-stage matching cascade, and field-level diffs.
//
// Reuses QAFRow from the existing parser — we do not re-model process rows.
//
// tdd-guard:skip — type declarations + constants; logic lives in the
// normalizer/differ/matcher modules which are test-driven.

import type { QAFRow } from '@/lib/qaf-parser'

/** Which logical sheet a value came from. */
export type QafSheet = 'Zusammenfassung' | 'Fertigungskosten'

/**
 * Source provenance for one value — every engine number must stay traceable to
 * file / sheet / row / cell (spec A2.16, B-Parser). `cell` is an A1 address
 * (e.g. "I8") when known; `row`/`column` are 1-based.
 */
export interface SourceRef {
  file: string
  sheet: QafSheet
  row: number | null
  column: number | null
  cell: string | null
}

/** A numeric value tagged with its currency context (BW or AW). */
export interface MoneyValue {
  value: number | null
  /** ISO-ish currency code as written in the QAF (e.g. "EUR", "CNY"). */
  currency: string | null
}

/**
 * Percent-typed fields where a percentage-point delta is meaningful in addition
 * to a relative delta (spec B13, A4: SGK, Ausschuss).
 */
export const PERCENT_FIELDS: ReadonlyArray<keyof QAFRow> = [
  'lohnzuschlagssaetze',
  'ausschuss',
] as const

export function isPercentField(field: keyof QAFRow): boolean {
  return PERCENT_FIELDS.includes(field)
}

/**
 * Per-field fill-state classification (Master-Prompt §13, P0.4/KAR-889).
 * Derived deterministically from Kadi-v2's own parsed QAF data (typed value +
 * KAR-891 rawText) — there is no external "correct/incorrect" judgment to
 * read (unlike the BMW Fehlerreport's color column). See rule-engine.ts. // allow-customer-string
 */
export type FieldState =
  | 'provided_valid'
  | 'provided_invalid'
  | 'empty_allowed'
  | 'empty_comparison_critical'
  | 'not_applicable'

// ── Diff ────────────────────────────────────────────────────────────────────

/** Status of a single field delta (spec B13). */
export type DiffStatus =
  | 'neu'
  | 'entfallen'
  | 'nicht_berechenbar'
  | 'nicht_anwendbar'
  // R2 Pflichtfeld-Blockade in 'block'-Modus (KAR-889): the underlying value
  // is invalid/missing on a mandatory field — no delta is computed at all
  // (deltaAbsolute/deltaPercent/deltaPercentagePoints stay null), instead of
  // showing a possibly-wrong number. altValue/neuValue are kept as-is (mirrors
  // the nicht_anwendbar pattern, KAR-891) so the still-valid side stays visible.
  | 'blockiert'
  | 'konstant'
  | 'anstieg'
  | 'senkung'
  | 'auffaellig_10'
  | 'auffaellig_25'
  | 'kritisch_50'
  // Formel-Vergleich (KAR-900/P2.1, formula-engine.ts). Both override whatever
  // band/structural status the plain value delta would otherwise carry — see
  // compareFormulaPair's FormulaComparisonKind doc for the exact semantics.
  // 'formel_geaendert': formula hash differs on both sides while the
  // displayed value is unchanged — the silent-manipulation risk Master-Prompt
  // §12.4 explicitly calls out ("hidden formula change must be reported even
  // when the displayed value is unchanged"). Persists fine into the existing
  // qaf_manufacturing_diff/qaf_summary_diff `status TEXT` column — no CHECK
  // constraint restricts its values, no migration needed.
  | 'formel_geaendert'
  // 'formel_zu_konstante': a formula was removed and replaced by a hardcoded
  // value — the classic manipulation pattern from the offline tool (KAR-861).
  | 'formel_zu_konstante'

export interface FieldDiff {
  field: keyof QAFRow
  altValue: number | null
  neuValue: number | null
  /** NEU − ALT. null when one side is missing. */
  deltaAbsolute: number | null
  /** deltaAbsolute / |ALT|, as a fraction (0.12 = +12 %). null when ALT is 0/empty. */
  deltaPercent: number | null
  /** NEU − ALT in percentage points, only for percent fields. */
  deltaPercentagePoints: number | null
  isPercentField: boolean
  status: DiffStatus
  /**
   * KAR-900/P2.1: set true only when both sides carry the SAME formula (hash
   * equal) but the computed value differs — i.e. an upstream input changed,
   * not the calculation logic itself. `status` above is untouched in this
   * case (normal delta/status-band semantics, task spec: "normale
   * Wert-Diff-Semantik + Kennzeichnung inputs_changed") — this is a
   * supplementary, IN-MEMORY-ONLY annotation: qaf_manufacturing_diff/
   * qaf_summary_diff have no spare column for it, so it does not survive a
   * DB round-trip (persisted `status` already carries the load-bearing
   * signal for the other 3 formula semantics). Optional/absent for every
   * pre-P2.1 caller.
   */
  formulaInputsChanged?: boolean
  /**
   * KAR-900/P2.1: present for all 3 "reportable" formula-comparison kinds
   * (see compareFormulaPair) — compare.ts scans stepComparisons/summaryDiffs
   * for this field and turns each into a qaf_plausibility_issue-shaped
   * finding (formulaFindingToPlausibilityIssue), which DOES persist (no
   * schema gap there, unlike this field itself). Two of the three kinds
   * (formel_geaendert_wert_gleich, formel_zu_konstante) ALSO override
   * `status` above to the matching DiffStatus — this field is not the only
   * place that signal lives for those. The third kind
   * (formel_geaendert_wert_geaendert) has no dedicated DiffStatus (the
   * ordinary band status already makes the value change visible), so this
   * field is its ONLY carrier pre-persistence. In-memory-only: does not
   * survive a DB round-trip (no spare column on qaf_manufacturing_diff/
   * qaf_summary_diff) — the persisted plausibility issue is what survives.
   */
  formulaFinding?: {
    kind: 'formel_geaendert_wert_gleich' | 'formel_geaendert_wert_geaendert' | 'formel_zu_konstante'
    explanation: string
  }
}

// ── Matching cascade ─────────────────────────────────────────────────────────

/** Outcome bucket of the 5-stage matching cascade (spec B3). */
export type MatchStatus =
  | 'safe_match'
  | 'probable_match'
  | 'possible_structure_change'
  | 'candidate_match'
  | 'new_step'
  | 'removed_step'

/** How a match was produced — kept for reproducibility (spec B4). */
export type MatchMethod =
  | 'exact'
  | 'normalized_exact'
  | 'position_fuzzy'
  | 'position_divergent'
  | 'fuzzy_no_position'
  | 'manual'
  | 'none'

/**
 * One alt↔neu process-step match (or an unmatched new/removed step).
 * `altIndex`/`neuIndex` reference positions in the respective step arrays;
 * `null` marks the missing side.
 */
export interface StepMatch {
  altIndex: number | null
  neuIndex: number | null
  matchStatus: MatchStatus
  /** 0..1. Higher = safer. */
  confidenceScore: number
  matchMethod: MatchMethod
  matchedFields: Array<keyof QAFRow>
  conflictingFields: Array<keyof QAFRow>
  explanation: string
  /** When true, excluded from the final auto-diff until a human confirms (spec B3, B15). */
  requiresReview: boolean
}

/** Thresholds used by the matcher — persisted with results for reproducibility (B4). */
export interface MatchConfig {
  /** Min fuzzy score (0..1) for a process/machine name to count as "similar". */
  similarityThreshold: number
  /** Min fuzzy score for a candidate match without equal position number. */
  candidateThreshold: number
  /**
   * Relative delta (fraction) on a cost-relevant field above which a probable
   * match is flagged requires_review (spec B3 stage 2).
   */
  costDivergenceReviewThreshold: number
}

export const DEFAULT_MATCH_CONFIG: MatchConfig = {
  similarityThreshold: 0.6,
  candidateThreshold: 0.7,
  costDivergenceReviewThreshold: 0.25,
}

// ── Summary sheet (Zusammenfassung) ──────────────────────────────────────────

/** One extracted summary value with its A1 source cell (provenance, spec A2.16). */
export interface SummaryField {
  value: string | null
  /** A1 address where the value was read (e.g. "I8"), or null when not found. */
  cell: string | null
}

/**
 * Identity + context fields parsed from the Zusammenfassung/Summary sheet.
 * Field names are deliberately customer-neutral (no customer token in code).
 * `partNumber` is the primary comparison identity (spec A2/B2.6).
 */
export interface QafSummary {
  partNumber: SummaryField
  quotationDate: SummaryField
  supplier: SummaryField
  partName: SummaryField
  variant: SummaryField
  project: SummaryField
  requestVersion: SummaryField
  changeIndex: SummaryField
  supplierNo: SummaryField
  /**
   * KAR-910 (registry-extension follow-up to KAR-907's cross-language-
   * fixtures suite): 4 of the "Allgemeine Prämissen"/"Prämissen Lieferant"
   * fields (Leitfaden 02-leitfaden-teil1.md [11], canonical-fields.ts
   * SUMMARY_PREMISES_FIELDS) that were registry-only ("not parsed by Kadi-v2
   * today") before this PR — promoted to parsed via summary-parser.ts's
   * label-scan (no known fixed A1 anchor, see FIELD_DEFS there), closing
   * fehlerreport-analyse.md §5 Fälle #10/#12/#14/#16. Kept string-typed
   * (SummaryField, like every other QafSummary field) rather than numeric —
   * this module never types SUMMARY identity/premise values, only the
   * separate SummaryMetricKey money-metric block does.
   */
  peakVolumeYear: SummaryField
  productionStartSop: SummaryField
  deliverySite: SummaryField
  shiftsPerWeek: SummaryField
  /**
   * QVS-P4 (KAR-973, reports/qaf-value-stream-corpus-evidence.md): 2 more
   * "Allgemeine Prämissen" fields (canonical-fields.ts sum_planned_capacity/
   * sum_lot_size), promoted from "not parsed by Kadi-v2 today" via the exact
   * same summary-parser.ts label-scan mechanism KAR-910 established above —
   * corpus-evidence justified (221/227 resp. 220/227 dev-split files carry
   * the label), unlike the 6 time-split fields the same scan found at 0/227
   * (those stay Klasse C, never parsed — see mapper.ts's `not_mapped`
   * section). Kept string-typed like every sibling field here (never
   * numeric) — QVS's own file-level-context reader (lib/qaf-value-stream/
   * internal/qaf-source.ts) parses these to numbers itself; this module's
   * QafSummary stays a pure label-scan transcript, no unit coercion.
   */
  plannedCapacity: SummaryField
  lotSize: SummaryField
}

export type QafSummaryKey = keyof QafSummary

/** Engine module versions — persisted per comparison so old results stay reproducible (B4). */
export const ENGINE_VERSION = {
  // 1.3.0: manual field-mapping overrides (KAR-912/P4.2,
  // field-mapping-override.ts) can now correct a single field's value on a
  // specific row (identified by Positionsnummer) when the header→field
  // mapping below got it wrong or left it unmapped — applied on top of the
  // parse result at both ingest (file replace, carried forward from the old
  // file) and recompute (rehydrated from qaf_file.g60_meta.
  // fieldMappingOverrides, same KAR-899 rehydration-staleness discipline
  // material/sbm/rmr/logistics/workbookSafety already follow). A genuine
  // behavior change (a field can now show a user-corrected value instead of
  // the parser's own), so bumped for the same reproducibility reason as
  // every other key here — a comparison computed before this override was
  // set (or before this feature existed) must not be silently reinterpreted.
  //
  // 1.2.0: Fertigungskosten header→field mapping now goes through the
  // canonical MANUFACTURING field registry (label-anchor + confidence)
  // instead of an exact-string-only dictionary, with a degradation path
  // (parseConfidence/unmappedHeaders/mappedFieldCount) instead of a hard
  // failure below the core-field minimum (KAR-893/P1.2, qaf-parser.ts).
  parser: '1.3.0',
  normalizer: '1.0.0',
  matcher: '1.0.0',
  differ: '1.1.0',
  // 1.1.0: R3 now fires for optional SUMMARY fields via a cross-side
  // (ALT/NEU) asymmetric-emptiness heuristic (KAR-908), and R2/R3 now also
  // run over MATERIAL/SBM/RMR/LOGISTICS detail rows, mandatory/optional
  // derived from the canonical field registry instead of a hardcoded list
  // (KAR-909) — both are genuine BEHAVIOR changes (new findings become
  // possible on data that previously produced none), so this is bumped
  // rather than left at 1.0.0, same reproducibility reasoning as every
  // other bump in this object: an already-reviewed comparison's persisted
  // rule-engine issues must not be silently reinterpreted by a later code
  // change.
  //
  // 1.0.0: Fehlerreport-Regel-Engine R1-R6 (KAR-889/P0.4). The active
  // ruleEnforcement ('warn'|'block') is NOT a fixed code version — it varies
  // per comparison and is folded into the persisted engine_version JSONB
  // separately by persistence-mapper.ts (from QafComparisonResult.ruleEnforcement),
  // so a later flip of the RULE_ENGINE_CONFIG default stays reproducible for
  // past comparisons (reviewer finding, KAR-889).
  ruleEngine: '1.1.0',
  // 1.1.0: cell provenance (sourceCells on G60Step/G60TabAggregate/
  // ratesSourceCells on G60ParseResult) + label-based column localization for
  // the 5 structure-guard-anchored columns (locateG60ColumnAnchors — an
  // anchor found ±1/±2 from its fix coordinate is now read from THAT column
  // instead of being downgraded to a soft/hard mismatch, confidence 0.8,
  // g60_column_relocated issue) (KAR-894/P1.3, parser.ts/structure-guard.ts).
  // Still persisted per G60 comparison for the same reproducibility reasoning
  // as the 1.0.0 note below: a later change to the anchor list/thresholds/
  // relocatedConfidence must not retroactively reinterpret an already-
  // reviewed comparison.
  //
  // 1.0.0: G60 structure guard — label-anchor validation (row-14 tab headers,
  // INPUT!B22-B29 rate-card labels) against silent wrong/empty numbers on a
  // shifted template (KAR-888/P0.3, structure-guard.ts). Persisted per G60
  // comparison (qaf_comparison.engine_version already spreads ENGINE_VERSION
  // for the g60 path too) so a later change to G60_STRUCTURE_GUARD_CONFIG's
  // anchor list/thresholds does not retroactively reinterpret an
  // already-reviewed comparison — same reproducibility reasoning as ruleEngine.
  g60Parser: '1.1.0',
  // 1.0.0: Template-Fingerprint + known/modified/unknown classification
  // (KAR-895/P1.4, template-fingerprint.ts). Computed ONCE at ingest and
  // persisted (qaf_file.g60_meta.templateFingerprint) — never recomputed at
  // rehydrate, so a later change to TEMPLATE_FINGERPRINT_CONFIG's coverage
  // thresholds or to the built-in profile set does not retroactively
  // reclassify an already-reviewed file's persisted result (same
  // reproducibility reasoning as g60Parser/ruleEngine above).
  templateFingerprint: '1.0.0',
  // 1.0.0: Central versioned engine config (KAR-896/P1.5, engine-config.ts) —
  // the CODE-level stamp of DEFAULT_ENGINE_CONFIG at the time this module was
  // last touched. NOT derived from DEFAULT_ENGINE_CONFIG.configVersion by
  // import (would create a circular dependency: engine-config.ts already
  // imports DEFAULT_MATCH_CONFIG from this file) — kept in manual sync by
  // convention instead, same as every other independently-versioned key in
  // this object. Bump together with DEFAULT_ENGINE_CONFIG.configVersion
  // whenever any config section's default values change. The per-comparison
  // EFFECTIVE configVersion (which may differ once a future DB-backed config
  // exists) is folded into the persisted engine_version separately by
  // persistence-mapper.ts (from QafComparisonResult.engineConfig.configVersion,
  // via buildEngineConfigDelta) — same as ruleEngine/ruleEnforcement above,
  // for the same reproducibility reason, and always wins over this literal
  // when both are spread into the same engine_version object.
  configVersion: '1.0.0',
  // 1.0.0: Formel-Extraktion, -Normalisierung und -Vergleich (KAR-900/P2.1,
  // formula-engine.ts) — cell.formula is now extracted alongside the computed
  // value for Fertigungskosten step fields and Summary metrics, normalized
  // (DE->EN function names, whitespace, reference-anchor unification) and
  // hashed for structural comparison. Persisted per comparison for the same
  // reproducibility reasoning as every other independently-versioned key in
  // this object: a later change to FUNCTION_NAME_DE_TO_EN or the comparison
  // state machine must not retroactively reinterpret an already-reviewed
  // comparison's formel_geaendert/formel_zu_konstante findings.
  formulaEngine: '1.0.0',
} as const

// ── Empty-Field-Reason-Taxonomie (KAR-958/P1, Master-Prompt §10/§12) ───────
//
// WARUM ein Facetten-Ergebnis leer/degradiert ist, nicht nur DASS es leer
// ist. This is a deliberately small slice of the full 16-value availability
// taxonomy Master-Prompt §12 describes (PRESENT/PARTIALLY_PRESENT/DERIVED/
// ...) — that full taxonomy needs a corpus-wide canonical-field registry and
// UI consumption, both explicitly out of scope here (P3). This slice covers
// exactly the cases the KAR-958/P1 producers below can distinguish today:
// Package 1 (workbook-adapter.ts parseQafFile's manufacturing-facet
// decoupling) and Package 2 (the coreFieldsFound-resilience fix in
// material-/sbm-/rmr-/logistics-/lccn-/co2e-parser.ts). Minimal-invasive by
// task instruction: the type + its producer-side population, no UI consumer
// yet.
export type EmptyFieldReason =
  /** Label/sheet genuinely not present in the workbook after the parser's
   * normal search (not a parser failure). */
  | 'MISSING_IN_WORKBOOK'
  /** The sheet/area exists, but the parser could not read it (header
   * mismatch, thrown exception, too few recognized columns). */
  | 'PARSE_FAILED'
  /** A recognized structure for which no parser exists (yet). */
  | 'NOT_YET_SUPPORTED'
  /** Suppressed by comparison_mode/template classification, not by data
   * availability (reserved for a future producer — none of KAR-958/P1's
   * changes populate this value; see gate-audit.md B15-B17). */
  | 'GATED_BY_MODE'
  /** Data present but contradictory/implausible (e.g. ambiguous candidates). */
  | 'INCONSISTENT'
  /** Historisch: Ganz-Datei-Abweisung am Upload-Gate für legacy `.xls`
   * (KAR-961/P4). **Seit 27.07.2026 gegenstandslos** — der Ingest liest das
   * Altformat über `legacy-workbook-shim.ts`, das Gate lässt `.xls` durch, und
   * eine Altdatei erzeugt keinen Grund mehr, der von einer modernen Datei
   * abwiche. Der Wert war auch vorher nie produktiv belegt (kein
   * `reason: 'LEGACY_FORMAT_UNSUPPORTED'` im Repo) und bleibt nur erhalten,
   * damit ein künftiger Konsument des Enums keine Lücke im Union sieht. */
  | 'LEGACY_FORMAT_UNSUPPORTED'

/**
 * A structured degradation finding for ONE facet (manufacturing/material/
 * sbm/rmr/logistics/lccn/co2e/...) — the ERZEUGER-seitige counterpart to
 * module-degradation.ts's existing UI read-model (extractDegradedModules),
 * which stays unchanged and keeps consuming the plain `coreFieldsFound`
 * boolean every parser already persists. This type is what Package 1/2's
 * producers attach alongside that boolean once they have more to say than
 * "false" — WHY (EmptyFieldReason), on WHICH sheet, and a human-readable
 * message (the parser's own existing diagnostic text/label list).
 */
export interface FacetDegradation {
  /** Facet key, e.g. 'manufacturing' | 'material' | 'sbm' | 'rmr' |
   * 'logistics' | 'lccn' | 'co2e_summary' | 'co2e_material' — a plain string,
   * not a union: Package 1 and Package 2 are independent producers, each
   * with its own facet name, and a shared enum would create an import
   * coupling between workbook-adapter.ts and the six detail parsers that
   * does not otherwise exist. */
  facet: string
  reason: EmptyFieldReason
  /** Worksheet the degradation was found on, when known. */
  sheet?: string | null
  message: string
}

/**
 * KAR-958/P2 (gate-audit.md B5/B7/B10/B11) minimum-signal floor, shared by
 * every Package 2 producer (material-/sbm-/rmr-/logistics-/lccn-/co2e-
 * parser.ts): below this many recognized header columns (or, for
 * lccn-parser.ts's whole-grid label scan, recognized labels), a sheet is
 * treated as genuinely empty/foreign — no coreFieldsFound-degraded partial
 * extraction is attempted at all. At/above it, a header missing a core field
 * still yields its own already-mapped columns instead of being discarded
 * outright (see each parser's own coreFieldsFound doc comment).
 *
 * PR #325 review fix (finding #5, cleanup): previously six separate,
 * unexported `const ... = 2` literals — each parser's own doc comment
 * already claimed "same minimum-signal floor as material-parser.ts's
 * MIN_SIGNAL_MAPPED_COLUMNS", but nothing enforced that beyond the comment;
 * a future re-tuning would have had to be applied to all six files
 * individually with no compiler/test signal for a missed one. One shared,
 * exported constant now backs every parser's floor.
 */
export const MIN_SIGNAL_MAPPED_COLUMNS = 2
