// Multi-QAF per-variant Summary-Kennzahlen diff (KAR-951 / Multi-QAF-Programm,
// urgent livetest fix, Epic KAR-925).
//
// Problem this closes (livetest KAR-951 task brief, verified 2026-07-13): a
// Multi-QAF ALT/NEU pair where only 2 GLOBAL surcharge parameters changed on
// the Summary sheet (P21/P22, e.g. "Overhead"/"zus. Standort-OH" percentage
// factors) produced ZERO findings anywhere in the app, even though the
// resulting computed Summary metrics (SUMME Overhead / ANGEBOTSBASISPREIS /
// ...) moved for every one of the 26 matched variants. Root cause:
// `VirtualVariantSummaryTotals` (types.ts — materialCosts,
// materialCostsByCurrency, manufacturingCosts, totalProductionCosts,
// toolingAndFixtureCost, setupCostAllocation, scrap, otherSurcharges,
// offerBasePrice, offerPrice, one per matched variant) was assembled by
// container-assembly.ts but never diffed ALT<->NEU by ANY module —
// compare-flow.ts only orchestrates containerDiff/materialDiff/profileDiff/
// reconciliation, none of which touch the Summary sheet's OWN computed
// totals at all. A silent "no differences" on a genuinely changed file is
// the worst failure class (Master-Prompt §3) — this module is that missing
// diff.
//
// ── §19 boundary — a SEPARATE surface, never merged into aggregate-impact.ts
// ──────────────────────────────────────────────────────────────────────────
// aggregate-impact.ts (KAR-944/P3.3) builds its "aggregate commercial
// impact" EXCLUSIVELY from material-/profile-COMPONENT-level deltas
// (materialDiff/profileDiff) — it has no concept of the Summary sheet's own
// already-computed totals at all. This module's `changedMetricSumsByCurrency`
// is a DIFFERENT, additional surface: "what does the Summary sheet's own
// computed total say", summed ONLY over metrics this module itself found
// `changed` — it is never fed into computeAggregateImpact and never merged
// with its output, so a caller can never double-count the same commercial
// delta once via material/profile components and once via this module's own
// Summary-total sum. compare-flow.ts's own module header documents exactly
// where `summaryTotalsDiff` sits in the pipeline (a peer of materialDiff/
// profileDiff, not a dependency of aggregateImpact).
//
// ── Fail-closed (Master-Prompt §7, KAR-936 matched-only gate) ──────────────
// Only `matchResult` entries of `kind: 'matched'` are diffed; every other
// kind ('ambiguous'/'split_suspected'/'merge_suspected'/'unmatched_left'/
// 'unmatched_right') is never guessed at — 'ambiguous'/'split_suspected'/
// 'merge_suspected' pairs are carried through verbatim on `uncertainMatches`
// (same "pass through, never resolve" discipline material-differ.ts's own
// `uncertainMatches` already establishes); unmatched-left/right variants have
// no counterpart to diff against at all and produce no finding here (they
// already surface via containerDiff.variants.added/removed).
//
// ── 3-state per metric (task instruction) ───────────────────────────────────
// Every metric on every matched pair gets exactly one of:
//   'changed'          — both sides have a known value (not null) AND
//                         (the numeric delta is non-'konstant' OR the
//                         currency itself changed).
//   'unchanged'        — both sides known, numerically 'konstant', same
//                         currency (or both currencies equally unknown).
//   'nicht_ermittelbar' — at least one side's value is null (never silently
//                         read as 'unchanged' — `missingSide` records which
//                         side, or 'both').
// A metric is NEVER reported 'changed' merely because currency metadata is
// unknown on one side (that stays 'currency_unknown' on the finding, an
// honest "can't tell" state, not a fabricated swap) — only a genuine
// ALT<->NEU currency STRING difference (both sides known, unequal) sets
// `currencyChanged: true`.
//
// ── Vorzeichen-Konvention (aggregate-impact.ts's own doc header) ───────────
// deltaAbsolute/deltaPercent are always NEU − ALT, via differ.ts's
// `computeNumericDelta` — the SAME function (same EPSILON/band thresholds)
// material-differ.ts/profile-differ.ts already use, per task instruction
// "identische Konvention... keine neue Epsilon-Erfindung".
//
// ── Mixed-Currency-Doktrin (KAR-935/KAR-938 precedent, respected here) ─────
// A metric's own deltaAbsolute/deltaPercent/status ARE still computed from
// the two raw numbers even when currencyChanged is true — same "field-level
// delta is currency-agnostic, only the AGGREGATE gates on currency parity"
// split material-differ.ts's `buildMoneyFieldFinding`/`computeSharedImpact`
// already establishes (module header there). What NEVER happens is
// `changedMetricSumsByCurrency` summing two findings whose currencies
// disagree into one number — that sum only includes findings whose
// `currencyGate === 'same_currency'`, keyed by that shared currency, exactly
// mirroring `MaterialImpactAggregate`'s own per-currency-bucket shape.
// `materialCostsByCurrency` is compared per-currency-bucket directly (its own
// finding array, `materialCostsByCurrencyFindings`) — bucket VALUES are
// already same-currency by construction (each bucket IS one currency), so no
// separate currency-swap detection applies there; a currency present on only
// one side is its own 'nicht_ermittelbar'-shaped finding (bucket added/
// removed), never summed against a different bucket.
//
// tdd-guard: covered by __tests__/summary-totals-differ.test.ts (synthetic —
// changed/unchanged/null-one-side/null-both-sides/currency-swap/mixed-
// currency-materialCostsByCurrency, Q7x/T95-style invented fixture ids) and
// __tests__/summary-totals-differ.real-files.test.ts (env-gated: the KAR-951
// livetest ALT/NEU pair — aggregate/counter assertions only, never a literal
// cell value/code/filename from that corpus — plus the 4-file self-compare
// regression corpus already used by material-differ.real-files.test.ts /
// profile-differ.real-files.test.ts).

