// Multi-QAF Compare-Flow MQ<->MQ end-to-end (KAR-942 / Multi-QAF-Programm
// P3.1, Epic KAR-925) — DAS Wiring-Item.
//
// Problem this closes (30-backlog-phasenplan.md P3.1): P2.1-P2.6 shipped six
// independent, unit-tested MODULES (container-assembly.ts assembles ONE
// MultiQafContainer; variant-matcher.ts matches two containers' variants;
// container-differ.ts/material-differ.ts/profile-differ.ts each diff a
// DIFFERENT facet of an already-matched pair; variant-reconciliation.ts
// verifies ONE container's own internal consistency) — but nothing ever
// CALLED them together, and nothing ever PERSISTED a result. Two
// `qaf_file` rows with `template_type: 'MULTI_QAF'` sat in the batch/project
// with no `qaf_comparison` row pointing at them at all (actions.ts's
// `analyzeQafBatchFromStorage`, `kind === 'multi_qaf'` branch, KAR-935 P2.1:
// "no comparison run (Compare is P2.3+ scope)"). This module is that
// orchestration: given two already-assembled `MultiQafContainer`s (rehydrated
// from `qaf_file.g60_meta.multiQafContainer` via serialization.ts — this
// module never re-parses a workbook, same "compute once, read from storage
// thereafter" discipline templateFingerprint/manufacturingParseMeta already
// establish for the standard/G60 paths), runs the full P2 pipeline and
// returns ONE composed, versioned, JSON-serializable `MultiQafComparisonResult`
// ready to persist.
//
// ── Pipeline (mirrors this module's own `runMultiQafCompareFlow` body) ─────
//   1. allMultiQafContainerVariants(alt)/(neu) — active + inactive, container-
//      differ.ts's own "both buckets must be matchable" default.
//   2. Persisted `VariantMatchOverride[]` (if any — KAR-912 field-mapping-
//      override.ts precedent, applied to variant PAIRS instead of single
//      file fields; see this module's own doc below for exactly where a
//      caller persists these) are applied via `matchVariantsWithOverrides`;
//      otherwise a plain `matchVariants` cascade runs.
//   3. `diffContainers` (structural, KAR-937/P2.3), `diffMaterial` (value-
//      level material, KAR-938/P2.4), `diffProfiles` (value-level
//      manufacturing/setup-cost profiles, KAR-939/P2.5) all run off the SAME
//      `matchResult` — the shared-matchResult contract every one of those
//      three modules' own doc comments already requires (an id that does not
//      resolve against the container's own `activeVariants`/
//      `inactiveVariants` throws, fail-closed).
//   4. `reconcileContainer` runs once per SIDE (alt, neu independently — it
//      verifies ONE container's own internal consistency, Master-Prompt §13;
//      it is not an alt-vs-neu diff and never becomes one here).
//   5. `reviewRequired`/`reviewRequiredReasons` are lifted verbatim from
//      `diffContainers`' own computed floor (that function's own doc:
//      "never overridable downward") plus ONE additive reason this module
//      contributes (`match_overrides_dropped_on_drift`) — never re-derived
//      from scratch, so this module can never disagree with container-
//      differ.ts about what its OWN signal means.
//   6. `aggregate-impact.ts`'s `computeAggregateImpact` (KAR-944/P3.3, the
//      LAST P3 item) runs LAST, over the exact `matchResult`/`containerDiff`/
//      `materialDiff`/`profileDiff`/`reconciliation`/`reviewRequired`/
//      `reviewRequiredReasons` this function already computed above — never
//      a second, independent recomputation of any of those.
//
// ── Result-shape version bump (KAR-944, extended KAR-951) ───────────────
// `MULTI_QAF_COMPARISON_RESULT_VERSION` moved 1 -> 2 for the `aggregateImpact`
// field (KAR-944) and now 2 -> 3 for the new `summaryTotalsDiff` field
// (KAR-951 — see module header "Step 7" below). `types.ts`'s own
// `MULTI_QAF_MODEL_VERSION` doc states field additions never need a version
// bump (every reader already tolerates missing optional/array/record
// fields) — this module bumps anyway because `deserializeMultiQafComparisonResult`
// does a STRICT `modelVersion !==` throw (unlike a container's own deep,
// per-field `normalizeDeserializedContainer`), so a bump is the only way to
// make the old/new shape distinction visible to a caller at all. All three
// versions are ACCEPTED by `deserializeMultiQafComparisonResult`, which
// normalizes a pre-existing envelope's absent `aggregateImpact`/
// `summaryTotalsDiff` to `null` (never `undefined` — every reader can rely
// on the field always being present) rather than throwing on an old,
// already-persisted record.
//
// ── Step 7 — summaryTotalsDiff (KAR-951, urgent livetest fix) ──────────────
// `diffSummaryTotals` (summary-totals-differ.ts) runs alongside
// materialDiff/profileDiff (same `matchResult` input, same "matched pairs
// only, everything else passed through on `uncertainMatches`" contract) —
// it is the missing ALT<->NEU diff of `VirtualVariantSummaryTotals` the
// KAR-951 livetest exposed (module header there for the full root-cause
// writeup: two global Summary-sheet surcharge parameters changed, every
// variant's computed Summary total moved, and NO existing differ noticed
// because none of them read the Summary sheet's own computed totals at
// all). It is a PEER of materialDiff/profileDiff, never an input to
// `computeAggregateImpact` (summary-totals-differ.ts's own module header
// "§19 boundary" — no double-counting between the Summary-sheet-based sums
// this step produces and the material-/profile-component-based aggregate
// impact `computeAggregateImpact` already produces). Its own
// `changedMetricSumsByCurrency` is therefore reported to a caller
// SEPARATELY, explicitly labeled "summary-basiert" (UI convention, see
// qaf-multi-qaf-detail.tsx), never merged into `aggregateImpact`.
// `'summary_totals_changed_without_material_or_profile_evidence'` — this
// module's own ADDITIVE reviewRequired reason (alongside
// `match_overrides_dropped_on_drift`) — fires when a matched variant pair
// has at least one `state: 'changed'` summary-totals finding while BOTH
// materialDiff and profileDiff carry ZERO findings that reference that same
// variant pair (see `hasNoMaterialOrProfileEvidenceFor` below): a changed
// Summary number with no explaining material/profile-level detail is
// exactly the "change without an explaining detail beleg" case a human
// needs to look at (task instruction).
//
// ── Override persistence (KAR-912 `fieldMappingOverrides`-in-`g60_meta`
// pattern, applied here) ────────────────────────────────────────────────────
// `fieldMappingOverrides` (field-mapping-override.ts) persists on
// `qaf_file.g60_meta` because a field correction is a property of ONE file.
// A `VariantMatchOverride` (variant-matcher.ts) is a property of a PAIR
// (which alt variant maps to which neu variant) — it has no single owning
// file, so it belongs on the COMPARISON, not either file. This PR persists
// it inside `qaf_comparison.engine_version.variantMatchOverrides` (the same
// "misc JSONB bag on the one column that already exists, no migration"
// discipline `engine_version.baselineStatus`/`engine_version.
// engineConfigOverrides` already established for that column) — the actions.ts
// wiring for THIS PR reads/re-applies it on every `multi_qaf` recompute
// (`matchVariantsWithOverrides`, dropped-on-drift entries written back
// exactly like `carryForwardFieldMappingOverrides` does), but setting one is
// P4 UI scope (no `setVariantMatchOverride`-shaped action ships in this PR —
// see KAR-942 task scope: "keine neue UI-Sektion (P4)"). The plumbing is
// therefore exercised end-to-end by this module's own tests via
// `RunMultiQafCompareFlowOptions.variantMatchOverrides`, ready for a P4
// setter action to write into that same JSONB key.
//
// ── Persistence shape (actions.ts wiring) ───────────────────────────────────
// `qaf_comparison` carries no dedicated multi-QAF result column (no
// operator-applied migration in this PR — see this module's own PR
// description) — the composed `MultiQafComparisonResult` is persisted the
// SAME way `container`/`virtualVariants` already are on `qaf_file.g60_meta`
// (KAR-935 P2.1): a versioned envelope object assigned directly into
// `qaf_comparison.engine_version.multiQafComparisonResult`, spread alongside
// the existing `ENGINE_VERSION`/`baselineStatus` keys `resolvePersistedEngineConfig`
// already tolerates unknown keys on (rehydrate.ts only reads the keys it
// knows; an extra key is inert to every existing reader). `reviewRequired`
// additionally sets `qaf_comparison.baseline_status` to `'baseline_review'`
// (existing CHECK-constrained enum value, already used by the G60 pairing
// path for "please confirm before trusting this comparison" — reused here
// for the SAME meaning rather than inventing a new column/value) when
// `MultiQafComparisonResult.reviewRequired` is true, `'ok'` otherwise — this
// is the "reviewRequired aggregiert bis in die Comparison-Row" requirement,
// expressed via the one enum this table already has for exactly that
// semantic, never a new column.
//
// tdd-guard:skip — this file's own body is a thin, deterministic composition
// of six already-unit-tested pure functions (allMultiQafContainerVariants,
// matchVariants/matchVariantsWithOverrides, diffContainers, diffMaterial,
// diffProfiles, reconcileContainer) plus a versioned JSON envelope — covered
// by compare-flow.test.ts (synthetic containers) and compare-flow.real-
// files.test.ts (env-gated real-file regression), same category as
// container-assembly.ts's own orchestration body.

