// QAF Diff layer (KAR-799, spec B13).
//
// Field-level delta with absolute, relative (%) and — for percent fields —
// percentage-point components, plus a status band. Pure functions.
//
// Extends (does not duplicate) lib/qaf/comparison.ts: that module computes
// absolute-only deltas; here we add relative % + percentage-points + status
// bands, reusing its NUMERIC_FIELDS list (DRY).

import type { QAFRow, QAFFieldKey } from '@/lib/qaf-parser'
import { NUMERIC_FIELDS } from '@/lib/qaf/comparison'
import { isPercentField, type FieldDiff, type DiffStatus } from './types'
import { isNotApplicableValue } from './normalizer'
import { compareFormulaPair, type FormulaProvenance } from './formula-engine'

const EPSILON = 1e-9

/**
 * Status-band thresholds (P1.5/KAR-896 — named config, same pattern as
 * RULE_ENGINE_CONFIG/RECONCILIATION_CONFIG/G60_STRUCTURE_GUARD_CONFIG). The
 * three fields are relative-delta magnitude cutoffs (fractions, e.g. 0.1 =
 * 10%) above which a field delta escalates to the next DiffStatus band.
 */
export interface DifferBandsConfig {
  /** Magnitude above which a delta counts as 'auffaellig_10' (currently 10%). */
  auffaellig10: number
  /** Magnitude above which a delta counts as 'auffaellig_25' (currently 25%). */
  auffaellig25: number
  /** Magnitude above which a delta counts as 'kritisch_50' (currently 50%). */
  kritisch50: number
}

export const DEFAULT_DIFFER_BANDS_CONFIG: DifferBandsConfig = {
  auffaellig10: 0.1,
  auffaellig25: 0.25,
  kritisch50: 0.5,
}

/** Round helper mirroring the existing computeDelta precision (4 dp).
 * Exported (KAR-944 adversarial review F3) so multi-qaf/material-differ.ts,
 * multi-qaf/profile-differ.ts, and multi-qaf/aggregate-impact.ts import this
 * ONE implementation instead of each keeping its own private copy — the
 * fourth copy (aggregate-impact.ts) was the one that tipped this into
 * genuine drift risk, not a defensible per-module precedent. (multi-qaf/
 * variant-reconciliation.ts keeps its own copy — that module's header
 * comment already documents it as an established, deliberate exception,
 * unaffected by this change.) */
export function round4(n: number): number {
  return Number(n.toFixed(4))
}

function bandStatus(
  deltaAbsolute: number,
  deltaPercent: number | null,
  bands: DifferBandsConfig = DEFAULT_DIFFER_BANDS_CONFIG,
): DiffStatus {
  if (Math.abs(deltaAbsolute) < EPSILON) return 'konstant'

  // Without a relative delta (ALT === 0) we can only state direction.
  if (deltaPercent === null) return deltaAbsolute > 0 ? 'anstieg' : 'senkung'

  const magnitude = Math.abs(deltaPercent)
  if (magnitude > bands.kritisch50) return 'kritisch_50'
  if (magnitude > bands.auffaellig25) return 'auffaellig_25'
  if (magnitude > bands.auffaellig10) return 'auffaellig_10'
  return deltaAbsolute > 0 ? 'anstieg' : 'senkung'
}

/** Value-level delta shared by step-field diffs and summary-metric diffs. */
export interface NumericDelta {
  deltaAbsolute: number | null
  deltaPercent: number | null
  status: DiffStatus
}

/**
 * Delta between two numeric values (NEU − ALT) with the shared status bands.
 * - deltaAbsolute = NEU − ALT (null if a side is missing)
 * - deltaPercent  = deltaAbsolute / |ALT| as a fraction; null when ALT is 0/empty
 *                   (spec: no forced percent delta against a zero/empty baseline)
 */
export function computeNumericDelta(
  alt: number | null,
  neu: number | null,
  bands: DifferBandsConfig = DEFAULT_DIFFER_BANDS_CONFIG,
): NumericDelta {
  const altMissing = alt === null || alt === undefined
  const neuMissing = neu === null || neu === undefined

  if (altMissing && neuMissing) return { deltaAbsolute: null, deltaPercent: null, status: 'nicht_berechenbar' }
  if (altMissing) return { deltaAbsolute: null, deltaPercent: null, status: 'neu' }
  if (neuMissing) return { deltaAbsolute: null, deltaPercent: null, status: 'entfallen' }

  const deltaAbsolute = round4(neu! - alt!)
  const deltaPercent = alt !== 0 ? Number((deltaAbsolute / Math.abs(alt!)).toFixed(6)) : null
  return { deltaAbsolute, deltaPercent, status: bandStatus(deltaAbsolute, deltaPercent, bands) }
}

/**
 * Diff one numeric field between ALT and NEU.
 * - deltaPercentagePoints = NEU − ALT, only for percent fields (SGK, Ausschuss)
 */
export function computeFieldDiff(
  field: keyof QAFRow,
  alt: number | null,
  neu: number | null,
  bands: DifferBandsConfig = DEFAULT_DIFFER_BANDS_CONFIG,
): FieldDiff {
  const pct = isPercentField(field)
  const delta = computeNumericDelta(alt, neu, bands)

  return {
    field,
    altValue: alt,
    neuValue: neu,
    deltaAbsolute: delta.deltaAbsolute,
    deltaPercent: delta.deltaPercent,
    deltaPercentagePoints: pct ? delta.deltaAbsolute : null,
    isPercentField: pct,
    status: delta.status,
  }
}