import { computeNumericDelta, DEFAULT_DIFFER_BANDS_CONFIG, round4, type DifferBandsConfig } from '../differ'
import type { DiffStatus } from '../types'
import type { MultiQafCellRef, MultiQafContainer, MultiQafMoneyAmount, VirtualVariantSummaryTotals } from './types'
import type {
  VariantAmbiguousResult,
  VariantMatchedResult,
  VariantMatchResult,
  VariantMergeSuspectedResult,
  VariantSplitSuspectedResult,
} from './variant-matcher'

// ── Config (own named section — same "engine-config, no magic numbers"
// discipline material-differ.ts/profile-differ.ts already establish) ───────

export interface SummaryTotalsDifferConfig {
  bands: DifferBandsConfig
}

export const DEFAULT_SUMMARY_TOTALS_DIFFER_CONFIG: SummaryTotalsDifferConfig = { bands: DEFAULT_DIFFER_BANDS_CONFIG }

export interface SummaryTotalsDiffOptions {
  config?: SummaryTotalsDifferConfig
}

// ── Metric vocabulary ───────────────────────────────────────────────────────

/** Every VirtualVariantSummaryTotals money field EXCEPT
 * `materialCostsByCurrency` (compared separately below, per-currency-bucket
 * — see module header) and `variantId` (the join key, not a metric). Order
 * mirrors VirtualVariantSummaryTotals's own field order (types.ts) — also
 * this module's finding sort/display order. */
export const SUMMARY_TOTALS_METRIC_KEYS = [
  'materialCosts',
  'manufacturingCosts',
  'totalProductionCosts',
  'toolingAndFixtureCost',
  'setupCostAllocation',
  'scrap',
  'otherSurcharges',
  'offerBasePrice',
  'offerBasePriceInclAllocation',
  'offerPrice',
] as const

export type SummaryTotalsMetricKey = (typeof SUMMARY_TOTALS_METRIC_KEYS)[number]

export type SummaryTotalsMetricState = 'changed' | 'unchanged' | 'nicht_ermittelbar'

/** Same 3-way currency gate material-differ.ts's own `CurrencyGateState`
 * establishes — kept as this module's own copy (not imported) since that
 * type is module-private there; identical semantics. */
export type SummaryTotalsCurrencyGate = 'same_currency' | 'currency_changed' | 'currency_unknown'

/** Set only when `state === 'nicht_ermittelbar'` — which side's value was
 * `null` (task instruction: "welche Seite fehlt, festhalten"). */
export type SummaryTotalsMissingSide = 'alt' | 'neu' | 'both'

