// QAF Engine-Konfiguration — zentrale, versionierte Regel-Governance (P1.5/KAR-896).
//
// Master-Prompt §18 fordert Regeln, die "versioniert, reviewbar, testbar,
// auditierbar" sind statt als verstreute Code-Konstanten zu leben. Bis zu
// diesem PR gab es dafür bereits VIER unabhängige, aber strukturell
// identische Vorstufen (KAR-887/888/889 — siehe Kommentare in reconciliation.ts,
// g60/structure-guard.ts, rule-engine.ts): ein benannter Config-Typ, eine
// modul-lokale DEFAULT-Konstante, ein expliziter Funktionsparameter, der
// darauf zurückfällt. Dieses Modul zieht diese vier (plus die bisher NICHT
// benannten differ.ts-Status-Bänder und die bereits parametrisierte
// matcher.ts MatchConfig) zu EINER versionierten Struktur zusammen — ohne
// die einzelnen Modul-Konstanten zu duplizieren oder zu verändern: jede
// Sektion referenziert exakt die bestehende Default-Konstante ihres Moduls,
// damit es keine zweite, potenziell abweichende Quelle der Wahrheit gibt.
//
// Bewusst NICHT Teil dieses PRs (Kais' Vorgabe, KAR-896 comment 10.07.2026):
// - Keine DB-Tabelle `qaf_engine_config` (das ist ein Folge-Issue-Kandidat
//   für die tatsächliche Operator-Editierbarkeit; siehe P1.5-Backlog-Item,
//   das explizit "Read-Pfad zunächst, Admin-UI ist Folge-Item" vorsieht).
// - Keine UI-Settings-Seite.
// Der Wert dieses PRs ist ausschließlich: eine Stelle, ein Versionsstempel,
// saubere Injektion statt verstreuter Hardcodierung, Reproduzierbarkeit über
// die bestehende qaf_comparison.engine_version-Persistenz (siehe
// persistence-mapper.ts/rehydrate.ts).
//
// Zwei bindende Kais-Fachentscheide (KAR-896 comment 10.07.2026), inhaltlich
// dokumentiert an ihrer jeweiligen Sektion-Quelle, hier nur querverwiesen:
//   1. ruleEngine.ruleEnforcement bleibt DAUERHAFT 'warn' im Default — R2
//      (Pflichtfeld-Blockade) blockiert produktiv nie automatisch. 'block'
//      bleibt ein gültiger Optionswert (Typ + validateEngineConfig), wird
//      aber ohne neue explizite Fachfreigabe nie Default. Siehe
//      rule-engine.ts, RULE_ENGINE_CONFIG-Kommentar.
//   2. reconciliation = {relativeTolerance: 0.005, absoluteToleranceMinor: 1}
//      (0.5 % relativ + 1 AW absolut) ist fachlich freigegeben als STARTWERT,
//      nicht als endgültige Zahl. Siehe reconciliation.ts,
//      RECONCILIATION_CONFIG-Kommentar.
//
// Pure. Keine DB-, Zeit- oder I/O-Abhängigkeit — testbar ohne Fixtures.

import { RULE_ENGINE_CONFIG, type RuleEngineConfig } from './rule-engine'
import { RECONCILIATION_CONFIG, type ReconciliationConfig } from './reconciliation'
import { G60_STRUCTURE_GUARD_CONFIG, type G60StructureGuardConfig } from './g60/structure-guard'
import { DEFAULT_DIFFER_BANDS_CONFIG, type DifferBandsConfig } from './differ'
import { DEFAULT_MATCH_CONFIG, type MatchConfig } from './types'
import { FORMULA_ENGINE_CONFIG, type FormulaEngineConfig } from './formula-engine'
import { BUSINESS_RULES_CONFIG, type BusinessRulesConfig } from './business-rules'
import { DEFAULT_MULTI_QAF_DETECTION_CONFIG, type MultiQafDetectionConfig } from './qaf-type-detector'
import { MAKE_OR_BUY_INDICATION_CONFIG, type MakeOrBuyIndicationConfig } from './plausibility'

/**
 * The full, versioned engine configuration — one struct covering every
 * tolerance/threshold section the engine reads at compare time. `configVersion`
 * is bumped whenever ANY section's default values change (not per-section —
 * a single stamp keeps "which config produced this comparison" a one-field
 * lookup in the persisted engine_version, see persistence-mapper.ts).
 */