import {
  allMultiQafContainerVariants,
  diffContainers,
  type MultiQafContainerDiff,
  type MultiQafContainerDiffOptions,
} from './container-differ'
import { diffMaterial, type MaterialDiffOptions, type MaterialDiffResult } from './material-differ'
import { diffProfiles, type ProfileDiffOptions, type ProfileDiffResult } from './profile-differ'
import { reconcileContainer, type VariantReconciliationResult } from './variant-reconciliation'
import { computeAggregateImpact, type AggregateImpactOptions, type AggregateImpactResult } from './aggregate-impact'
import { diffSummaryTotals, type SummaryTotalsDiffOptions, type SummaryTotalsDiffResult } from './summary-totals-differ'
import {
  matchVariants,
  matchVariantsWithOverrides,
  DEFAULT_VARIANT_MATCH_CONFIG,
  type VariantMatchConfig,
  type VariantMatchResult,
  type VariantMatchOverride,
} from './variant-matcher'
import type { MultiQafContainer } from './types'
import type { ReconciliationConfig } from '../reconciliation'

/** Bumped on a breaking change OR (KAR-944, see module header "Result-shape
 * version bump") an addition that `deserializeMultiQafComparisonResult`'s
 * own strict `modelVersion !==` check would otherwise be unable to
 * distinguish from the prior shape. Both `1` and `2` are accepted by
 * `deserializeMultiQafComparisonResult`. */