export interface SummaryTotalsMetricFinding {
  metricKey: SummaryTotalsMetricKey
  altVariantId: string
  neuVariantId: string
  state: SummaryTotalsMetricState
  missingSide?: SummaryTotalsMissingSide
  altAmount: MultiQafMoneyAmount | null
  neuAmount: MultiQafMoneyAmount | null
  /** `true` exactly when both sides' values are known AND their currency
   * strings differ (module header "3-state per metric") — a currency swap
   * is its own reportable fact, independent of `state`/`deltaAbsolute`. */
  currencyChanged: boolean
  currencyGate: SummaryTotalsCurrencyGate
  /** NEU − ALT (differ.ts computeNumericDelta) — `null` whenever `state`
   * is `'nicht_ermittelbar'`; still computed (module header "Mixed-Currency-
   * Doktrin") even when `currencyChanged` is true. */
  deltaAbsolute: number | null
  deltaPercent: number | null
  status: DiffStatus | null
  /** Combined ALT+NEU source cells, ONLY for the metrics
   * container-assembly.ts's `summaryMoneyRowsByVariant` tracks provenance
   * for (scrap/otherSurcharges/offerBasePrice/offerBasePriceInclAllocation/
   * offerPrice) — empty for the other 5 metrics, which carry no per-field
   * cell provenance anywhere in this package (task instruction "soweit
   * vorhanden"). */
  sourceRefs: readonly MultiQafCellRef[]
}

export interface SummaryTotalsMaterialCurrencyFinding {
  altVariantId: string
  neuVariantId: string
  /** `null` represents the "currency itself unknown" bucket
   * (materialCostsByCurrency's own convention, types.ts doc). */
  currency: string | null
  state: SummaryTotalsMetricState
  missingSide?: SummaryTotalsMissingSide
  altValue: number | null
  neuValue: number | null
  deltaAbsolute: number | null
  deltaPercent: number | null
  status: DiffStatus | null
}

export interface SummaryTotalsCurrencySum {
  currency: string
  /** Sum of every `state === 'changed'` finding's `deltaAbsolute` whose
   * `currencyGate === 'same_currency'` and NEU currency equals this bucket's
   * currency — see module header "Mixed-Currency-Doktrin": a
   * `currency_changed`/`currency_unknown` finding is NEVER folded into any
   * bucket here. */
  totalDeltaAbsolute: number
  metricFindingCount: number
}

export interface SummaryTotalsDiffResult {
  findings: readonly SummaryTotalsMetricFinding[]
  materialCostsByCurrencyFindings: readonly SummaryTotalsMaterialCurrencyFinding[]
  /** Pass-through, never resolved here — same discipline material-differ.ts/
   * profile-differ.ts's own `uncertainMatches` already establish. */
  uncertainMatches: readonly (VariantAmbiguousResult | VariantSplitSuspectedResult | VariantMergeSuspectedResult)[]
  /** §19-separate summary-sheet-based sums — see module header. Explicitly
   * labeled `summary-basiert` by callers (compare-flow.ts doc / UI), never
   * merged with aggregate-impact.ts's material-/profile-component-based
   * numbers. */
  changedMetricSumsByCurrency: readonly SummaryTotalsCurrencySum[]
}

// ── Currency gate (identical semantics to material-differ.ts's private
// currencyGateState — see that module's own doc for why 'currency_unknown'
// must never collapse into 'currency_changed') ─────────────────────────────

function currencyGateState(altCurrency: string | null, neuCurrency: string | null): SummaryTotalsCurrencyGate {
  if (altCurrency === null || neuCurrency === null) return 'currency_unknown'
  return altCurrency === neuCurrency ? 'same_currency' : 'currency_changed'
}

function sourceCellsFor(
  altTotalsSourceCell: MultiQafCellRef | null | undefined,
  neuTotalsSourceCell: MultiQafCellRef | null | undefined,
): readonly MultiQafCellRef[] {
  const out: MultiQafCellRef[] = []
  if (altTotalsSourceCell) out.push(altTotalsSourceCell)
  if (neuTotalsSourceCell) out.push(neuTotalsSourceCell)
  return out
}

// ── Per-metric diff (one matched pair, one metric key) ──────────────────────