export interface EngineConfig {
  /** Bumped whenever any DEFAULT_ENGINE_CONFIG section value changes. */
  configVersion: string
  /** R1-R6 Fehlerreport-Regel-Engine enforcement mode (rule-engine.ts, KAR-889/P0.4). */
  ruleEngine: RuleEngineConfig
  /** Summen-Rekonziliations-Toleranzen (reconciliation.ts, KAR-887/P0.2). */
  reconciliation: ReconciliationConfig
  /** G60-Struktur-Anker-Toleranz (g60/structure-guard.ts, KAR-888/P0.3). */
  g60StructureGuard: G60StructureGuardConfig
  /** Status-Bänder für Feld-Deltas (differ.ts, ehemals hart codiert 10/25/50%). */
  differBands: DifferBandsConfig
  /** Matching-Kaskaden-Schwellen (types.ts/matcher.ts, spec B3). */
  matching: MatchConfig
  /** Formel-Extraktion/-Vergleich (formula-engine.ts, KAR-900/P2.1). */
  formulaEngine: FormulaEngineConfig
  /** Zeilen-Ebene Business-Rule-Nachrechnungs-Toleranzen (business-rules.ts, KAR-901/P2.2). */
  businessRules: BusinessRulesConfig
  /** Multi-QAF-Erkennung + sichere Degradation am Ingest-Punkt (qaf-type-
   * detector.ts, KAR-926/Multi-QAF-Programm P0.1). `enabled: true` per
   * Default seit KAR-925 (13.07.2026, Kais' Go TG 8475) — siehe
   * DEFAULT_MULTI_QAF_DETECTION_CONFIG-Kommentar. */
  multiQafDetection: MultiQafDetectionConfig
  /** D17 Fertigungstiefen-Indiz-Schwelle (plausibility.ts, Spec-Erhebung
   * 08.08.2026 — PO-Linie „System liefert Indizien, Bewertung durch die
   * Analyse"). Startwert-Charakter wie reconciliation, siehe
   * MAKE_OR_BUY_INDICATION_CONFIG-Kommentar. */
  makeOrBuyIndication: MakeOrBuyIndicationConfig
}

/**
 * The frozen default — every section's value is taken verbatim from its
 * owning module's existing named constant (RULE_ENGINE_CONFIG etc.), NOT
 * redefined here, so there is exactly one source of truth per section and no
 * risk of this struct silently drifting from what compareQafPair actually
 * used to run with pre-KAR-896. Passing no engineConfig at all anywhere in
 * the engine must stay byte-identical to the pre-KAR-896 behaviour — this is
 * the acceptance criterion (P1.5 backlog: "Risiko niedrig — reiner
 * Read-Pfad-Wechsel mit identischen Default-Werten").
 */