export const MULTI_QAF_COMPARISON_RESULT_VERSION = 3 as const

/** Every `modelVersion` `deserializeMultiQafComparisonResult` accepts — see
 * module header "Result-shape version bump". */
const SUPPORTED_MULTI_QAF_COMPARISON_RESULT_VERSIONS: readonly number[] = [1, 2, 3]

/**
 * The ONE composed, per-side-reconciled, versioned Multi-QAF comparison
 * result this module's `runMultiQafCompareFlow` produces — see module header
 * for the exact pipeline each field comes from.
 */
export interface MultiQafComparisonResult {
  modelVersion: typeof MULTI_QAF_COMPARISON_RESULT_VERSION
  /** ALT variants are `left`, NEU variants are `right` — same convention
   * `diffContainers`/`diffMaterial`/`diffProfiles` already require of a
   * caller-supplied `matchResult`. */
  matchResult: readonly VariantMatchResult[]
  /** Persisted `VariantMatchOverride`s that could not be re-identified this
   * run (drifted, or already `dropped_on_drift` coming in) — empty when no
   * overrides were supplied at all. See module header "Override
   * persistence". */
  droppedOverrides: readonly VariantMatchOverride[]
  containerDiff: MultiQafContainerDiff
  materialDiff: MaterialDiffResult
  profileDiff: ProfileDiffResult
  /** `reconcileContainer` run independently per side — Master-Prompt §13 is
   * an internal-consistency check of ONE container, never an alt-vs-neu
   * diff; kept as two separate arrays rather than merged so a caller never
   * has to guess which side a given `VariantReconciliationResult` came
   * from. */
  reconciliation: {
    alt: readonly VariantReconciliationResult[]
    neu: readonly VariantReconciliationResult[]
  }
  /** Lifted verbatim from `containerDiff.reviewRequired` plus this module's
   * own additive `droppedOverrides.length > 0` signal — never re-derived
   * from `materialDiff`/`profileDiff`/`reconciliation` (see module header:
   * those are informational evidence, not a second review-required floor —
   * `reconcileContainer`'s `'abweichung'`/`'nicht_pruefbar'` outcomes are
   * expected, structural, per-check-scoped facts, most of them by design,
   * never a container-wide "needs human review" signal on their own). */
  reviewRequired: boolean
  reviewRequiredReasons: readonly (
    | MultiQafContainerDiff['reviewRequiredReasons'][number]
    | 'match_overrides_dropped_on_drift'
    | 'summary_totals_changed_without_material_or_profile_evidence'
  )[]
  /** KAR-944/P3.3 — `computeAggregateImpact` run over this exact result's own
   * `matchResult`/`containerDiff`/`materialDiff`/`profileDiff`/
   * `reconciliation`/`reviewRequired`/`reviewRequiredReasons` (see module
   * header pipeline step 6). `null` ONLY for a `modelVersion: 1` record
   * deserialized from before this field existed (see
   * `deserializeMultiQafComparisonResult`) — a freshly-run
   * `runMultiQafCompareFlow` always sets it. */
  aggregateImpact: AggregateImpactResult | null
  /** KAR-951 — `diffSummaryTotals` run over this exact result's own
   * `matchResult` (module header "Step 7"). `null` ONLY for a
   * `modelVersion: 1 | 2` record deserialized from before this field
   * existed — a freshly-run `runMultiQafCompareFlow` always sets it. NEVER
   * an input to `aggregateImpact` (§19 boundary, summary-totals-differ.ts's
   * own module header). */
  summaryTotalsDiff: SummaryTotalsDiffResult | null
}