/**
 * Diff one field where either side's raw cell text may carry an explicit
 * "not applicable" marker (spec Master-Prompt §13 / P0.6, KAR-891). This is
 * additive to computeFieldDiff, not a replacement: when neither raw value is
 * n.a. it falls straight through to the existing numeric-delta logic.
 *
 * When either side IS n.a., the pair gets its own status —
 * 'nicht_anwendbar' — instead of collapsing into 'neu' (ALT missing),
 * 'entfallen' (NEU missing) or 'nicht_berechenbar' (both missing): a field
 * consciously marked "n.a." was not left blank, and comparing it against a
 * real value is not a structural add/removal, just a state that can't be
 * diffed numerically. n.a.<->n.a. also lands here (consistent, unauffaellig).
 */
export function computeFieldDiffWithNotApplicable(
  field: keyof QAFRow,
  altRaw: unknown,
  neuRaw: unknown,
  alt: number | null,
  neu: number | null,
  bands: DifferBandsConfig = DEFAULT_DIFFER_BANDS_CONFIG,
): FieldDiff {
  if (isNotApplicableValue(altRaw) || isNotApplicableValue(neuRaw)) {
    return {
      field,
      altValue: alt,
      neuValue: neu,
      deltaAbsolute: null,
      deltaPercent: null,
      deltaPercentagePoints: null,
      isPercentField: isPercentField(field),
      status: 'nicht_anwendbar',
    }
  }
  return computeFieldDiff(field, alt, neu, bands)
}

/**
 * Formula-aware post-processing of one already-computed FieldDiff (KAR-900/
 * P2.1) — consults the two sides' optional `formulas` provenance and, per
 * compareFormulaPair's FormulaComparisonKind state machine:
 *   - 'formel_geaendert_wert_gleich' -> overrides `status` (the hidden-risk
 *     case Master-Prompt §12.4 calls out — a plain numeric-delta status would
 *     otherwise read 'konstant' and hide the change entirely).
 *   - 'formel_zu_konstante' -> overrides `status` (classic manipulation
 *     pattern, KAR-861).
 *   - 'formel_geaendert_wert_geaendert' -> leaves `status` as the normal band
 *     (the value diff is already visible), attaches `formulaFinding` only.
 *   - 'unauffaellig' with inputsChanged -> leaves `status` as the normal
 *     band, sets `formulaInputsChanged` (task spec: "normale
 *     Wert-Diff-Semantik + Kennzeichnung inputs_changed").
 *   - 'no_formula_data' / 'unauffaellig' without inputsChanged -> untouched.
 * `nicht_anwendbar` (P0.6/KAR-891) takes precedence over every formula
 * override — an explicit "n.a." marker means the value isn't meaningfully
 * comparable at all, so a formula-structure signal would be noise there.
 * Pure.
 */
function applyFormulaComparison(
  fd: FieldDiff,
  altFormula: FormulaProvenance | undefined,
  neuFormula: FormulaProvenance | undefined,
  altValue: number | null,
  neuValue: number | null,
): FieldDiff {
  if (fd.status === 'nicht_anwendbar') return fd
  const cmp = compareFormulaPair({ altFormula, neuFormula, altValue, neuValue })
  switch (cmp.kind) {
    case 'formel_geaendert_wert_gleich':
      return { ...fd, status: 'formel_geaendert', formulaFinding: { kind: cmp.kind, explanation: cmp.explanation } }
    case 'formel_zu_konstante':
      return { ...fd, status: 'formel_zu_konstante', formulaFinding: { kind: cmp.kind, explanation: cmp.explanation } }
    case 'formel_geaendert_wert_geaendert':
      return { ...fd, formulaFinding: { kind: cmp.kind, explanation: cmp.explanation } }
    case 'unauffaellig':
      return cmp.inputsChanged ? { ...fd, formulaInputsChanged: true } : fd
    case 'no_formula_data':
    default:
      return fd
  }
}

/**
 * Diff two matched process steps across all numeric fields. Either side may be
 * null to represent a new (alt=null) or removed (neu=null) step.
 *
 * Uses the optional `rawText` provenance (KAR-891) when present — a numeric
 * field whose Excel cell held an explicit n.a. marker (e.g. "n.a.", "entfällt")
 * parses to null just like a truly empty cell, so without rawText the two are
 * indistinguishable here.
 *
 * `formulaEngineEnabled` (KAR-900/P2.1, default true — matches
 * FORMULA_ENGINE_CONFIG's default) gates the optional `formulas` provenance
 * post-processing above; false reproduces pre-P2.1 behavior exactly (every
 * pre-existing caller that doesn't pass this 4th argument is unaffected only
 * because the default is true AND rows without `formulas` set produce
 * 'no_formula_data' regardless — the flag exists for compare.ts to honor
 * engineConfig.formulaEngine.enabled without every test needing to know
 * about it).
 */
export function diffSteps(
  alt: QAFRow | null,
  neu: QAFRow | null,
  bands: DifferBandsConfig = DEFAULT_DIFFER_BANDS_CONFIG,
  formulaEngineEnabled = true,
): FieldDiff[] {
  return NUMERIC_FIELDS.map((field) => {
    // NUMERIC_FIELDS only ever lists QAFRowValues keys (never the
    // sourceCells/normalized/rawText/formulas provenance keys), so this
    // narrowing is safe — it just gives us a type that can index those maps.
    const key = field as QAFFieldKey
    const fd = computeFieldDiffWithNotApplicable(
      field,
      alt?.rawText?.[key],
      neu?.rawText?.[key],
      alt ? (alt[field] as number | null) : null,
      neu ? (neu[field] as number | null) : null,
      bands,
    )
    if (!formulaEngineEnabled) return fd
    return applyFormulaComparison(
      fd,
      alt?.formulas?.[key],
      neu?.formulas?.[key],
      alt ? (alt[field] as number | null) : null,
      neu ? (neu[field] as number | null) : null,
    )
  })
}