// configVersion bumped to 1.1.0 (KAR-900/P2.1): adds the formulaEngine
// section — unlike every prior section here, this is NOT a config-refactor of
// pre-existing hardcoded behavior (P1.5's original "byte-identical to
// pre-KAR-896" acceptance criterion doesn't apply to a section that didn't
// exist before). formulaEngine.enabled=true means a comparison run under this
// default CAN now produce formel_geaendert/formel_zu_konstante findings that
// were structurally impossible pre-KAR-900 — genuinely new behavior, hence
// the version bump (not a silent default change).
//
// configVersion bumped to 1.2.0 (KAR-901/P2.2): adds the businessRules
// section — same reasoning as the 1.1.0 bump above, genuinely new behavior
// (row-level Leitfaden-Formel-Nachrechnung, rule_calc_* findings) that was
// structurally impossible before this section existed.
//
// configVersion bumped to 1.3.0 (KAR-926/Multi-QAF-Programm P0.1): adds the
// multiQafDetection section — UNLIKE every prior bump above, this section's
// `enabled: false` default means the byte-identical-behavior acceptance
// criterion (P1.5's original "reiner Read-Pfad-Wechsel mit identischen
// Default-Werten") DOES still hold for this section specifically: with
// enabled:false, ingestQafUpload never calls detectMultiQaf at all (see
// actions.ts wiring) — the version bump reflects that a NEW config section
// with real behavior exists (a future enabled:true flip is a one-line
// config change, not a code change), not that the default ingest path's
// observable behavior changed.
// (seit 1.4.0/KAR-925: enabled=true — the paragraph above describes the
// 1.3.0 state ONLY, kept unedited as the historical record of that bump; see
// the 1.4.0 note directly below for the current default and its behavior
// change.)
//
// configVersion bumped to 1.4.0 (KAR-925 Flag-ON, 13.07.2026): multiQafDetection
// default flipped to enabled after Kais' explicit go (TG 8475) — P0-P3 live.
// UNLIKE the 1.3.0 bump above, this DOES change the default ingest path's
// observable behavior for every future comparison: ingestQafUpload now always
// calls detectMultiQaf (bounded scan, see qaf-type-detector.ts's
// HEADER_SCAN_ROWS/MATERIAL_FORMULA_SCAN_ROWS), and a confirmed/probable
// result now ingests the file as a Multi-QAF container (KAR-935/P2.1) instead
// of the pre-KAR-935 hard reject. replaceComparisonFile (file-replace-actions.ts) gates
// this per-comparison via resolveReplaceMultiQafDetectionConfig
// (rehydrate.ts) instead of the general resolvePersistedEngineConfig
// fallback — see that function's doc for why: a comparison persisted before
// this bump (configVersion < 1.4.0, or no recognizable stamp — the common
// case, see rehydrate.ts) keeps enabled:false on replace, so a pre-flip
// comparison's file-replace behavior does not silently change (KAR-925
// adversarial-review F1 fix, 13.07.2026).
//
// configVersion bumped to 1.5.0 (D17 Spec-Erhebung, 08.08.2026): adds the
// makeOrBuyIndication section — same reasoning as the 1.1.0/1.2.0 bumps
// above, genuinely new behavior: with enabled:true a comparison run under
// this default CAN now produce a make_or_buy_indication finding that was
// structurally impossible before this section existed. The threshold
// (10 percentage points) is deliberately conservative and, like the
// reconciliation tolerances, released as a STARTING value, not a final one
// (see MAKE_OR_BUY_INDICATION_CONFIG in plausibility.ts).
export const DEFAULT_ENGINE_CONFIG: EngineConfig = {
  configVersion: '1.5.0',
  ruleEngine: RULE_ENGINE_CONFIG,
  reconciliation: RECONCILIATION_CONFIG,
  g60StructureGuard: G60_STRUCTURE_GUARD_CONFIG,
  differBands: DEFAULT_DIFFER_BANDS_CONFIG,
  matching: DEFAULT_MATCH_CONFIG,
  formulaEngine: FORMULA_ENGINE_CONFIG,
  businessRules: BUSINESS_RULES_CONFIG,
  multiQafDetection: DEFAULT_MULTI_QAF_DETECTION_CONFIG,
  makeOrBuyIndication: MAKE_OR_BUY_INDICATION_CONFIG,
}

/**
 * Pure structural validation — no side effects, does not throw. Returns an
 * empty array when `config` is internally consistent, otherwise one
 * human-readable (DE) message per violation. Intended for both a unit test
 * gate on DEFAULT_ENGINE_CONFIG itself and, once a future DB-backed config
 * exists (see module header), as the gate an operator-supplied override must
 * pass before it can become the active config.
 */