export interface RunMultiQafCompareFlowOptions {
  /** Persisted match decisions (see module header "Override persistence").
   * Omitted/empty runs the plain `matchVariants` cascade — a true no-op,
   * mirroring `applyFieldMappingOverrides`'s own empty-array no-op
   * contract. */
  variantMatchOverrides?: readonly VariantMatchOverride[]
  variantMatchConfig?: VariantMatchConfig
  containerDiffOptions?: MultiQafContainerDiffOptions
  materialDiffOptions?: MaterialDiffOptions
  profileDiffOptions?: ProfileDiffOptions
  reconciliationConfig?: ReconciliationConfig
  aggregateImpactOptions?: AggregateImpactOptions
  summaryTotalsDiffOptions?: SummaryTotalsDiffOptions
}

/**
 * True when NEITHER `materialDiff` NOR `profileDiff` carries a single
 * finding that references `neuVariantId` (module header "Step 7" —
 * `'summary_totals_changed_without_material_or_profile_evidence'`). Checks
 * every finding array that is itself variant-scoped (`VariantAllocationFinding.
 * neuVariantId`, every `SharedComponentImpactSummary.affectedVariantIds`,
 * `ProfileBindingValueImpactFinding.neu.variantId`,
 * `ProfileTotalChangedFinding.affectedVariantIds`) — `rowIdentityChanges`/
 * `componentValueChanges` are deliberately excluded: neither is variant-
 * scoped (module-/profile-level facts, not evidence FOR a specific variant's
 * own number moving), so including them would risk masking a genuinely
 * unexplained Summary change behind an unrelated finding.
 */
function hasNoMaterialOrProfileEvidenceFor(neuVariantId: string, materialDiff: MaterialDiffResult, profileDiff: ProfileDiffResult): boolean {
  const materialEvidence =
    materialDiff.variantAllocation.findings.some((f) => f.neuVariantId === neuVariantId) ||
    materialDiff.sharedComponents.unitCostValueChanges.some((f) => f.impact.affectedVariantIds.includes(neuVariantId)) ||
    materialDiff.sharedComponents.unitCostCurrencyChanges.some((f) => f.impact.affectedVariantIds.includes(neuVariantId)) ||
    materialDiff.sharedComponents.exchangeRateChanges.some((f) => f.impact.affectedVariantIds.includes(neuVariantId)) ||
    materialDiff.sharedComponents.logisticsOrDutyChanges.some((f) => f.impact.affectedVariantIds.includes(neuVariantId)) ||
    materialDiff.sharedComponents.materialOverheadChanges.some((f) => f.impact.affectedVariantIds.includes(neuVariantId)) ||
    materialDiff.sharedComponents.formulaChanges.some((f) => f.impact.affectedVariantIds.includes(neuVariantId))
  if (materialEvidence) return false

  const profileEvidence =
    profileDiff.bindingValueImpacts.some((f) => f.neu.variantId === neuVariantId) ||
    profileDiff.totalChanges.some((f) => f.affectedVariantIds.includes(neuVariantId))
  return !profileEvidence
}