function diffOneMetric(
  metricKey: SummaryTotalsMetricKey,
  altVariantId: string,
  neuVariantId: string,
  altAmount: MultiQafMoneyAmount | null,
  neuAmount: MultiQafMoneyAmount | null,
  sourceRefs: readonly MultiQafCellRef[],
  bands: DifferBandsConfig,
): SummaryTotalsMetricFinding {
  const altValue = altAmount?.value ?? null
  const neuValue = neuAmount?.value ?? null
  const altMissing = altValue === null
  const neuMissing = neuValue === null

  if (altMissing || neuMissing) {
    const missingSide: SummaryTotalsMissingSide = altMissing && neuMissing ? 'both' : altMissing ? 'alt' : 'neu'
    return {
      metricKey,
      altVariantId,
      neuVariantId,
      state: 'nicht_ermittelbar',
      missingSide,
      altAmount,
      neuAmount,
      currencyChanged: false,
      currencyGate: 'currency_unknown',
      deltaAbsolute: null,
      deltaPercent: null,
      status: null,
      sourceRefs,
    }
  }

  const altCurrency = altAmount?.currency ?? null
  const neuCurrency = neuAmount?.currency ?? null
  const currencyGate = currencyGateState(altCurrency, neuCurrency)
  // KAR-951 F3/F4 fix (review findings): a currency swap requires BOTH
  // sides' currency to be genuinely KNOWN and unequal — `null` (unknown) on
  // either side is `currencyGate === 'currency_unknown'`'s own territory
  // (module header "3-state per metric"), never a fabricated "changed"
  // currency. The original `altCurrency !== neuCurrency` treated `null` on
  // one side as automatically different from a known currency string
  // (e.g. `{ value: 100, currency: null }` vs. `{ value: 100, currency:
  // 'EUR' }`), which reported `state: 'changed'` for two sides carrying the
  // SAME numeric value — a fabricated currency swap.
  const currencyChanged = currencyGate === 'currency_changed'
  const delta = computeNumericDelta(altValue, neuValue, bands)
  const state: SummaryTotalsMetricState = delta.status !== 'konstant' || currencyChanged ? 'changed' : 'unchanged'

  return {
    metricKey,
    altVariantId,
    neuVariantId,
    state,
    altAmount,
    neuAmount,
    currencyChanged,
    currencyGate,
    deltaAbsolute: delta.deltaAbsolute,
    deltaPercent: delta.deltaPercent,
    status: delta.status,
    sourceRefs,
  }
}

// ── materialCostsByCurrency (per-currency-bucket diff) ──────────────────────

function currencyBucketKey(currency: string | null): string {
  return currency ?? ' unknown'
}

function diffMaterialCostsByCurrency(
  altVariantId: string,
  neuVariantId: string,
  altBuckets: readonly MultiQafMoneyAmount[],
  neuBuckets: readonly MultiQafMoneyAmount[],
  bands: DifferBandsConfig,
): SummaryTotalsMaterialCurrencyFinding[] {
  const altByCurrency = new Map(altBuckets.map((b) => [currencyBucketKey(b.currency), b] as const))
  const neuByCurrency = new Map(neuBuckets.map((b) => [currencyBucketKey(b.currency), b] as const))
  const allKeys = new Set<string>([...altByCurrency.keys(), ...neuByCurrency.keys()])

  const findings: SummaryTotalsMaterialCurrencyFinding[] = []
  for (const key of allKeys) {
    const altBucket = altByCurrency.get(key) ?? null
    const neuBucket = neuByCurrency.get(key) ?? null
    const currency = altBucket?.currency ?? neuBucket?.currency ?? null
    const altValue = altBucket?.value ?? null
    const neuValue = neuBucket?.value ?? null
    const altMissing = altValue === null
    const neuMissing = neuValue === null

    if (altMissing || neuMissing) {
      findings.push({
        altVariantId,
        neuVariantId,
        currency,
        state: 'nicht_ermittelbar',
        missingSide: altMissing && neuMissing ? 'both' : altMissing ? 'alt' : 'neu',
        altValue,
        neuValue,
        deltaAbsolute: null,
        deltaPercent: null,
        status: null,
      })
      continue
    }

    const delta = computeNumericDelta(altValue, neuValue, bands)
    findings.push({
      altVariantId,
      neuVariantId,
      currency,
      state: delta.status === 'konstant' ? 'unchanged' : 'changed',
      altValue,
      neuValue,
      deltaAbsolute: delta.deltaAbsolute,
      deltaPercent: delta.deltaPercent,
      status: delta.status,
    })
  }

  return findings.sort((a, b) => currencyBucketKey(a.currency).localeCompare(currencyBucketKey(b.currency)))
}

// ── Sums (§19-separate — module header) ─────────────────────────────────────