export function validateEngineConfig(config: EngineConfig): string[] {
  const errors: string[] = []

  if (config.configVersion.trim() === '') {
    errors.push('configVersion darf nicht leer sein.')
  }

  if (config.ruleEngine.ruleEnforcement !== 'warn' && config.ruleEngine.ruleEnforcement !== 'block') {
    errors.push(
      `ruleEngine.ruleEnforcement muss 'warn' oder 'block' sein, war "${String(config.ruleEngine.ruleEnforcement)}".`,
    )
  }

  if (!(config.reconciliation.relativeTolerance > 0)) {
    errors.push('reconciliation.relativeTolerance muss positiv sein.')
  }
  if (!(config.reconciliation.absoluteToleranceMinor >= 0)) {
    errors.push('reconciliation.absoluteToleranceMinor darf nicht negativ sein.')
  }

  if (!(config.g60StructureGuard.softConfidence > 0 && config.g60StructureGuard.softConfidence <= 1)) {
    errors.push('g60StructureGuard.softConfidence muss in (0, 1] liegen.')
  }
  if (
    !Number.isInteger(config.g60StructureGuard.hardMismatchThreshold) ||
    config.g60StructureGuard.hardMismatchThreshold < 1
  ) {
    errors.push('g60StructureGuard.hardMismatchThreshold muss eine ganze Zahl >= 1 sein.')
  }
  // relocatedConfidence (KAR-894/P1.3) is optional (defaults to
  // DEFAULT_RELOCATED_CONFIDENCE in structure-guard.ts) — only validated when
  // a caller actually set it, so a pre-P1.3 config object without the field
  // stays valid.
  if (
    config.g60StructureGuard.relocatedConfidence !== undefined &&
    !(config.g60StructureGuard.relocatedConfidence > 0 && config.g60StructureGuard.relocatedConfidence <= 1)
  ) {
    errors.push('g60StructureGuard.relocatedConfidence muss in (0, 1] liegen.')
  }

  const { auffaellig10, auffaellig25, kritisch50 } = config.differBands
  if (!(auffaellig10 > 0)) errors.push('differBands.auffaellig10 muss positiv sein.')
  if (!(auffaellig25 > 0)) errors.push('differBands.auffaellig25 muss positiv sein.')
  if (!(kritisch50 > 0)) errors.push('differBands.kritisch50 muss positiv sein.')
  if (auffaellig10 > 0 && auffaellig25 > 0 && kritisch50 > 0 && !(auffaellig10 < auffaellig25 && auffaellig25 < kritisch50)) {
    errors.push('differBands müssen strikt aufsteigend sein (auffaellig10 < auffaellig25 < kritisch50).')
  }

  const matchingFields: ReadonlyArray<[string, number]> = [
    ['matching.similarityThreshold', config.matching.similarityThreshold],
    ['matching.candidateThreshold', config.matching.candidateThreshold],
    ['matching.costDivergenceReviewThreshold', config.matching.costDivergenceReviewThreshold],
  ]
  for (const [name, value] of matchingFields) {
    if (!(value >= 0 && value <= 1)) errors.push(`${name} muss in [0, 1] liegen, war ${value}.`)
  }

  if (typeof config.formulaEngine.enabled !== 'boolean') {
    errors.push('formulaEngine.enabled muss ein boolean sein.')
  }

  if (!(config.businessRules.relativeTolerance > 0)) {
    errors.push('businessRules.relativeTolerance muss positiv sein.')
  }
  if (!(config.businessRules.absoluteToleranceMinor >= 0)) {
    errors.push('businessRules.absoluteToleranceMinor darf nicht negativ sein.')
  }

  if (typeof config.multiQafDetection.enabled !== 'boolean') {
    errors.push('multiQafDetection.enabled muss ein boolean sein.')
  }
  const multiQafFields: ReadonlyArray<[string, number]> = [
    ['multiQafDetection.strongVariantColumnPopulation', config.multiQafDetection.strongVariantColumnPopulation],
    ['multiQafDetection.weakVariantColumnPopulation', config.multiQafDetection.weakVariantColumnPopulation],
    ['multiQafDetection.strongHeaderBlockRowCount', config.multiQafDetection.strongHeaderBlockRowCount],
    ['multiQafDetection.weakHeaderBlockRowCount', config.multiQafDetection.weakHeaderBlockRowCount],
    ['multiQafDetection.strongMaterialFormulaColumnCount', config.multiQafDetection.strongMaterialFormulaColumnCount],
    ['multiQafDetection.weakMaterialFormulaColumnCount', config.multiQafDetection.weakMaterialFormulaColumnCount],
  ]
  for (const [name, value] of multiQafFields) {
    if (!(value >= 0)) errors.push(`${name} darf nicht negativ sein.`)
  }
  if (config.multiQafDetection.weakVariantColumnPopulation > config.multiQafDetection.strongVariantColumnPopulation) {
    errors.push('multiQafDetection.weakVariantColumnPopulation darf nicht größer als strongVariantColumnPopulation sein.')
  }
  if (config.multiQafDetection.weakHeaderBlockRowCount > config.multiQafDetection.strongHeaderBlockRowCount) {
    errors.push('multiQafDetection.weakHeaderBlockRowCount darf nicht größer als strongHeaderBlockRowCount sein.')
  }
  if (config.multiQafDetection.weakMaterialFormulaColumnCount > config.multiQafDetection.strongMaterialFormulaColumnCount) {
    errors.push(
      'multiQafDetection.weakMaterialFormulaColumnCount darf nicht größer als strongMaterialFormulaColumnCount sein.',
    )
  }

  if (typeof config.makeOrBuyIndication.enabled !== 'boolean') {
    errors.push('makeOrBuyIndication.enabled muss ein boolean sein.')
  }
  if (!(config.makeOrBuyIndication.shareShiftPercentagePoints > 0)) {
    errors.push('makeOrBuyIndication.shareShiftPercentagePoints muss positiv sein.')
  }

  return errors
}