/**
 * Orchestrates the full P2 Multi-QAF pipeline over two already-assembled
 * containers (rehydrated via serialization.ts's `deserializeMultiQafContainer`
 * — this function never reads a workbook) into ONE composed
 * `MultiQafComparisonResult`. `alt`/`neu` map onto `matchVariants`' own
 * `left`/`right` convention — same ALT/NEU roles the standard/G60 compare
 * paths already use.
 */
export function runMultiQafCompareFlow(
  alt: MultiQafContainer,
  neu: MultiQafContainer,
  options: RunMultiQafCompareFlowOptions = {},
): MultiQafComparisonResult {
  const altVariants = allMultiQafContainerVariants(alt)
  const neuVariants = allMultiQafContainerVariants(neu)
  const matchConfig = options.variantMatchConfig ?? DEFAULT_VARIANT_MATCH_CONFIG

  const { results: matchResult, droppedOverrides } =
    options.variantMatchOverrides && options.variantMatchOverrides.length > 0
      ? matchVariantsWithOverrides(altVariants, neuVariants, options.variantMatchOverrides, matchConfig)
      : { results: matchVariants(altVariants, neuVariants, matchConfig), droppedOverrides: [] as readonly VariantMatchOverride[] }

  const containerDiff = diffContainers(alt, neu, matchResult, options.containerDiffOptions)
  const materialDiff = diffMaterial(alt, neu, matchResult, options.materialDiffOptions)
  const profileDiff = diffProfiles(alt, neu, matchResult, options.profileDiffOptions)
  const summaryTotalsDiff = diffSummaryTotals(alt, neu, matchResult, options.summaryTotalsDiffOptions)
  const reconciliation = {
    alt: reconcileContainer(alt, options.reconciliationConfig),
    neu: reconcileContainer(neu, options.reconciliationConfig),
  }

  // KAR-951 — module header "Step 7": a changed Summary metric on a variant
  // pair with zero corroborating material/profile findings is review-
  // relevant (a number moved with no explaining detail beleg).
  //
  // KAR-951 F7 fix (review finding): `noEvidenceByVariantId` memoizes BOTH
  // outcomes (has evidence AND has no evidence) per `neuVariantId`, keyed in
  // a `Map<string, boolean>` — the original version only short-circuited the
  // "no evidence" case (via `changedVariantIdsWithoutEvidence.has(...)`), so
  // a variant WITH evidence re-ran every one of
  // `hasNoMaterialOrProfileEvidenceFor`'s several `.some()` scans once per
  // changed metric on that same variant (up to 9 SUMMARY_TOTALS_METRIC_KEYS
  // times) instead of once.
  const noEvidenceByVariantId = new Map<string, boolean>()
  const hasNoEvidenceCached = (neuVariantId: string): boolean => {
    const cached = noEvidenceByVariantId.get(neuVariantId)
    if (cached !== undefined) return cached
    const result = hasNoMaterialOrProfileEvidenceFor(neuVariantId, materialDiff, profileDiff)
    noEvidenceByVariantId.set(neuVariantId, result)
    return result
  }
  const changedVariantIdsWithoutEvidence = new Set<string>()
  for (const f of summaryTotalsDiff.findings) {
    if (f.state !== 'changed') continue
    if (hasNoEvidenceCached(f.neuVariantId)) changedVariantIdsWithoutEvidence.add(f.neuVariantId)
  }

  const reviewRequiredReasons: MultiQafComparisonResult['reviewRequiredReasons'] = [
    ...containerDiff.reviewRequiredReasons,
    ...(droppedOverrides.length > 0 ? (['match_overrides_dropped_on_drift'] as const) : []),
    ...(changedVariantIdsWithoutEvidence.size > 0 ? (['summary_totals_changed_without_material_or_profile_evidence'] as const) : []),
  ]
  const reviewRequired = containerDiff.reviewRequired || droppedOverrides.length > 0 || changedVariantIdsWithoutEvidence.size > 0

  const aggregateImpact = computeAggregateImpact(
    alt,
    neu,
    { matchResult, materialDiff, profileDiff, containerDiff, reconciliation, reviewRequired, reviewRequiredReasons },
    options.aggregateImpactOptions,
  )

  return {
    modelVersion: MULTI_QAF_COMPARISON_RESULT_VERSION,
    matchResult,
    droppedOverrides,
    containerDiff,
    materialDiff,
    profileDiff,
    reconciliation,
    reviewRequired,
    reviewRequiredReasons,
    aggregateImpact,
    summaryTotalsDiff,
  }
}