function buildChangedMetricSums(findings: readonly SummaryTotalsMetricFinding[]): SummaryTotalsCurrencySum[] {
  const byCurrency = new Map<string, { total: number; count: number }>()
  for (const f of findings) {
    if (f.state !== 'changed') continue
    if (f.currencyGate !== 'same_currency') continue
    if (f.deltaAbsolute === null) continue
    const currency = f.neuAmount?.currency ?? f.altAmount?.currency
    if (!currency) continue
    const bucket = byCurrency.get(currency) ?? { total: 0, count: 0 }
    bucket.total += f.deltaAbsolute
    bucket.count += 1
    byCurrency.set(currency, bucket)
  }
  return [...byCurrency.entries()]
    .map(([currency, { total, count }]) => ({ currency, totalDeltaAbsolute: round4(total), metricFindingCount: count }))
    .sort((a, b) => a.currency.localeCompare(b.currency))
}

// ── Orchestration ────────────────────────────────────────────────────────────

function totalsByVariantId(container: MultiQafContainer): ReadonlyMap<string, VirtualVariantSummaryTotals> {
  return new Map(container.summaryAggregation.perVariant.map((t) => [t.variantId, t] as const))
}

/**
 * Diffs every matched variant pair's `VirtualVariantSummaryTotals` ALT<->NEU
 * — the module this KAR-951 livetest gap requires (module header). Reads
 * `alt.summaryAggregation.perVariant`/`neu.summaryAggregation.perVariant`
 * (already populated by container-assembly.ts's `generateVirtualVariants` —
 * this module never re-derives them) plus `alt.summaryMoneyRowsByVariant`/
 * `neu.summaryMoneyRowsByVariant` for the money-metrics' source-cell
 * provenance.
 */
export function diffSummaryTotals(
  alt: MultiQafContainer,
  neu: MultiQafContainer,
  matchResult: readonly VariantMatchResult[],
  options: SummaryTotalsDiffOptions = {},
): SummaryTotalsDiffResult {
  const bands = options.config?.bands ?? DEFAULT_SUMMARY_TOTALS_DIFFER_CONFIG.bands
  const altTotals = totalsByVariantId(alt)
  const neuTotals = totalsByVariantId(neu)

  const findings: SummaryTotalsMetricFinding[] = []
  const materialCostsByCurrencyFindings: SummaryTotalsMaterialCurrencyFinding[] = []
  const uncertainMatches: (VariantAmbiguousResult | VariantSplitSuspectedResult | VariantMergeSuspectedResult)[] = []

  for (const r of matchResult) {
    if (r.kind === 'ambiguous' || r.kind === 'split_suspected' || r.kind === 'merge_suspected') {
      uncertainMatches.push(r)
      continue
    }
    if (r.kind !== 'matched') continue // unmatched_left/unmatched_right — containerDiff's own added/removed territory.
    const matched = r as VariantMatchedResult
    const altVariantId = matched.leftId
    const neuVariantId = matched.rightId
    const altT = altTotals.get(altVariantId)
    const neuT = neuTotals.get(neuVariantId)
    if (!altT || !neuT) continue // no VirtualQafVariant materialized for this id — nothing to diff.

    const altMoneyRows = alt.summaryMoneyRowsByVariant[altVariantId]
    const neuMoneyRows = neu.summaryMoneyRowsByVariant[neuVariantId]

    for (const metricKey of SUMMARY_TOTALS_METRIC_KEYS) {
      const sourceRefs =
        metricKey === 'scrap' ||
        metricKey === 'otherSurcharges' ||
        metricKey === 'offerBasePrice' ||
        metricKey === 'offerBasePriceInclAllocation' ||
        metricKey === 'offerPrice'
          ? sourceCellsFor(altMoneyRows?.[metricKey]?.sourceCell, neuMoneyRows?.[metricKey]?.sourceCell)
          : []
      findings.push(diffOneMetric(metricKey, altVariantId, neuVariantId, altT[metricKey], neuT[metricKey], sourceRefs, bands))
    }

    materialCostsByCurrencyFindings.push(
      ...diffMaterialCostsByCurrency(altVariantId, neuVariantId, altT.materialCostsByCurrency, neuT.materialCostsByCurrency, bands),
    )
  }

  findings.sort((a, b) => a.neuVariantId.localeCompare(b.neuVariantId) || a.metricKey.localeCompare(b.metricKey))
  materialCostsByCurrencyFindings.sort(
    (a, b) => a.neuVariantId.localeCompare(b.neuVariantId) || currencyBucketKey(a.currency).localeCompare(currencyBucketKey(b.currency)),
  )

  return {
    findings,
    materialCostsByCurrencyFindings,
    uncertainMatches,
    changedMetricSumsByCurrency: buildChangedMetricSums(findings),
  }
}