/** Config sections that participate in the delta computation below, in a stable order. */
const ENGINE_CONFIG_SECTION_KEYS = [
  'ruleEngine',
  'reconciliation',
  'g60StructureGuard',
  'differBands',
  'matching',
  'formulaEngine',
  'businessRules',
  'multiQafDetection',
  'makeOrBuyIndication',
] as const

export type EngineConfigSectionKey = (typeof ENGINE_CONFIG_SECTION_KEYS)[number]

/**
 * What actually gets persisted per comparison (P1.5 design: "nur Deltas,
 * nicht das ganze Objekt — Platz"). `overrides` is omitted entirely when
 * `effective` equals `base` in every section — the common case (nobody has
 * overridden anything yet, since there is no DB-backed config in this PR) —
 * so a comparison run under the plain default persists only a configVersion
 * stamp, no bloat.
 */
export interface EngineConfigDelta {
  configVersion: string
  overrides?: Partial<Record<EngineConfigSectionKey, unknown>>
}

/**
 * Section-level delta between an effective config and a base (default:
 * DEFAULT_ENGINE_CONFIG). Deliberately section-granular, not per-leaf-field:
 * a section either matches its base exactly or is carried in full — simpler
 * to reconstruct (resolvePersistedEngineConfig in rehydrate.ts just spreads
 * it back in) and every section is small (2-4 fields), so the size
 * difference against a per-field diff is negligible while the reconstruction
 * logic stays trivial and provably lossless.
 */
export function buildEngineConfigDelta(
  effective: EngineConfig,
  base: EngineConfig = DEFAULT_ENGINE_CONFIG,
): EngineConfigDelta {
  const overrides: Partial<Record<EngineConfigSectionKey, unknown>> = {}
  for (const key of ENGINE_CONFIG_SECTION_KEYS) {
    if (JSON.stringify(effective[key]) !== JSON.stringify(base[key])) {
      overrides[key] = effective[key]
    }
  }
  return Object.keys(overrides).length > 0
    ? { configVersion: effective.configVersion, overrides }
    : { configVersion: effective.configVersion }
}

/**
 * Parse a dotted "major.minor.patch" configVersion stamp into a comparable
 * tuple. Returns `null` for anything that doesn't parse (missing, non-string,
 * wrong shape) — callers treat that the same as "predates every version this
 * comparator knows about", never as "unknown, assume current" (KAR-925
 * adversarial-review F1 fix, 13.07.2026 — see isEngineConfigVersionAtLeast).
 */
function parseConfigVersion(version: string | undefined): [number, number, number] | null {
  if (!version) return null
  const parts = version.split('.').map(Number)
  if (parts.length !== 3 || parts.some((n) => !Number.isFinite(n))) return null
  return parts as [number, number, number]
}

/**
 * True only when `version` is a parseable configVersion stamp that is >=
 * `min` (both "major.minor.patch"). A missing/unparseable `version` always
 * compares as OLDER than any real stamp, never as "unknown, assume current"
 * — a caller gating stricter behavior on this check (e.g.
 * resolveReplaceMultiQafDetectionConfig, rehydrate.ts) therefore defaults to
 * the safer/older branch whenever the stamp is absent, matching rehydrate.
 * ts's documented invariant that a missing key must never silently upgrade
 * an old comparison to a stricter mode (KAR-925 adversarial-review F1 fix,
 * 13.07.2026).
 */
export function isEngineConfigVersionAtLeast(version: string | undefined, min: string): boolean {
  const a = parseConfigVersion(version)
  const b = parseConfigVersion(min)
  if (!a || !b) return false
  for (let i = 0; i < 3; i++) {
    if (a[i] !== b[i]) return a[i] > b[i]
  }
  return true
}