/** `'baseline_review'` when `reviewRequired` — see module header
 * "Persistence shape" for why `qaf_comparison.baseline_status` (an existing
 * CHECK-constrained enum, not a new column) is the reused signal. */
export function multiQafBaselineStatusFor(result: MultiQafComparisonResult): 'ok' | 'baseline_review' {
  return result.reviewRequired ? 'baseline_review' : 'ok'
}

// ── Versioned JSON envelope (same discipline as serialization.ts) ──────────

interface MultiQafComparisonResultEnvelope {
  modelVersion: number
  result: unknown
}

/** Serialize a `MultiQafComparisonResult` to a versioned JSON string — same
 * envelope discipline as `serializeMultiQafContainer`/
 * `serializeVirtualQafVariants` (serialization.ts). */
export function serializeMultiQafComparisonResult(result: MultiQafComparisonResult): string {
  const envelope: MultiQafComparisonResultEnvelope = { modelVersion: MULTI_QAF_COMPARISON_RESULT_VERSION, result }
  return JSON.stringify(envelope)
}

/**
 * Parse + rebuild a `MultiQafComparisonResult` from a JSON string produced by
 * `serializeMultiQafComparisonResult`. Throws under the same conditions as
 * `deserializeVirtualQafVariants` (bad envelope / unsupported modelVersion /
 * missing key) — this result type is, like a virtual-variant snapshot, only
 * ever WRITTEN by this module's own serializer (never hand-crafted), so it is
 * not deep-normalized field by field the way `deserializeMultiQafContainer`
 * normalizes a container (see that function's own doc for why containers
 * specifically pay that extra cost and this type does not).
 */
export function deserializeMultiQafComparisonResult(json: string): MultiQafComparisonResult {
  const parsed: unknown = JSON.parse(json)
  if (parsed === null || typeof parsed !== 'object') {
    throw new Error('Invalid Multi-QAF comparison result JSON: expected an object envelope')
  }
  const envelope = parsed as Partial<MultiQafComparisonResultEnvelope>
  if (typeof envelope.modelVersion !== 'number' || !SUPPORTED_MULTI_QAF_COMPARISON_RESULT_VERSIONS.includes(envelope.modelVersion)) {
    throw new Error(
      `Unsupported Multi-QAF comparison result modelVersion: ${String(envelope.modelVersion)} (expected one of ${SUPPORTED_MULTI_QAF_COMPARISON_RESULT_VERSIONS.join(', ')})`,
    )
  }
  if (!('result' in envelope) || envelope.result === undefined) {
    throw new Error('Invalid Multi-QAF comparison result JSON: missing "result" key')
  }
  const result = envelope.result as MultiQafComparisonResult & {
    aggregateImpact?: AggregateImpactResult | null
    summaryTotalsDiff?: SummaryTotalsDiffResult | null
  }
  // KAR-944/KAR-951: a modelVersion:1 envelope (persisted before KAR-944)
  // never carried `aggregateImpact`, and a modelVersion:1|2 envelope
  // (persisted before KAR-951) never carried `summaryTotalsDiff` either —
  // tolerant deserialize normalizes each absent field to `null` (see the
  // corresponding field's own doc on `MultiQafComparisonResult`) rather than
  // leaving it `undefined`, which would violate the type at runtime for
  // every caller reading it without an explicit `undefined` check.
  return {
    ...result,
    aggregateImpact: result.aggregateImpact === undefined ? null : result.aggregateImpact,
    summaryTotalsDiff: result.summaryTotalsDiff === undefined ? null : result.summaryTotalsDiff,
  }
}
