'use server'

// QAF-Differences batch analysis (KAR-799, spec 1D / D1).
//
// Reads QAF .xlsx/.xlsm files from storage, parses each (per-file error isolation),
// persists files + steps + part rows, groups strictly by part number, runs the
// engine comparison and persists the full result set. All writes go through the
// RLS-scoped server client — ownership is enforced by the qaf_* _own policies.
//
// Upload path (since KAR-840 storage rework): the browser uploads originals to
// the private 'qaf-uploads' bucket via signed URLs (createQafUploadTargets),
// analysis reads them back server-side — Vercel's 4.5 MB request limit never
// carries file bytes, and originals stay stored (re-ingest without re-upload).
// Per-step inserts favour correctness (FK resolution) over throughput — batch
// perf tuning is a Phase 1G item.
//
// tdd-guard:skip — DB-bound integration glue; the pure logic it composes
// (parseQafFile, buildBatchComparisons, buildComparisonRowset) is unit-tested,
// and this action is verified via the RLS harness + staging E2E (Phase 1G).

import { z } from 'zod'
import { createClient } from '@/lib/supabase/server'
import { qafStoragePathShape, sanitizeStorageName } from './storage-path-shape'
import { sanitizeSheetFilePart } from './export-filename'
import { deriveProductLineTags } from '@/lib/qaf-differences'
import { ingestQafUpload } from './ingest-core'
import { createAdminClient } from '@/lib/supabase/admin'
import {
  buildBatchComparisons,
  summaryMetricsFromRows,
  rehydrateFile,
  compareQafPair,
  type ManualPin,
  buildComparisonRowset,
  analyzeG60Pair,
  rehydrateG60File,
  buildG60StructureIssues,
  templateFingerprintToPlausibilityIssue,
  buildG60ExportWorkbook,
  type G60FileMeta,
  type G60TabDbRow,
  type G60CardDbRow,
  type G60StructureFinding,
  type WorkbookSafetyResult,
  type MultiQafDetectionResult,
  deserializeMultiQafContainer,
  runMultiQafCompareFlow,
  multiQafBaselineStatusFor,
  MULTI_QAF_COMPARISON_RESULT_VERSION,
  type MultiQafComparisonResult,
  type VariantMatchOverride,
  runVariantVsStandardCompare,
  MULTI_QAF_VARIANT_VS_STANDARD_RESULT_VERSION,
  type VariantVsStandardComparisonResult,
  deserializeMultiQafComparisonResult,
  deserializeVariantVsStandardComparisonResult,
  buildMultiQafExportWorkbook,
  buildVariantVsStandardExportWorkbook,
  type MultiQafContainer,
  allMultiQafContainerVariants,
  type VariantDefinition,
  type VariantMatchOverrideIdentitySnapshot,
  identitySnapshotsEqual,
  identitySnapshotFor,
  findAllByIdentitySnapshot,
  replaceOverrideForVariant,
  removeActiveOverridesForVariant,
  materialRowsFromPersistedMeta,
  sbmRowsFromPersistedMeta,
  rmrRowsFromPersistedMeta,
  logisticsRowsFromPersistedMeta,
  lccnFromPersistedMeta,
  co2eMaterialRowsForReconciliation,
  co2eFromPersistedMeta,
  recompareReplacedTables,
  ENGINE_VERSION,
  type QafFileParsed,
  type QafComparisonResult,
  type PlausibilityIssue,
  type TemplateFingerprintResult,
  type LogisticsFieldKey,
  type PersistedLccnMeta,
  type PersistedCo2eMeta,
  type PersistedMaterialMeta,
  type PersistedSbmMeta,
  type PersistedRmrMeta,
  type PersistedLogisticsMeta,
  applyFieldMappingOverrides,
  fieldMappingOverrideDroppedIssues,
  type FieldMappingOverride,
  comparisonModeRule,
} from '@/lib/qaf-differences'
import {
  QAF_UPLOAD_BUCKET,
  QAF_MAX_FILE_BYTES,
  QAF_MAX_BATCH_FILES,
  isAllowedQafFileName,
} from '@/components/qaf-differences/qaf-upload-constants'
import type { QAFRow } from '@/lib/qaf-parser'
import { logger } from '@/lib/logger'

export type ActionResult<T = unknown> = { ok: true; data: T } | { ok: false; error: string }

const uuidSchema = z
  .string()
  .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, 'invalid uuid')

// Hard cap on the (compressed) upload size per QAF file. ExcelJS reads the whole
// workbook into memory, so an unbounded upload is a DoS vector — reject oversized
// or wrong-type files before reading them into a buffer. Note: this bounds the
// wire size only, not the decompressed size, so a highly-compressible workbook can
// still expand larger; a decompressed-size/ratio limit + per-user rate limit is a
// tracked hardening follow-up (KAR-801 area).

export interface BatchAnalysisSummary {
  comparisons: number
  filesParsed: number
  fileErrors: Array<{ file: string; error: string }>
  groupsNeedingReview: number
  /** KAR-935 adversarial-review F3 fix: a successful Multi-QAF detection
   * ("file recognized, saved, comparison support coming later") used to be
   * pushed onto `fileErrors` — the ONLY channel that existed for a soft,
   * non-blocking, per-file message at the time. The UI (qaf-differences-
   * client.tsx) counts `fileErrors.length` as the "Datei-Fehler" stat and
   * renders every entry with a destructive/red AlertTriangle icon, so a
   * successful upload was shown to the user as a failure. This is the
   * separate, additive, non-destructive channel for exactly that kind of
   * message — `fileErrors` stays reserved for genuine per-file failures. */
  multiQafNotices: Array<{ file: string; message: string }>
}

// ── Storage-based upload (KAR-840): the browser uploads directly to the
// private 'qaf-uploads' bucket via signed URLs — Vercel's 4.5 MB request
// limit never sees the file bytes. The analyze action then reads from
// storage server-side. Originals stay stored (re-ingest without re-upload).


export interface QafUploadTarget {
  name: string
  path: string
  token: string
  /** Full signed PUT URL — the client uploads via XHR for byte progress. */
  signedUrl: string
}

/**
 * Issue signed upload URLs for a batch. Ownership-checked; the admin client
 * only signs paths under the caller's own project prefix (see the
 * service-role intent register).
 */
export async function createQafUploadTargets(
  projectId: string,
  files: Array<{ name: string; size: number }>,
): Promise<ActionResult<QafUploadTarget[]>> {
  if (!uuidSchema.safeParse(projectId).success) return { ok: false, error: 'invalid project id' }
  if (!files || files.length === 0) return { ok: false, error: 'no files' }
  if (files.length > QAF_MAX_BATCH_FILES) return { ok: false, error: `maximal ${QAF_MAX_BATCH_FILES} Dateien pro Batch` }

  const supabase = await createClient()
  const { data: claims } = await supabase.auth.getClaims()
  if (!claims?.claims) return { ok: false, error: 'unauthorized' }
  const { data: project, error: projErr } = await supabase
    .from('projects')
    .select('id')
    .eq('id', projectId)
    .maybeSingle()
  if (projErr) return { ok: false, error: 'project lookup failed' }
  if (!project) return { ok: false, error: 'project not found or access denied' }

  for (const f of files) {
    if (!isAllowedQafFileName(f.name)) {
      return { ok: false, error: `${f.name}: unsupported file type (.xlsx/.xlsm/.xls only)` }
    }
    if (f.size > QAF_MAX_FILE_BYTES) {
      return { ok: false, error: `${f.name}: zu groß (${Math.round(f.size / 1024 / 1024)} MB, max 20 MB)` }
    }
  }

  const admin = createAdminClient()
  const results = await Promise.all(
    files.map(async (f) => {
      const path = `${projectId}/${crypto.randomUUID()}-${sanitizeStorageName(f.name)}`
      const { data, error } = await admin.storage.from(QAF_UPLOAD_BUCKET).createSignedUploadUrl(path)
      return { name: f.name, data, error }
    }),
  )
  const failed = results.find((r) => r.error || !r.data)
  if (failed) return { ok: false, error: `upload url failed: ${failed.error?.message ?? 'unknown'}` }
  return {
    ok: true,
    data: results.map((r) => ({ name: r.name, path: r.data!.path, token: r.data!.token, signedUrl: r.data!.signedUrl })),
  }
}

/**
 * Best-effort cleanup for a batch that never reached analysis (mid-upload
 * failure / user abort) — without it, half-uploaded batches would orphan
 * objects in the private bucket forever. Same ownership + path checks as the
 * analyze action. Objects of ANALYZED files are deliberately kept (re-ingest).
 */
export async function discardQafUploads(projectId: string, paths: string[]): Promise<ActionResult<null>> {
  if (!uuidSchema.safeParse(projectId).success) return { ok: false, error: 'invalid project id' }
  if (!paths || paths.length === 0 || paths.length > QAF_MAX_BATCH_FILES) return { ok: false, error: 'invalid paths' }
  if (!paths.every((p) => qafStoragePathShape(projectId).test(p))) return { ok: false, error: 'invalid storage path' }

  const supabase = await createClient()
  const { data: claims } = await supabase.auth.getClaims()
  if (!claims?.claims) return { ok: false, error: 'unauthorized' }
  const { data: project, error: projErr } = await supabase
    .from('projects')
    .select('id')
    .eq('id', projectId)
    .maybeSingle()
  if (projErr || !project) return { ok: false, error: 'project not found or access denied' }

  const admin = createAdminClient()
  const { error } = await admin.storage.from(QAF_UPLOAD_BUCKET).remove(paths)
  if (error) return { ok: false, error: `cleanup failed: ${error.message}` }
  return { ok: true, data: null }
}

/**
 * Insert the derived child rows of one comparison result (step matches +
 * field diffs). Shared by analyze and recompare so the sequence cannot drift.
 * Returns an error message or null.
 */
async function insertStepChildren(
  supabase: Awaited<ReturnType<typeof createClient>>,
  ctx: { projectId: string; comparisonId: string },
  result: QafComparisonResult,
  altStepIds: string[],
  neuStepIds: string[],
): Promise<string | null> {
  for (const sc of result.stepComparisons) {
    const { data: matchRow, error: matchErr } = await supabase
      .from('qaf_step_match')
      .insert({
        project_id: ctx.projectId,
        comparison_id: ctx.comparisonId,
        alt_step_id: sc.altIndex !== null ? (altStepIds[sc.altIndex] ?? null) : null,
        neu_step_id: sc.neuIndex !== null ? (neuStepIds[sc.neuIndex] ?? null) : null,
        match_status: sc.match.matchStatus,
        confidence_score: sc.match.confidenceScore,
        match_method: sc.match.matchMethod,
        matched_fields: sc.match.matchedFields.map(String),
        conflicting_fields: sc.match.conflictingFields.map(String),
        explanation: sc.match.explanation,
        requires_review: sc.match.requiresReview,
      })
      .select('id')
      .single()
    if (matchErr || !matchRow) return matchErr?.message ?? 'qaf_step_match insert failed'

    if (sc.altIndex !== null && sc.neuIndex !== null && sc.fieldDiffs.length) {
      const { error } = await supabase.from('qaf_manufacturing_diff').insert(
        sc.fieldDiffs.map((d) => ({
          project_id: ctx.projectId,
          comparison_id: ctx.comparisonId,
          step_match_id: matchRow.id as string,
          field: String(d.field),
          alt_value: d.altValue,
          neu_value: d.neuValue,
          delta_absolute: d.deltaAbsolute,
          delta_percent: d.deltaPercent,
          delta_percentage_points: d.deltaPercentagePoints,
          status: d.status,
        })),
      )
      if (error) return `qaf_manufacturing_diff insert failed: ${error.message}`
    }
  }
  return null
}


/**
 * Analyze a batch of QAF files previously uploaded to the qaf-uploads bucket.
 * Paths are hard-scoped to the caller's project prefix; RLS guarantees the
 * project + all written rows belong to the caller.
 */
export async function analyzeQafBatchFromStorage(
  projectId: string,
  uploads: Array<{ path: string; name: string }>,
): Promise<ActionResult<BatchAnalysisSummary>> {
  if (!uuidSchema.safeParse(projectId).success) return { ok: false, error: 'invalid project id' }
  if (!uploads || uploads.length === 0) return { ok: false, error: 'no files' }
  if (uploads.length > QAF_MAX_BATCH_FILES) return { ok: false, error: `maximal ${QAF_MAX_BATCH_FILES} Dateien pro Batch` }
  // Path guard: exactly the shape createQafUploadTargets mints —
  // '<projectId>/<uuid>-<sanitized name>'. Segment-exact (no substring check,
  // so filenames containing '..' cannot false-positive), names the culprit.
  const pathShape = qafStoragePathShape(projectId)
  for (const u of uploads) {
    if (!pathShape.test(u.path)) {
      return { ok: false, error: `invalid storage path for ${u.name}` }
    }
  }

  const supabase = await createClient()
  const { data: claims } = await supabase.auth.getClaims()
  if (!claims?.claims) return { ok: false, error: 'unauthorized' }
  const userId = claims.claims.sub as string

  // Verify project ownership up front. RLS would reject the first qaf_* insert
  // anyway, but only after every file has been read into memory and parsed —
  // an unowned project id + N large files is otherwise a resource-exhaustion
  // vector. The RLS-scoped select returns a row only if the caller owns it.
  const { data: project, error: projErr } = await supabase
    .from('projects')
    .select('id')
    .eq('id', projectId)
    .maybeSingle()
  if (projErr) return { ok: false, error: 'project lookup failed' }
  if (!project) return { ok: false, error: 'project not found or access denied' }

  const parsed: QafFileParsed[] = []
  const stepIdsByFile = new Map<string, string[]>()
  const fileErrors: Array<{ file: string; error: string }> = []
  const multiQafNotices: Array<{ file: string; message: string }> = []
  // G60 detail QAFs take their own persistence path and are compared as whole
  // files (upload order = ALT, NEU), not grouped by part number.
  const g60Files: Array<{ id: string; fileName: string }> = []
  // Multi-QAF containers (KAR-935/P2.1 persisted them; KAR-942/P3.1 compares
  // them) take the SAME "own persistence path, compared as a whole pair,
  // upload order = ALT/NEU" shape G60 already established — see the pairing
  // block below (mirrors the G60 block exactly, including the "Nachzügler"
  // single-file-finds-an-earlier-unpaired-partner case).
  const multiQafFiles: Array<{ id: string; fileName: string }> = []

  // ── Per-file parse + persist (errors isolated to the offending file) ──────────
  const admin = createAdminClient()
  // Client array order feeds selectBaseline's official uploadedAt tiebreaker:
  // quotation date stays authoritative (V11 semantics), the user's order
  // decides only where dates are missing or equal.
  const batchStart = Date.now()
  for (const [uploadIndex, upload] of uploads.entries()) {
    // Set once the qaf_file row is committed — used to compensate on a later
    // failure inside this file's block (steps/metrics cascade via FK).
    let insertedFileId: string | null = null
    try {
      const ingested = await ingestQafUpload(supabase, admin, { projectId }, upload, (id) => {
        insertedFileId = id
      })
      if (ingested.kind === 'g60') {
        g60Files.push({ id: ingested.fileId, fileName: upload.name })
        continue
      }
      if (ingested.kind === 'multi_qaf') {
        // KAR-935/Multi-QAF-Programm P2.1: no standard-summary persistence —
        // the file IS persisted (qaf_file row, visible/inspectable) but
        // stays out of `parsed`, so it never enters the part-number
        // grouping. KAR-942/P3.1: it now DOES enter its own pairing below
        // (mirrors the G60 block), so "Vergleich folgt in einem späteren
        // Release" is no longer true — replaced with a plain detection
        // notice; the pairing block further down reports the actual
        // comparison outcome (created / no partner yet / batch has >2
        // files) as its own notices. KAR-935 adversarial-review F3 fix
        // (still honored): this stays a SUCCESS outcome on the dedicated
        // `multiQafNotices` channel, never `fileErrors` (which the UI
        // renders as a red, destructive "Datei-Fehler").
        const n = ingested.multiQafContainerSummary?.variantCount ?? 0
        const active = ingested.multiQafContainerSummary?.activeVariantCount ?? 0
        multiQafNotices.push({
          file: upload.name,
          message: `Multi-QAF erkannt, ${active} von ${n} Varianten aktiv — Datei gespeichert. / Multi-QAF detected, ${active} of ${n} variants active — file saved.`,
        })
        multiQafFiles.push({ id: ingested.fileId, fileName: upload.name })
        continue
      }
      const fileId = ingested.fileId
      const summary = ingested.summary!
      const summaryMetrics = ingested.summaryMetrics
      const steps = ingested.steps!
      stepIdsByFile.set(fileId, ingested.stepIds)

      parsed.push({
        ref: {
          id: fileId,
          fileName: upload.name,
          quotationDate: summary.quotationDate.value,
          uploadedAt: new Date(batchStart + uploadIndex).toISOString(),
        },
        summary,
        summaryMetrics: summaryMetrics ?? undefined,
        steps,
        materialRows: ingested.materialRows,
        materialParseMeta: ingested.materialParseMeta,
        sbmRows: ingested.sbmRows,
        sbmParseMeta: ingested.sbmParseMeta,
        rmrRows: ingested.rmrRows,
        rmrParseMeta: ingested.rmrParseMeta,
        logisticsRows: ingested.logisticsRows,
        lccnValues: ingested.lccnValues,
        co2eMaterialRows: ingested.co2eMaterialRows,
        manufacturingParseMeta: ingested.manufacturingParseMeta,
        workbookSafety: ingested.workbookSafety,
        multiQafDetection: ingested.multiQafDetection,
      })
    } catch (e) {
      let message = e instanceof Error ? e.message : 'parse error'
      // Compensate: a failed file must not leave an orphaned qaf_file behind
      // (steps + summary metrics are removed via ON DELETE CASCADE). The
      // qaf_part upsert stays — it is idempotent identity data shared across
      // files of the same part number.
      if (insertedFileId) {
        stepIdsByFile.delete(insertedFileId)
        const { error: cleanupErr } = await supabase.from('qaf_file').delete().eq('id', insertedFileId)
        if (cleanupErr) message += ` (cleanup of orphaned file row also failed: ${cleanupErr.message})`
      }
      fileErrors.push({ file: upload.name, error: message })
    }
  }

  // ── G60 pair: compared as whole files, upload order = ALT, NEU ────────────────
  let comparisonCount = 0

  // Single new upload (G60 or Multi-QAF)? Pair it with an earlier,
  // still-unpaired file of the same type/mode in this project (earlier
  // upload = ALT) — otherwise the first upload of a pair would stay orphaned
  // with no comparison and no UI path back to it. KAR-942 adversarial-review
  // F7 fix (13.07.2026): the G60 and Multi-QAF call sites below used to
  // duplicate this query-and-Set logic near-verbatim — extracted here so a
  // future fix (e.g. this helper's own F4 newest-unpaired-first ordering, or
  // a race-condition fix under concurrent uploads) is applied once, not
  // twice-by-hand. Mutates `files` in place (unshifts the found partner) and
  // pushes onto the enclosing `fileErrors` on either a query failure or "no
  // partner yet" — verified behaviorally neutral for the G60 call site by
  // its own existing tests (identical query shape/ordering/messages, only
  // parameterized; only the newly-added Multi-QAF call site's `partnerOrder`
  // differs from the pre-existing G60 default).
  const findUnpairedPartnerForSingleUpload = async (
    files: Array<{ id: string; fileName: string }>,
    ctx: {
      comparisonMode: 'g60' | 'multi_qaf'
      templateType: 'BMW_DETAIL_TABS_G60' | 'MULTI_QAF'
      /** 'oldest_first' — pre-existing G60 behavior, unchanged by this fix.
       * 'newest_first' — Multi-QAF only (KAR-942 F4 fix): after a prior
       * qaf_comparison-insert failure ever left more than one unpaired file
       * behind, the most recent orphan is the more likely intended
       * partner. */
      partnerOrder: 'oldest_first' | 'newest_first'
      partnerSearchFailedPrefix: string
      noPartnerYetMessage: string
    },
  ): Promise<void> => {
    const [cmpRes, fileRes] = await Promise.all([
      supabase
        .from('qaf_comparison')
        .select('baseline_file_id, comparison_file_id')
        .eq('project_id', projectId)
        .eq('comparison_mode', ctx.comparisonMode),
      supabase
        .from('qaf_file')
        .select('id, original_file_name')
        .eq('project_id', projectId)
        .eq('template_type', ctx.templateType)
        .neq('id', files[0]!.id)
        .order('created_at', { ascending: ctx.partnerOrder === 'oldest_first' }),
    ])
    if (cmpRes.error || fileRes.error) {
      fileErrors.push({
        file: files[0]!.fileName,
        error: `${ctx.partnerSearchFailedPrefix}: ${cmpRes.error?.message ?? fileRes.error?.message}`,
      })
      return
    }
    const paired = new Set(
      (cmpRes.data ?? []).flatMap((c) => [c.baseline_file_id, c.comparison_file_id]).filter(Boolean),
    )
    const partner = (fileRes.data ?? []).find((f) => !paired.has(f.id))
    if (partner) {
      // Earlier upload becomes ALT.
      files.unshift({ id: partner.id as string, fileName: partner.original_file_name as string })
    } else {
      fileErrors.push({ file: files[0]!.fileName, error: ctx.noPartnerYetMessage })
    }
  }

  if (g60Files.length === 1) {
    await findUnpairedPartnerForSingleUpload(g60Files, {
      comparisonMode: 'g60',
      templateType: 'BMW_DETAIL_TABS_G60',
      partnerOrder: 'oldest_first',
      partnerSearchFailedPrefix: 'G60-Partner-Suche fehlgeschlagen',
      noPartnerYetMessage:
        'G60-Datei gespeichert — noch kein Vergleichspartner im Projekt. Beim Upload der zweiten G60-Datei entsteht der Vergleich automatisch.',
    })
  }

  if (g60Files.length === 2) {
    const g60ComparisonId = crypto.randomUUID()
    const { error: g60CmpErr } = await supabase.from('qaf_comparison').insert({
      id: g60ComparisonId,
      project_id: projectId,
      part_number: null,
      baseline_file_id: g60Files[0].id,
      comparison_file_id: g60Files[1].id,
      comparison_mode: 'g60',
      tags: deriveProductLineTags([g60Files[0].fileName, g60Files[1].fileName]),
      engine_version: { ...ENGINE_VERSION, baselineStatus: 'baseline_review' },
      // Upload order decides ALT/NEU — G60 files carry no quotation date, so
      // the baseline needs a human confirmation (V11 asks explicitly too).
      baseline_status: 'baseline_review',
      status: 'draft',
      created_by: userId,
    })
    if (g60CmpErr) {
      fileErrors.push({ file: 'G60-Vergleich', error: g60CmpErr.message })
    } else {
      const { error: auditErr } = await supabase.from('qaf_audit_log').insert({
        project_id: projectId,
        action: 'analyze',
        entity: 'qaf_comparison',
        entity_id: g60ComparisonId,
        detail: { mode: 'g60', baseline: g60Files[0].fileName, comparison: g60Files[1].fileName },
      })
      if (auditErr) {
        fileErrors.push({ file: 'G60-Vergleich', error: `qaf_audit_log insert failed: ${auditErr.message}` })
      } else {
        comparisonCount += 1
      }
    }
  } else if (g60Files.length > 2) {
    fileErrors.push({
      file: 'G60-Vergleich',
      error: `${g60Files.length} G60-Detail-QAFs in einem Batch — für einen Vergleich genau 2 hochladen (ALT zuerst).`,
    })
  }

  // ── Multi-QAF pair: compared as whole containers, upload order = ALT, NEU ─
  // (KAR-942/Multi-QAF-Programm P3.1) — mirrors the G60 block above via the
  // shared findUnpairedPartnerForSingleUpload helper (KAR-942
  // adversarial-review F7 fix), including the "Nachzügler"
  // single-file-finds-an-earlier-unpaired-partner case and the fileErrors
  // channel choice for partner-search/pairing-outcome messages (the per-file
  // "Multi-QAF erkannt..." detection notice above stays on the separate,
  // neutral multiQafNotices channel — KAR-935 adversarial-review F3 fix,
  // unchanged by this PR). `partnerOrder: 'newest_first'` is the one
  // intentional behavioral difference from G60 — see the helper's own doc
  // comment (KAR-942 F4 fix).
  if (multiQafFiles.length === 1) {
    await findUnpairedPartnerForSingleUpload(multiQafFiles, {
      comparisonMode: 'multi_qaf',
      templateType: 'MULTI_QAF',
      partnerOrder: 'newest_first',
      partnerSearchFailedPrefix: 'Multi-QAF-Partner-Suche fehlgeschlagen',
      noPartnerYetMessage:
        'Multi-QAF-Datei gespeichert — noch kein Vergleichspartner im Projekt. Beim Upload der zweiten Multi-QAF-Datei entsteht der Vergleich automatisch.',
    })
  }

  if (multiQafFiles.length === 2) {
    const multiQafComparisonId = crypto.randomUUID()
    // The container was already fully assembled at ingest time (this call's
    // OWN upload, or an earlier call's — the "Nachzügler" partner above) and
    // persisted into qaf_file.g60_meta.multiQafContainer — re-read it from
    // there rather than threading it through IngestResult, same "compute
    // once, read from storage thereafter" discipline templateFingerprint/
    // manufacturingParseMeta already establish (container-assembly.ts's own
    // IngestResult doc comment states this explicitly).
    const [altFileRes, neuFileRes] = await Promise.all([
      supabase.from('qaf_file').select('g60_meta').eq('id', multiQafFiles[0].id).maybeSingle(),
      supabase.from('qaf_file').select('g60_meta').eq('id', multiQafFiles[1].id).maybeSingle(),
    ])
    if (altFileRes.error || neuFileRes.error || !altFileRes.data || !neuFileRes.data) {
      fileErrors.push({
        file: 'Multi-QAF-Vergleich',
        error: `Multi-QAF-Container konnten nicht geladen werden: ${altFileRes.error?.message ?? neuFileRes.error?.message ?? 'nicht gefunden'}.`,
      })
    } else {
      try {
        const altContainerRaw = (altFileRes.data.g60_meta as { multiQafContainer?: unknown } | null)?.multiQafContainer
        const neuContainerRaw = (neuFileRes.data.g60_meta as { multiQafContainer?: unknown } | null)?.multiQafContainer
        if (!altContainerRaw || !neuContainerRaw) {
          throw new Error('qaf_file.g60_meta.multiQafContainer fehlt auf mindestens einer Seite')
        }
        const altContainer = deserializeMultiQafContainer(JSON.stringify(altContainerRaw))
        const neuContainer = deserializeMultiQafContainer(JSON.stringify(neuContainerRaw))
        const result = runMultiQafCompareFlow(altContainer, neuContainer)
        const baselineStatus = multiQafBaselineStatusFor(result)

        const { error: mqCmpErr } = await supabase.from('qaf_comparison').insert({
          id: multiQafComparisonId,
          project_id: projectId,
          part_number: null,
          baseline_file_id: multiQafFiles[0].id,
          comparison_file_id: multiQafFiles[1].id,
          comparison_mode: 'multi_qaf',
          tags: deriveProductLineTags([multiQafFiles[0].fileName, multiQafFiles[1].fileName]),
          // No dedicated result column exists for qaf_comparison (no
          // operator-applied migration in this PR) — persisted the SAME way
          // qaf_file.g60_meta.multiQafContainer already is: a versioned
          // envelope object in the one JSONB "misc bag" this row already
          // has. See compare-flow.ts module header "Persistence shape".
          engine_version: {
            ...ENGINE_VERSION,
            baselineStatus,
            multiQafComparisonResult: { modelVersion: MULTI_QAF_COMPARISON_RESULT_VERSION, result },
          },
          baseline_status: baselineStatus,
          status: 'draft',
          created_by: userId,
        })
        if (mqCmpErr) {
          // KAR-942 adversarial-review F4 fix (13.07.2026): on a qaf_comparison
          // insert failure, BOTH already-persisted qaf_file rows (MULTI_QAF
          // template_type) are deliberately left in place — no delete/
          // compensate() here, unlike ingestQafUpload's per-file compensate.
          // The files themselves are the valuable artifact (successfully
          // parsed, container-assembled, visible/inspectable); only the
          // PAIRING attempt failed. A delete would destroy real data to
          // "clean up" a transient DB error. The message tells the user both
          // files survived and how to recover: re-upload triggers the
          // Nachzügler search again (now newest-unpaired-first, see above),
          // or a future manual-compare UI (P4) can pair them directly.
          fileErrors.push({
            file: 'Multi-QAF-Vergleich',
            error: `qaf_comparison insert failed: ${mqCmpErr.message} — beide Dateien bleiben gespeichert. Pairing fehlgeschlagen, bitte erneut hochladen oder manuell vergleichen. / Both files remain saved. Pairing failed — please re-upload or compare manually.`,
          })
        } else {
          const { error: auditErr } = await supabase.from('qaf_audit_log').insert({
            project_id: projectId,
            action: 'analyze',
            entity: 'qaf_comparison',
            entity_id: multiQafComparisonId,
            detail: {
              mode: 'multi_qaf',
              baseline: multiQafFiles[0].fileName,
              comparison: multiQafFiles[1].fileName,
              matched: result.matchResult.filter((r) => r.kind === 'matched').length,
              reviewRequired: result.reviewRequired,
            },
          })
          if (auditErr) {
            fileErrors.push({ file: 'Multi-QAF-Vergleich', error: `qaf_audit_log insert failed: ${auditErr.message}` })
          } else {
            comparisonCount += 1
          }
        }
      } catch (e) {
        fileErrors.push({
          file: 'Multi-QAF-Vergleich',
          error: `Multi-QAF-Vergleich fehlgeschlagen: ${e instanceof Error ? e.message : String(e)}`,
        })
      }
    }
  } else if (multiQafFiles.length > 2) {
    fileErrors.push({
      file: 'Multi-QAF-Vergleich',
      error: `${multiQafFiles.length} Multi-QAF-Dateien in einem Batch — für einen Vergleich genau 2 hochladen (ALT zuerst).`,
    })
  }

  // ── Group by part number, compare, persist results ────────────────────────────
  const groups = buildBatchComparisons(parsed)

  for (const group of groups) {
    if (!group.comparison) continue
    const result = group.comparison
    const comparisonId = crypto.randomUUID()

    const rowset = buildComparisonRowset(result, {
      projectId,
      comparisonId,
      baselineFileId: result.altRef.id,
      comparisonFileId: result.neuRef.id,
      createdBy: userId,
      engineVersion: {
        ...ENGINE_VERSION,
        baselineStatus: group.baseline.status,
        // KAR-994-Folge (31.07.2026): worüber dieses Paar zustande kam. Ein
        // Vergleich, der NICHT über die Sachnummer gepaart wurde, beruht auf
        // einem schwächeren Merkmal — das muss nachvollziehbar bleiben, sonst
        // wäre die gelockerte Gruppierung eine stille Annahme.
        pairedBy: group.pairedBy,
      },
    })

    // Titel nur setzen, wenn über ein schwächeres Merkmal gepaart wurde —
    // sonst bleibt die bestehende Benennung (Sachnummer) unangetastet. Der
    // Titel ist die sichtbarste Stelle in der Vergleichsliste; ohne ihn wäre
    // die Grundlage nur in der JSONB-Spalte vergraben.
    const fallbackTitle =
      group.pairedBy === 'requestVersion'
        ? 'Vergleich (über Anfragenummer gepaart — Zuordnung prüfen)'
        : group.pairedBy === 'partAndSupplier'
          ? 'Vergleich (über Teil und Lieferant gepaart — Zuordnung prüfen)'
          : null

    const { error: cmpErr } = await supabase.from('qaf_comparison').insert({
      ...rowset.comparison,
      baseline_status: group.baseline.status,
      ...(fallbackTitle ? { title: fallbackTitle } : {}),
      tags: deriveProductLineTags([result.altRef.fileName, result.neuRef.fileName]),
    })
    if (cmpErr) {
      fileErrors.push({ file: `comparison ${result.partNumber}`, error: cmpErr.message })
      continue
    }

    const altStepIds = stepIdsByFile.get(result.altRef.id) ?? []
    const neuStepIds = stepIdsByFile.get(result.neuRef.id) ?? []

    // step matches + their field diffs — shared sequence with recompare.
    // Every write is checked: a swallowed error would leave a comparison that
    // looks complete but is missing its step-level evidence.
    let childErr: string | null = await insertStepChildren(
      supabase,
      { projectId, comparisonId },
      result,
      altStepIds,
      neuStepIds,
    )

    if (!childErr && rowset.summaryDiffs.length) {
      const { error } = await supabase.from('qaf_summary_diff').insert(rowset.summaryDiffs)
      if (error) childErr = `qaf_summary_diff insert failed: ${error.message}`
    }
    if (!childErr && rowset.materialDiffs.length) {
      const { error } = await supabase.from('qaf_material_diff').insert(rowset.materialDiffs)
      if (error) childErr = `qaf_material_diff insert failed: ${error.message}`
    }
    if (!childErr && rowset.structureChanges.length) {
      const { error } = await supabase.from('qaf_structure_change').insert(rowset.structureChanges)
      if (error) childErr = `qaf_structure_change insert failed: ${error.message}`
    }
    if (!childErr && rowset.plausibilityIssues.length) {
      const { error } = await supabase.from('qaf_plausibility_issue').insert(rowset.plausibilityIssues)
      if (error) childErr = `qaf_plausibility_issue insert failed: ${error.message}`
    }
    if (!childErr) {
      const { error } = await supabase.from('qaf_root_cause').insert(rowset.rootCause)
      if (error) childErr = `qaf_root_cause insert failed: ${error.message}`
    }
    if (!childErr) {
      const { error } = await supabase.from('qaf_audit_log').insert({
        project_id: projectId,
        action: 'analyze',
        entity: 'qaf_comparison',
        entity_id: comparisonId,
        detail: { partNumber: result.partNumber, baselineStatus: group.baseline.status },
      })
      if (error) childErr = `qaf_audit_log insert failed: ${error.message}`
    }

    if (childErr) {
      // The qaf_comparison parent row was already committed (PostgREST has no
      // cross-request transaction). Compensate by deleting it so a failed batch
      // never leaves an orphaned draft comparison with missing children/audit log
      // surfacing in the UI — every qaf_* child FK is ON DELETE CASCADE, so this
      // also removes any partially-written step matches/diffs. RLS (FOR ALL, owner-
      // scoped) permits the owner to delete their own row.
      const { error: cleanupErr } = await supabase.from('qaf_comparison').delete().eq('id', comparisonId)
      const suffix = cleanupErr ? ` (cleanup of orphaned comparison also failed: ${cleanupErr.message})` : ''
      fileErrors.push({ file: `comparison ${result.partNumber}`, error: `${childErr}${suffix}` })
      continue
    }

    comparisonCount += 1
  }

  return {
    ok: true,
    data: {
      comparisons: comparisonCount,
      filesParsed: parsed.length,
      fileErrors,
      groupsNeedingReview: groups.filter((g) => g.needsReview).length,
      multiQafNotices,
    },
  }
}

// ── XLSX export (KAR-840 PR1) ─────────────────────────────────────────────────
// Der Summary-Export selbst wohnt seit Loop-10-Schnitt 4 in
// xlsx-export-actions.ts (Begründung und Rehydrate-Disziplin dort im
// Modulkopf); hier verbleiben der geteilte Download-Typ und die drei
// deferred-Export-Varianten (G60/Multi-QAF/VvS, deferred_by_product_owner).

export interface QafExportDownload {
  filename: string
  /** base64-encoded .xlsx bytes (server actions can't stream a Blob). */
  base64: string
}


// ── G60 XLSX export (KAR-913/P4.3) ────────────────────────────────────────────
// Regenerate the G60 export workbook on demand from persisted qaf_g60_tab /
// qaf_input_card rows — same rehydrate discipline as exportQafComparisonXlsx
// above (no re-parse of the original xlsx; the download reproduces the
// on-screen [id]/page.tsx G60 render exactly: same rehydrateG60File +
// analyzeG60Pair(alt, neu) call, no aliasMap, same structure-issue build).
//
// component_rows is deliberately NOT selected from qaf_g60_tab here — only
// the live scenario editor needs it (rehydrateG60File/analyzeG60Pair never
// read it), and skipping it materially shrinks the per-tab JSONB payload for
// the ~112-tab case the P4.3 performance risk note calls out.
//
// Live what-if scenario overrides (scenario.ts) are NOT exported: they are
// session-only (no qaf_g60_scenario table, never persisted — see
// qaf-g60-detail.tsx "Eingaben gelten je Sitzung"), so there is no single
// canonical state to reproduce deterministically. See CHANGELOG/PR body.

const OK_G60_STRUCTURE_EXPORT: G60StructureFinding = { ok: true, mismatches: [], confidence: 1 }

export async function exportQafG60ComparisonXlsx(
  comparisonId: string,
  generatedAtLabel: string,
): Promise<ActionResult<QafExportDownload>> {
  if (!uuidSchema.safeParse(comparisonId).success) return { ok: false, error: 'invalid comparison id' }

  const supabase = await createClient()

  // RLS returns no row (no error) when the caller does not own the comparison.
  const { data: cmp, error: cmpErr } = await supabase
    .from('qaf_comparison')
    .select('id, project_id, baseline_file_id, comparison_file_id, comparison_mode, engine_version')
    .eq('id', comparisonId)
    .maybeSingle()
  if (cmpErr) return { ok: false, error: `comparison load failed: ${cmpErr.message}` }
  if (!cmp) return { ok: false, error: 'Vergleich nicht gefunden oder kein Zugriff.' }
  if (cmp.comparison_mode !== 'g60') {
    return {
      ok: false,
      error: 'Dieser Export ist nur für G60-Detailvergleiche verfügbar — für Summary-Vergleiche exportQafComparisonXlsx verwenden.',
    }
  }
  if (!cmp.baseline_file_id || !cmp.comparison_file_id) {
    return { ok: false, error: 'Vergleich hat keine zwei Dateien — Export nicht möglich.' }
  }

  const loadG60Side = async (fileId: string) => {
    const [meta, tabs, cards] = await Promise.all([
      supabase.from('qaf_file').select('original_file_name, g60_meta').eq('id', fileId).maybeSingle(),
      supabase
        .from('qaf_g60_tab')
        .select('tab_name, tab_index, aggregate')
        .eq('file_id', fileId)
        .order('tab_index', { ascending: true }),
      supabase.from('qaf_input_card').select('code, label, value_numeric, value_text').eq('file_id', fileId),
    ])
    if (meta.error || tabs.error || cards.error) {
      throw new Error(meta.error?.message ?? tabs.error?.message ?? cards.error?.message ?? 'g60 load failed')
    }
    const g60Meta = (meta.data?.g60_meta as
      | (G60FileMeta & {
          inputStructure?: G60StructureFinding
          excludedTabs?: Record<string, G60StructureFinding>
          templateFingerprint?: TemplateFingerprintResult
        })
      | null) ?? null
    // Same JSONB-embedded structure-finding extraction as [id]/page.tsx
    // (persistence.ts rides `structure` along inside `aggregate`, no schema
    // change) — kept local here rather than shared, same precedent as that
    // page's other G60-local helpers.
    const tabStructure: Record<string, G60StructureFinding> = {}
    for (const row of tabs.data ?? []) {
      const structure = (row.aggregate as { structure?: G60StructureFinding } | null)?.structure
      if (structure && !structure.ok) tabStructure[row.tab_name as string] = structure
    }
    const tabRows: G60TabDbRow[] = (tabs.data ?? []).map((row) => ({
      tab_name: row.tab_name as string,
      tab_index: row.tab_index as number,
      aggregate: row.aggregate,
      component_rows: null,
    }))
    return {
      fileName: (meta.data?.original_file_name as string | null) ?? null,
      parse: rehydrateG60File(g60Meta, tabRows, (cards.data ?? []) as G60CardDbRow[]),
      inputStructure: g60Meta?.inputStructure ?? OK_G60_STRUCTURE_EXPORT,
      excludedTabs: g60Meta?.excludedTabs ?? {},
      tabStructure,
      templateFingerprint: g60Meta?.templateFingerprint ?? null,
    }
  }

  let alt: Awaited<ReturnType<typeof loadG60Side>>
  let neu: Awaited<ReturnType<typeof loadG60Side>>
  try {
    ;[alt, neu] = await Promise.all([loadG60Side(cmp.baseline_file_id), loadG60Side(cmp.comparison_file_id)])
  } catch (e) {
    return { ok: false, error: `G60-Daten laden fehlgeschlagen: ${e instanceof Error ? e.message : String(e)}` }
  }

  const analysis = analyzeG60Pair(alt.parse, neu.parse)
  const structureIssues: PlausibilityIssue[] = [
    ...buildG60StructureIssues({
      side: 'ALT',
      inputStructure: alt.inputStructure,
      tabStructure: alt.tabStructure,
      excludedTabs: alt.excludedTabs,
    }),
    ...buildG60StructureIssues({
      side: 'NEU',
      inputStructure: neu.inputStructure,
      tabStructure: neu.tabStructure,
      excludedTabs: neu.excludedTabs,
    }),
    ...[
      alt.templateFingerprint && templateFingerprintToPlausibilityIssue(alt.templateFingerprint, 'ALT', alt.fileName ?? 'ALT-Datei'),
      neu.templateFingerprint && templateFingerprintToPlausibilityIssue(neu.templateFingerprint, 'NEU', neu.fileName ?? 'NEU-Datei'),
    ].filter((i): i is PlausibilityIssue => Boolean(i)),
  ]

  let buffer: ArrayBuffer
  try {
    buffer = await buildG60ExportWorkbook({
      generatedAtLabel,
      baselineFileName: alt.fileName,
      comparisonFileName: neu.fileName,
      analysis,
      baselineTabs: alt.parse.tabs,
      comparisonTabs: neu.parse.tabs,
      baselineRates: alt.parse.rates,
      comparisonRates: neu.parse.rates,
      volumes: neu.parse.volumes,
      structureIssues,
      engineVersion: cmp.engine_version,
    })
  } catch (e) {
    return { ok: false, error: `Export-Erzeugung fehlgeschlagen: ${e instanceof Error ? e.message : String(e)}` }
  }

  const base64 = Buffer.from(buffer).toString('base64')
  const neuBaseName = (neu.fileName ?? '').replace(/\.[^.]+$/, '')
  const filename = `QAF_G60_Vergleich_${sanitizeSheetFilePart(neuBaseName || null)}.xlsx`

  // Best-effort audit trail (never fail the download on a log-write hiccup), but
  // attribute the export to the caller and surface a genuine insert failure.
  const { data: claims } = await supabase.auth.getClaims()
  const exportedBy = (claims?.claims?.sub as string | undefined) ?? null
  const { error: auditErr } = await supabase
    .from('qaf_export')
    .insert({ project_id: cmp.project_id, comparison_id: cmp.id, format: 'xlsx', status: 'ready', exported_by: exportedBy })
  if (auditErr) logger.warn('qaf.export.g60_audit_insert_failed', { comparisonId, error: auditErr.message })

  return { ok: true, data: { filename, base64 } }
}

// ── Multi-QAF XLSX export (KAR-949) ───────────────────────────────────────────
// Same "reopen without re-parse" discipline as [id]/page.tsx's own
// `comparison_mode === 'multi_qaf'` branch: the full `MultiQafComparisonResult`
// and both sides' `MultiQafContainer` are already persisted (engine_version.
// multiQafComparisonResult / qaf_file.g60_meta.multiQafContainer) — this
// action reads exactly those same columns and calls the exact same
// deserializers, so the download reproduces the on-screen result byte-for-
// byte, never a second, independently-computed run. Guarded by its own
// inline `comparison_mode` check (NOT via COMPARISON_MODE_RULES.
// exportSupported, which stays false for this mode — that flag means "does
// the STANDARD/Summary export support it", same precedent
// exportQafG60ComparisonXlsx's own inline guard already establishes for
// 'g60').

export async function exportMultiQafComparisonXlsx(
  comparisonId: string,
  generatedAtLabel: string,
): Promise<ActionResult<QafExportDownload>> {
  if (!uuidSchema.safeParse(comparisonId).success) return { ok: false, error: 'invalid comparison id' }

  const supabase = await createClient()

  const { data: cmp, error: cmpErr } = await supabase
    .from('qaf_comparison')
    .select('id, project_id, baseline_file_id, comparison_file_id, comparison_mode, engine_version')
    .eq('id', comparisonId)
    .maybeSingle()
  if (cmpErr) return { ok: false, error: `comparison load failed: ${cmpErr.message}` }
  if (!cmp) return { ok: false, error: 'Vergleich nicht gefunden oder kein Zugriff.' }
  if (cmp.comparison_mode !== 'multi_qaf') {
    return {
      ok: false,
      error:
        'Dieser Export ist nur für Multi-QAF-Vergleiche verfügbar. / This export is only available for Multi-QAF comparisons.',
    }
  }

  const fileIds = [cmp.baseline_file_id, cmp.comparison_file_id].filter(Boolean) as string[]
  const { data: files, error: filesErr } = fileIds.length
    ? await supabase.from('qaf_file').select('id, original_file_name, g60_meta').in('id', fileIds)
    : { data: [] as { id: string; original_file_name: string; g60_meta: unknown }[], error: null }
  if (filesErr) return { ok: false, error: `file load failed: ${filesErr.message}` }
  const fileById = new Map((files ?? []).map((f) => [f.id as string, f]))

  const deserializeContainerSafely = (fileId: string | null): MultiQafContainer | null => {
    if (!fileId) return null
    const raw = (fileById.get(fileId)?.g60_meta as { multiQafContainer?: unknown } | null)?.multiQafContainer
    if (!raw) return null
    try {
      return deserializeMultiQafContainer(JSON.stringify(raw))
    } catch {
      return null
    }
  }
  const altContainer = deserializeContainerSafely(cmp.baseline_file_id)
  const neuContainer = deserializeContainerSafely(cmp.comparison_file_id)

  const engineVersion = cmp.engine_version as
    | { multiQafComparisonResult?: { modelVersion?: number; result?: unknown }; variantMatchOverrides?: VariantMatchOverride[] }
    | null
  let result: MultiQafComparisonResult | null = null
  const rawEnvelope = engineVersion?.multiQafComparisonResult
  if (rawEnvelope) {
    try {
      result = deserializeMultiQafComparisonResult(JSON.stringify(rawEnvelope))
    } catch {
      result = null
    }
  }
  const persistedOverrides = engineVersion?.variantMatchOverrides ?? []

  let buffer: ArrayBuffer
  try {
    buffer = await buildMultiQafExportWorkbook({
      generatedAtLabel,
      altFileName: cmp.baseline_file_id ? (fileById.get(cmp.baseline_file_id)?.original_file_name as string | undefined ?? null) : null,
      neuFileName: cmp.comparison_file_id ? (fileById.get(cmp.comparison_file_id)?.original_file_name as string | undefined ?? null) : null,
      altContainer,
      neuContainer,
      result,
      persistedOverrides,
    })
  } catch (e) {
    return { ok: false, error: `Export-Erzeugung fehlgeschlagen: ${e instanceof Error ? e.message : String(e)}` }
  }

  const base64 = Buffer.from(buffer).toString('base64')
  const neuFileNameRaw = cmp.comparison_file_id ? ((fileById.get(cmp.comparison_file_id)?.original_file_name as string | undefined) ?? '') : ''
  const filename = `QAF_MultiQAF_Vergleich_${sanitizeSheetFilePart(neuFileNameRaw.replace(/\.[^.]+$/, '') || null)}.xlsx`

  const { data: claims } = await supabase.auth.getClaims()
  const exportedBy = (claims?.claims?.sub as string | undefined) ?? null
  const { error: auditErr } = await supabase
    .from('qaf_export')
    .insert({ project_id: cmp.project_id, comparison_id: cmp.id, format: 'xlsx', status: 'ready', exported_by: exportedBy })
  if (auditErr) logger.warn('qaf.export.multi_qaf_audit_insert_failed', { comparisonId, error: auditErr.message })

  return { ok: true, data: { filename, base64 } }
}

// ── Multi-QAF-Variante ↔ Standard-QAF XLSX export (KAR-949) ──────────────────
// Same "reopen without re-parse" discipline as the multi_qaf export above and
// [id]/page.tsx's own `comparison_mode === 'multi_qaf_variant_vs_standard'`
// branch — baseline_file_id (role 'alt') is the STANDARD QAF (name only, no
// container to deserialize), comparison_file_id (role 'neu') is the Multi-QAF
// CONTAINER the selected variant is bridged from.

export async function exportVariantVsStandardComparisonXlsx(
  comparisonId: string,
  generatedAtLabel: string,
): Promise<ActionResult<QafExportDownload>> {
  if (!uuidSchema.safeParse(comparisonId).success) return { ok: false, error: 'invalid comparison id' }

  const supabase = await createClient()

  const { data: cmp, error: cmpErr } = await supabase
    .from('qaf_comparison')
    .select('id, project_id, baseline_file_id, comparison_file_id, comparison_mode, engine_version')
    .eq('id', comparisonId)
    .maybeSingle()
  if (cmpErr) return { ok: false, error: `comparison load failed: ${cmpErr.message}` }
  if (!cmp) return { ok: false, error: 'Vergleich nicht gefunden oder kein Zugriff.' }
  if (cmp.comparison_mode !== 'multi_qaf_variant_vs_standard') {
    return {
      ok: false,
      error:
        'Dieser Export ist nur für Multi-QAF-Variante-vs-Standard-Vergleiche verfügbar. / This export is only available for Multi-QAF-variant-vs-standard comparisons.',
    }
  }

  const fileIds = [cmp.baseline_file_id, cmp.comparison_file_id].filter(Boolean) as string[]
  const { data: files, error: filesErr } = fileIds.length
    ? await supabase.from('qaf_file').select('id, original_file_name, g60_meta').in('id', fileIds)
    : { data: [] as { id: string; original_file_name: string; g60_meta: unknown }[], error: null }
  if (filesErr) return { ok: false, error: `file load failed: ${filesErr.message}` }
  const fileById = new Map((files ?? []).map((f) => [f.id as string, f]))

  let container: MultiQafContainer | null = null
  const containerRaw = cmp.comparison_file_id
    ? (fileById.get(cmp.comparison_file_id)?.g60_meta as { multiQafContainer?: unknown } | null)?.multiQafContainer
    : null
  if (containerRaw) {
    try {
      container = deserializeMultiQafContainer(JSON.stringify(containerRaw))
    } catch {
      container = null
    }
  }

  const engineVersion = cmp.engine_version as
    | { selectedVariantId?: string; variantVsStandardResult?: { modelVersion?: number; result?: unknown } }
    | null
  let result: VariantVsStandardComparisonResult | null = null
  const rawEnvelope = engineVersion?.variantVsStandardResult
  if (rawEnvelope) {
    try {
      result = deserializeVariantVsStandardComparisonResult(JSON.stringify(rawEnvelope))
    } catch {
      result = null
    }
  }
  const selectedVariantId = engineVersion?.selectedVariantId ?? null

  let buffer: ArrayBuffer
  try {
    buffer = await buildVariantVsStandardExportWorkbook({
      generatedAtLabel,
      multiQafFileName: cmp.comparison_file_id ? (fileById.get(cmp.comparison_file_id)?.original_file_name as string | undefined ?? null) : null,
      standardFileName: cmp.baseline_file_id ? (fileById.get(cmp.baseline_file_id)?.original_file_name as string | undefined ?? null) : null,
      container,
      variantId: selectedVariantId,
      result,
    })
  } catch (e) {
    return { ok: false, error: `Export-Erzeugung fehlgeschlagen: ${e instanceof Error ? e.message : String(e)}` }
  }

  const base64 = Buffer.from(buffer).toString('base64')
  const standardFileNameRaw = cmp.baseline_file_id ? ((fileById.get(cmp.baseline_file_id)?.original_file_name as string | undefined) ?? '') : ''
  const filename = `QAF_Variante_vs_Standard_${sanitizeSheetFilePart(standardFileNameRaw.replace(/\.[^.]+$/, '') || null)}.xlsx`

  const { data: claims } = await supabase.auth.getClaims()
  const exportedBy = (claims?.claims?.sub as string | undefined) ?? null
  const { error: auditErr } = await supabase
    .from('qaf_export')
    .insert({ project_id: cmp.project_id, comparison_id: cmp.id, format: 'xlsx', status: 'ready', exported_by: exportedBy })
  if (auditErr) logger.warn('qaf.export.variant_vs_standard_audit_insert_failed', { comparisonId, error: auditErr.message })

  return { ok: true, data: { filename, base64 } }
}

// ── Re-compare with manual matching / role swap (KAR-845) ─────────────────────

export interface ManualPinById {
  altStepId: string
  /** null = explicitly unmatched ("Schritt entfallen"). */
  neuStepId: string | null
}

/**
 * Recompute a comparison from its persisted data — with user-pinned step
 * matches (match_method 'manual') and/or swapped ALT/NEU roles. Summary mode
 * re-runs the deterministic engine and replaces the derived rows; G60 mode
 * only swaps the file roles (its analysis is computed on page load).
 *
 * `refreshPlausibility` (KAR-899, default false — a plain pin-recompare or
 * role-swap NEVER refreshes qaf_plausibility_issue): findings there are not
 * matching-dependent (identity/reconciliation/rule-engine checks run per FILE,
 * not per step-match) and cannot be re-derived from the single last-write-wins
 * qaf_part row anyway (see rehydrate.ts's plausibilityOverride doc) — so the
 * default keeps them byte-identical across a pin-recompare. replaceComparisonFile
 * passes `true`: swapping in a different FILE genuinely changes what the fk_
 * detail_sum/scrap-/material-/sbm_detail_sum/Kaskaden/R-Regeln/Struktur checks
 * find, and the previously persisted issues describe a file that no longer
 * backs this comparison — before this fix they were never refreshed at all
 * (recompareReplacedTables in persistence-mapper.ts is the single source of
 * truth for which mode replaces which tables).
 */
export async function recompareComparison(
  comparisonId: string,
  options: { swap?: boolean; pins?: ManualPinById[]; refreshPlausibility?: boolean },
): Promise<ActionResult<null>> {
  if (!uuidSchema.safeParse(comparisonId).success) return { ok: false, error: 'invalid comparison id' }

  const supabase = await createClient()
  const { data: claims } = await supabase.auth.getClaims()
  if (!claims?.claims) return { ok: false, error: 'unauthorized' }
  const userId = claims.claims.sub as string

  const { data: cmp, error: cmpErr } = await supabase
    .from('qaf_comparison')
    .select('id, project_id, part_number, baseline_file_id, comparison_file_id, comparison_mode, engine_version')
    .eq('id', comparisonId)
    .maybeSingle()
  if (cmpErr) return { ok: false, error: `comparison load failed: ${cmpErr.message}` }
  if (!cmp || !cmp.baseline_file_id || !cmp.comparison_file_id) {
    return { ok: false, error: 'Vergleich nicht gefunden oder unvollständig.' }
  }

  // KAR-943 adversarial-review F2 fix (13.07.2026): pins/swap applicability
  // read from the central COMPARISON_MODE_RULES registry ONCE, up front,
  // instead of being re-checked ad-hoc inside each mode branch below (G60/
  // multi_qaf both used to repeat "pins is a Summary-only concept" verbatim;
  // multi_qaf_variant_vs_standard additionally rejected `swap`). A FUTURE
  // mode's pins/swap rule is now a registry entry, not a sixth copy of these
  // checks scattered across the per-mode branches.
  const modeRule = comparisonModeRule(cmp.comparison_mode as string | null)
  if (options.pins?.length && !modeRule.pinsSupported) {
    return { ok: false, error: modeRule.pinsNotSupportedError ?? 'Manuelles Matching ist für diesen Vergleichs-Modus nicht verfügbar.' }
  }
  if (options.swap && !modeRule.swapSupported) {
    return { ok: false, error: modeRule.swapNotSupportedError ?? 'Rollentausch ist für diesen Vergleichs-Modus nicht verfügbar.' }
  }

  const altFileId = options.swap ? (cmp.comparison_file_id as string) : (cmp.baseline_file_id as string)
  const neuFileId = options.swap ? (cmp.baseline_file_id as string) : (cmp.comparison_file_id as string)

  // ── G60: roles only — the detail page recomputes the analysis on load. ──────
  if (cmp.comparison_mode === 'g60') {
    if (!options.swap) return { ok: true, data: null }
    const { error } = await supabase
      .from('qaf_comparison')
      // Data changed → a previous 'reviewed' sign-off no longer applies.
      .update({ baseline_file_id: altFileId, comparison_file_id: neuFileId, baseline_status: 'ok', status: 'draft' })
      .eq('id', comparisonId)
    if (error) return { ok: false, error: `swap failed: ${error.message}` }
    const { error: auditErr } = await supabase.from('qaf_audit_log').insert({
      project_id: cmp.project_id,
      action: 'swap_roles',
      entity: 'qaf_comparison',
      entity_id: comparisonId,
      detail: { by: userId },
    })
    if (auditErr) return { ok: false, error: `audit log failed: ${auditErr.message}` }
    return { ok: true, data: null }
  }

  // ── Multi-QAF: rehydrate both containers (NO re-parse — reads back
  // qaf_file.g60_meta.multiQafContainer, exactly like the pairing block in
  // analyzeQafBatchFromStorage) and re-runs the full compare-flow pipeline
  // (KAR-942/Multi-QAF-Programm P3.1: this used to fall straight into the
  // Summary branch below — WRONG engine entirely for a MULTI_QAF-template
  // file, no qaf_manufacturing_step rows exist for it at all — a genuine
  // extend-not-crash fix for the "heute vermutlich Guard" gap the task noted).
  // pins/swap applicability already guarded up front via COMPARISON_MODE_RULES
  // — Multi-QAF's own review workflow is VariantMatchOverride (variant PAIRS,
  // not step pins), persisted separately (see below).
  if (cmp.comparison_mode === 'multi_qaf') {
    const { data: files, error: filesErr } = await supabase
      .from('qaf_file')
      .select('id, g60_meta')
      .in('id', [altFileId, neuFileId])
    if (filesErr) return { ok: false, error: `load failed: ${filesErr.message}` }
    const fileById = new Map((files ?? []).map((f) => [f.id as string, f]))
    const altRaw = (fileById.get(altFileId)?.g60_meta as { multiQafContainer?: unknown } | null)?.multiQafContainer
    const neuRaw = (fileById.get(neuFileId)?.g60_meta as { multiQafContainer?: unknown } | null)?.multiQafContainer
    if (!altRaw || !neuRaw) {
      return { ok: false, error: 'Multi-QAF-Container fehlt auf mindestens einer Seite — recompute nicht möglich.' }
    }
    // KAR-912-style carry-forward: persisted VariantMatchOverrides live on
    // THIS comparison's own engine_version JSONB bag (see compare-flow.ts
    // module header "Override persistence") — re-applied on every recompute,
    // same rehydration-staleness discipline fieldMappingOverrides/
    // multiQafDetection already establish for the standard/summary path. No
    // P4 UI ships a setter for these yet; the plumbing is exercised so a
    // future setter only needs to write this same JSONB key. A role swap
    // invalidates every persisted override (their ALT/NEU sides are now
    // reversed) — dropped rather than silently reinterpreted, same
    // fail-closed discipline matchVariantsWithOverrides' own drift-drop
    // already establishes elsewhere.
    const persistedOverrides = options.swap
      ? []
      : ((cmp.engine_version as { variantMatchOverrides?: VariantMatchOverride[] } | null)?.variantMatchOverrides ?? [])
    let result: MultiQafComparisonResult
    try {
      const altContainer = deserializeMultiQafContainer(JSON.stringify(altRaw))
      const neuContainer = deserializeMultiQafContainer(JSON.stringify(neuRaw))
      result = runMultiQafCompareFlow(altContainer, neuContainer, { variantMatchOverrides: persistedOverrides })
    } catch (e) {
      return { ok: false, error: `Multi-QAF-Neuberechnung fehlgeschlagen: ${e instanceof Error ? e.message : String(e)}` }
    }
    // Carry the drift/ambiguous-drop status back onto the persisted list
    // (KAR-912 carryForwardFieldMappingOverrides precedent) — a dropped
    // override is PRESERVED for audit history, never deleted, never
    // re-evaluated once dropped. Shared with setVariantMatchOverride/
    // clearVariantMatchOverride's own recompute tail (see that helper's own
    // doc for why a bare compositeCanonicalKey-string key was wrong here).
    const carriedOverrides = carryForwardMultiQafOverrideStatuses(persistedOverrides, result.droppedOverrides)
    const baselineStatus = multiQafBaselineStatusFor(result)
    const { error: updErr } = await supabase
      .from('qaf_comparison')
      .update({
        baseline_file_id: altFileId,
        comparison_file_id: neuFileId,
        baseline_status: baselineStatus,
        engine_version: {
          ...ENGINE_VERSION,
          baselineStatus,
          variantMatchOverrides: carriedOverrides,
          multiQafComparisonResult: { modelVersion: MULTI_QAF_COMPARISON_RESULT_VERSION, result },
        },
        status: 'draft',
      })
      .eq('id', comparisonId)
    if (updErr) return { ok: false, error: `comparison update failed: ${updErr.message}` }
    const { error: auditErr } = await supabase.from('qaf_audit_log').insert({
      project_id: cmp.project_id,
      action: 'recompare',
      entity: 'qaf_comparison',
      entity_id: comparisonId,
      detail: { swap: Boolean(options.swap), mode: 'multi_qaf', reviewRequired: result.reviewRequired, by: userId },
    })
    if (auditErr) logger.error('qaf.recompare (multi_qaf) audit insert failed', { comparisonId, error: auditErr.message })
    return { ok: true, data: null }
  }

  // ── Multi-QAF-Variante <-> Standard-QAF (KAR-943/Multi-QAF-Programm P3.2,
  // Master-Prompt §15 Szenario B): rehydrate the Multi-QAF container
  // (comparison_file_id) + the standard QAF's identity/summaryMetrics
  // (baseline_file_id), re-run runVariantVsStandardCompare for the SAME
  // selectedVariantId this comparison was created with (engine_version.
  // selectedVariantId — see createVariantVsStandardComparison below).
  // `swap`/`pins` are not meaningful concepts for this asymmetric pairing
  // (one side is a container+variant selection, the other a single standard
  // file) — already rejected explicitly up front via COMPARISON_MODE_RULES,
  // rather than silently ignored.
  if (cmp.comparison_mode === 'multi_qaf_variant_vs_standard') {
    const multiQafFileId = cmp.comparison_file_id as string
    const standardFileId = cmp.baseline_file_id as string
    const selectedVariantId = (cmp.engine_version as { selectedVariantId?: string } | null)?.selectedVariantId
    if (!selectedVariantId) {
      return { ok: false, error: 'engine_version.selectedVariantId fehlt — Vergleich kann nicht neu berechnet werden.' }
    }

    const { data: mqFile, error: mqFileErr } = await supabase.from('qaf_file').select('g60_meta').eq('id', multiQafFileId).maybeSingle()
    if (mqFileErr || !mqFile) {
      return { ok: false, error: `Multi-QAF-Container konnte nicht geladen werden: ${mqFileErr?.message ?? 'nicht gefunden'}.` }
    }
    const containerRaw = (mqFile.g60_meta as { multiQafContainer?: unknown } | null)?.multiQafContainer
    if (!containerRaw) return { ok: false, error: 'qaf_file.g60_meta.multiQafContainer fehlt — recompute nicht möglich.' }

    const standardLoad = await loadStandardQafFileForVariantCompare(supabase, cmp.project_id as string, standardFileId)
    if (!standardLoad.ok) return { ok: false, error: standardLoad.error }

    let result: VariantVsStandardComparisonResult
    try {
      const container: MultiQafContainer = deserializeMultiQafContainer(JSON.stringify(containerRaw))
      result = runVariantVsStandardCompare(container, selectedVariantId, standardLoad.parsed)
    } catch (e) {
      return { ok: false, error: `Multi-QAF-Variante-vs-Standard-Neuberechnung fehlgeschlagen: ${e instanceof Error ? e.message : String(e)}` }
    }

    const baselineStatus = result.reviewRequired ? 'baseline_review' : 'ok'
    const { error: updErr } = await supabase
      .from('qaf_comparison')
      .update({
        baseline_status: baselineStatus,
        engine_version: {
          ...ENGINE_VERSION,
          baselineStatus,
          selectedVariantId,
          variantVsStandardResult: { modelVersion: MULTI_QAF_VARIANT_VS_STANDARD_RESULT_VERSION, result },
        },
        status: 'draft',
      })
      .eq('id', comparisonId)
    if (updErr) return { ok: false, error: `comparison update failed: ${updErr.message}` }
    const { error: auditErr } = await supabase.from('qaf_audit_log').insert({
      project_id: cmp.project_id,
      action: 'recompare',
      entity: 'qaf_comparison',
      entity_id: comparisonId,
      detail: { mode: 'multi_qaf_variant_vs_standard', variantId: selectedVariantId, reviewRequired: result.reviewRequired, by: userId },
    })
    if (auditErr) logger.error('qaf.recompare (multi_qaf_variant_vs_standard) audit insert failed', { comparisonId, error: auditErr.message })
    return { ok: true, data: null }
  }

  // ── Summary: rehydrate both sides, re-run the engine with pins. ─────────────
  const fileIds = [altFileId, neuFileId]
  const [filesRes, stepsRes, partRes, metricsRes] = await Promise.all([
    // g60_meta carries the persisted MATERIAL/SBM rows (KAR-899 rehydration
    // fix below) alongside template_type.
    supabase.from('qaf_file').select('id, original_file_name, template_type, g60_meta').in('id', fileIds),
    supabase
      .from('qaf_manufacturing_step')
      .select('id, file_id, raw_values, row_index')
      .in('file_id', fileIds)
      .order('row_index', { ascending: true }),
    cmp.part_number
      ? supabase
          .from('qaf_part')
          .select('part_number, part_name, variant, project_code, supplier, quotation_date, version')
          .eq('project_id', cmp.project_id)
          .eq('part_number', cmp.part_number as string)
          .maybeSingle()
      : Promise.resolve({ data: null, error: null }),
    supabase
      .from('qaf_summary_metric')
      .select('file_id, metric_key, value, currency, source_cell')
      .in('file_id', fileIds),
  ])
  const loadErr = filesRes.error ?? stepsRes.error ?? partRes.error ?? metricsRes.error
  if (loadErr) return { ok: false, error: `load failed: ${loadErr.message}` }

  const fileById = new Map((filesRes.data ?? []).map((f) => [f.id as string, f]))
  const sideOf = (fileId: string) => {
    const file = fileById.get(fileId)
    const stepRows = (stepsRes.data ?? []).filter((r) => r.file_id === fileId)
    const parsed = rehydrateFile({
      file: {
        id: fileId,
        original_file_name: (file?.original_file_name as string) ?? '',
        template_type: (file?.template_type as string | null) ?? null,
      },
      part: (partRes.data as never) ?? null,
      steps: stepRows.map((r) => r.raw_values as QAFRow),
    })
    parsed.summaryMetrics = summaryMetricsFromRows(
      (metricsRes.data ?? []).filter((m) => m.file_id === fileId).map((m) => ({
        metric_key: m.metric_key as string,
        value: m.value as number | null,
        currency: m.currency as string | null,
        source_cell: m.source_cell as string | null,
      })),
      (file?.template_type as string | null) ?? null,
    )
    // KAR-899: rehydrate MATERIAL/SBM detail rows from the persisted
    // qaf_file.g60_meta JSONB — same tri-state contract as the live ingest
    // path (undefined/null/rows[], see material-parser.ts/sbm-parser.ts doc
    // comments). Without this, every recompare silently fell back to
    // materialRows/sbmRows === undefined ("no parse attempted"), which skips
    // the material_detail_sum/sbm_detail_sum reconciliation checks entirely —
    // not even as nicht_pruefbar — regardless of what was actually parsed at
    // ingest time.
    const g60Meta = file?.g60_meta as
      | {
          material?: PersistedMaterialMeta | null
          sbm?: PersistedSbmMeta | null
          rmr?: PersistedRmrMeta | null
          logistics?: PersistedLogisticsMeta | null
          lccn?: PersistedLccnMeta | null
          co2e?: PersistedCo2eMeta | null
          manufacturingParseMeta?: {
            parseConfidence: number
            unmappedHeaders: string[]
            mappedFieldCount: number
            ignoredCandidateSheets?: string[]
          }
          // KAR-914/P4.4 adversarial-review F3 fix — see the g60_meta insert
          // above (ingestQafUpload) for why this is persisted.
          workbookSafety?: WorkbookSafetyResult | null
          // KAR-926/Multi-QAF-Programm P0.1 — see the g60_meta insert above
          // (ingestQafUpload) for why this is persisted.
          multiQafDetection?: MultiQafDetectionResult | null
          // KAR-912/P4.2 — see the g60_meta insert above (ingestQafUpload)
          // and applyFieldMappingOverrides call below.
          fieldMappingOverrides?: FieldMappingOverride[]
        }
      | null
      | undefined
    parsed.materialRows = materialRowsFromPersistedMeta(g60Meta?.material)
    // KAR-927/P0.2, same permanent-loss-on-first-file-replace risk
    // manufacturingParseMeta's own KAR-899 follow-up fixed below —
    // materialParseMeta drives parser_ignored_material_candidate_sheets
    // (compare.ts). `g60Meta?.material?.parseMeta` is undefined both for
    // historical files that predate this fix (no key in g60_meta.material.
    // parseMeta yet) and for files with no MATERIAL sheet at all
    // (g60Meta.material itself null) — left as undefined, never fabricated.
    parsed.materialParseMeta = g60Meta?.material?.parseMeta
    parsed.sbmRows = sbmRowsFromPersistedMeta(g60Meta?.sbm)
    // KAR-927/P0.2, same contract as materialParseMeta above — drives
    // parser_ignored_sbm_candidate_sheets.
    parsed.sbmParseMeta = g60Meta?.sbm?.parseMeta
    // KAR-902/P2.3, same rehydration-staleness fix KAR-899 applied to
    // material/sbm: without this, every recompare would silently fall back
    // to rmrRows === undefined ("no parse attempted"), skipping the
    // rmr_raw_material_surcharge validation entirely regardless of what was
    // actually parsed at ingest time.
    parsed.rmrRows = rmrRowsFromPersistedMeta(g60Meta?.rmr)
    // KAR-903/P2.4, same rehydration-staleness fix KAR-899/KAR-902 applied to
    // material/sbm/rmr: without this, every recompare would silently fall
    // back to logisticsRows === undefined ("no parse attempted"), skipping
    // BOTH the log_calc_cost_per_delivery_site/log_incoterm_domain validation
    // AND the logistics_transport_detail_sum/logistics_customs_detail_sum
    // reconciliation checks entirely, regardless of what was actually parsed
    // at ingest time.
    parsed.logisticsRows = logisticsRowsFromPersistedMeta(g60Meta?.logistics)
    // KAR-904/P2.5, same rehydration-staleness fix KAR-899/KAR-902/KAR-903
    // applied to material/sbm/rmr/logistics: without this, every recompare
    // would silently fall back to lccnValues/co2eMaterialRows === undefined
    // ("no parse attempted"), skipping ALL LC-CN and CO2e validation checks
    // entirely, regardless of what was actually parsed at ingest time.
    // lccnFromPersistedMeta returns `LccnParseResult | null | undefined` (a
    // single record, not rows) — `?.values ?? null` mirrors the same
    // "undefined stays undefined, null/degraded collapses to null, otherwise
    // read .values" derivation ingestQafUpload's own IngestResult.lccnValues
    // uses, just applied to the rehydrated JSONB entry point instead of the
    // live parse.
    const lccnRehydrated = lccnFromPersistedMeta(g60Meta?.lccn)
    parsed.lccnValues = lccnRehydrated === undefined ? undefined : (lccnRehydrated?.values ?? null)
    const co2eRehydrated = co2eFromPersistedMeta(g60Meta?.co2e)
    parsed.co2eMaterialRows = co2eRehydrated === undefined ? undefined : co2eMaterialRowsForReconciliation(co2eRehydrated)
    // KAR-902 follow-up fix (adversarial-review finding, confidence 85):
    // rehydrate the RMR parse-level diagnostics too — without this, a
    // refreshPlausibility:true recompare would DELETE a persisted
    // rmr_possible_unparsed_block finding and could never regenerate it
    // (compareQafPair only emits it when rmrParseMeta is set), same
    // permanent-loss-on-first-file-replace risk manufacturingParseMeta's own
    // KAR-899 follow-up fixed below. `g60Meta?.rmr?.parseMeta` is undefined
    // for historical files/comparisons that predate this fix (no key in
    // g60_meta.rmr yet) — left as undefined, never fabricated.
    parsed.rmrParseMeta = g60Meta?.rmr?.parseMeta
    // KAR-899 follow-up (adversarial-review finding, confidence 90):
    // manufacturingParseMeta drives parser_degraded_manufacturing_headers
    // (compare.ts) — without rehydrating it here, a refreshPlausibility:true
    // recompare would DELETE a persisted degradation finding and could never
    // regenerate it (compareQafPair only emits it when this field is set),
    // permanently losing a still-true finding on the very first file replace.
    // g60Meta?.manufacturingParseMeta is undefined for historical files that
    // predate this fix (no key in g60_meta yet) — left as undefined, exactly
    // like today, never fabricated.
    parsed.manufacturingParseMeta = g60Meta?.manufacturingParseMeta
    // KAR-914/P4.4 adversarial-review F3 fix: same permanent-loss-on-first-
    // file-replace risk manufacturingParseMeta's own KAR-899 follow-up fixed
    // above — workbookSafety drives security_macro_present/
    // security_external_links (compare.ts). Without rehydrating it here, a
    // refreshPlausibility:true recompare (replaceComparisonFile) would
    // DELETE a persisted macro/external-link finding and could never
    // regenerate it (compareQafPair only emits it when this field is set),
    // even though the underlying file's macro/link content never changed.
    // g60Meta?.workbookSafety is undefined for historical files that predate
    // this fix (no key in g60_meta yet) — left as undefined, never
    // fabricated.
    parsed.workbookSafety = g60Meta?.workbookSafety
    // KAR-926/Multi-QAF-Programm P0.1: same rehydration-staleness discipline
    // as workbookSafety above — without this, a refreshPlausibility:true
    // recompare would DELETE a persisted multi_qaf_suspected (ambiguous)
    // finding and could never regenerate it. g60Meta?.multiQafDetection is
    // undefined for historical files that predate this fix (no key in
    // g60_meta yet) — left as undefined, never fabricated.
    parsed.multiQafDetection = g60Meta?.multiQafDetection
    // KAR-912/P4.2: re-apply any persisted manual field-mapping overrides on
    // every recompute — same rehydration-staleness discipline as
    // material/sbm/rmr/logistics/manufacturingParseMeta/workbookSafety
    // above (KAR-899 lesson): a correction that only lived in the ingest-
    // time in-memory IngestResult would silently vanish the first time this
    // comparison was recomputed, even though the persisted qaf_file.g60_meta
    // still carries it. `g60Meta?.fieldMappingOverrides ?? []` is a true
    // no-op for every file that never had an override set (the overwhelming
    // majority) — applyFieldMappingOverrides returns the SAME array
    // reference for an empty override list.
    parsed.steps = applyFieldMappingOverrides(parsed.steps, g60Meta?.fieldMappingOverrides ?? [])
    return { parsed, stepIds: stepRows.map((r) => r.id as string) }
  }
  const altSide = sideOf(altFileId)
  const neuSide = sideOf(neuFileId)

  // Pins arrive as step UUIDs — map to row indices for the engine.
  const altIndexById = new Map(altSide.stepIds.map((id, i) => [id, i]))
  const neuIndexById = new Map(neuSide.stepIds.map((id, i) => [id, i]))
  const pins: ManualPin[] = (options.pins ?? [])
    .map((p) => ({
      altIndex: altIndexById.get(p.altStepId) ?? -1,
      neuIndex: p.neuStepId === null ? null : (neuIndexById.get(p.neuStepId) ?? -1),
    }))
    .filter((p) => p.altIndex >= 0 && (p.neuIndex === null || p.neuIndex >= 0))

  const result = compareQafPair(altSide.parsed, neuSide.parsed, { pins })
  const rowset = buildComparisonRowset(result, {
    projectId: cmp.project_id as string,
    comparisonId,
    baselineFileId: altFileId,
    comparisonFileId: neuFileId,
    createdBy: userId,
    engineVersion: { ...ENGINE_VERSION, baselineStatus: 'manual' },
  })

  // Replace derived rows WITHOUT a transaction (PostgREST): insert-first with
  // compensation. Old rows are captured, new rows inserted alongside; only
  // after every insert succeeded are the old rows deleted. On any insert
  // failure the new rows are removed — the comparison is never half-empty.
  //
  // qaf_plausibility_issue (KAR-899): only part of the replace-set when
  // options.refreshPlausibility is true (replaceComparisonFile) — a plain
  // pin-recompare/role-swap leaves it untouched, because those findings are
  // not matching-dependent and cannot be re-derived from the single
  // last-write-wins qaf_part row (see this function's doc comment above).
  // recompareReplacedTables (persistence-mapper.ts) is the single source of
  // truth for the mode -> table-set decision.
  const REPLACED_TABLES = recompareReplacedTables(Boolean(options.refreshPlausibility))
  const oldIds: Record<string, string[]> = {}
  for (const table of REPLACED_TABLES) {
    const { data, error } = await supabase.from(table).select('id').eq('comparison_id', comparisonId)
    if (error) return { ok: false, error: `${table} snapshot failed: ${error.message}` }
    oldIds[table] = (data ?? []).map((r) => r.id as string)
  }

  const rollbackNew = async () => {
    for (const table of REPLACED_TABLES) {
      const ids = oldIds[table]
      // delete everything for this comparison that is NOT in the old snapshot
      const query = supabase.from(table).delete().eq('comparison_id', comparisonId)
      const { error } = ids.length ? await query.not('id', 'in', `(${ids.join(',')})`) : await query
      if (error) logger.error('qaf.recompare rollback failed', { table, error: error.message })
    }
  }

  const childErr = await insertStepChildren(
    supabase,
    { projectId: cmp.project_id as string, comparisonId },
    result,
    altSide.stepIds,
    neuSide.stepIds,
  )
  if (childErr) {
    await rollbackNew()
    return { ok: false, error: childErr }
  }
  if (rowset.summaryDiffs.length) {
    const { error } = await supabase.from('qaf_summary_diff').insert(rowset.summaryDiffs)
    if (error) {
      await rollbackNew()
      return { ok: false, error: `qaf_summary_diff insert failed: ${error.message}` }
    }
  }
  if (rowset.materialDiffs.length) {
    const { error } = await supabase.from('qaf_material_diff').insert(rowset.materialDiffs)
    if (error) {
      await rollbackNew()
      return { ok: false, error: `qaf_material_diff insert failed: ${error.message}` }
    }
  }
  if (rowset.structureChanges.length) {
    const { error } = await supabase.from('qaf_structure_change').insert(rowset.structureChanges)
    if (error) {
      await rollbackNew()
      return { ok: false, error: `qaf_structure_change insert failed: ${error.message}` }
    }
  }
  // KAR-899: only written when this call opted into refreshing plausibility
  // (see REPLACED_TABLES comment above) — a plain pin-recompare never inserts
  // here, leaving the existing qaf_plausibility_issue rows byte-identical.
  if (options.refreshPlausibility && rowset.plausibilityIssues.length) {
    const { error } = await supabase.from('qaf_plausibility_issue').insert(rowset.plausibilityIssues)
    if (error) {
      await rollbackNew()
      return { ok: false, error: `qaf_plausibility_issue insert failed: ${error.message}` }
    }
  }
  {
    const { error } = await supabase.from('qaf_root_cause').insert(rowset.rootCause)
    if (error) {
      await rollbackNew()
      return { ok: false, error: `root cause insert failed: ${error.message}` }
    }
  }

  // Persist the fresh engine snapshot BEFORE retiring the old rows: if this
  // update fails we roll the new rows back and the old state stays intact —
  // ok:false from this function ALWAYS means "comparison unchanged" (the
  // replace-file caller relies on that invariant for its own rollback).
  // baseline_status must stay within its CHECK ('ok','baseline_review',
  // 'insufficient') — a user decision counts as confirmed: 'ok'.
  const { error: cmpUpdErr } = await supabase
    .from('qaf_comparison')
    .update({
      baseline_file_id: altFileId,
      comparison_file_id: neuFileId,
      baseline_status: options.swap ? 'ok' : undefined,
      engine_version: rowset.comparison.engine_version,
      // Data changed → a previous 'reviewed' sign-off no longer applies.
      status: 'draft',
    })
    .eq('id', comparisonId)
  if (cmpUpdErr) {
    await rollbackNew()
    return { ok: false, error: `comparison update failed: ${cmpUpdErr.message}` }
  }

  // Retire the old snapshot. A partial failure here must NOT trigger
  // rollbackNew (earlier tables' old rows are already gone) — leftover old
  // rows only duplicate display data and the next successful recompare's
  // snapshot sweeps them up. Log and continue.
  for (const table of REPLACED_TABLES) {
    if (oldIds[table].length) {
      const { error } = await supabase.from(table).delete().in('id', oldIds[table])
      if (error) logger.error('qaf.recompare old-row retire failed (next recompare sweeps)', { table, error: error.message })
    }
  }

  // The recompare is committed — a failed audit insert must not fail the
  // user-visible operation (a retry would replay a completed change).
  const { error: auditErr } = await supabase.from('qaf_audit_log').insert({
    project_id: cmp.project_id,
    action: 'recompare',
    entity: 'qaf_comparison',
    entity_id: comparisonId,
    detail: {
      swap: Boolean(options.swap),
      manualPins: pins.length,
      by: userId,
      // KAR-899: makes a plausibility refresh (vs. the default untouched
      // pin-recompare) visible in the audit trail, with the count of fresh
      // findings actually persisted.
      ...(options.refreshPlausibility
        ? { refreshPlausibility: true, plausibilityIssuesReplaced: rowset.plausibilityIssues.length }
        : {}),
    },
  })
  if (auditErr) logger.error('qaf.recompare audit insert failed', { comparisonId, error: auditErr.message })
  return { ok: true, data: null }
}

// ── Multi-QAF-Variante <-> Standard-QAF (KAR-943/Multi-QAF-Programm P3.2,
// Master-Prompt §15 Szenario B) ─────────────────────────────────────────────

/**
 * Load ONE standard qaf_file's identity (`summary`) + `summaryMetrics` as a
 * `QafFileParsed` for `runVariantVsStandardCompare` (variant-vs-standard.ts's
 * own doc: that function only ever reads `.ref`/`.summary`/`.summaryMetrics`
 * off `standardFile` — MATERIAL/SBM/RMR/LOGISTICS/LCCN/CO2e/workbookSafety are
 * never read by this comparison mode, unlike the much richer `sideOf`
 * rehydration `recompareComparison`'s "Summary" branch above performs for the
 * FULL standard-vs-standard engine). `steps` is still populated (cheap, keeps
 * the returned `QafFileParsed` representative of the real file rather than a
 * lie) even though this specific comparison mode never reads it.
 *
 * Identity fields come from `qaf_part` (KEYED BY (project_id, part_number),
 * same "single last-write-wins row per part number" table `rehydrateFile`'s
 * `partToSummary` always reads — see rehydrate.ts) — resolved via
 * `qaf_file.part_number_from_content`, NOT via any existing `qaf_comparison`
 * row, so this also works for a standard file that has never been paired
 * into a comparison yet.
 */
async function loadStandardQafFileForVariantCompare(
  supabase: Awaited<ReturnType<typeof createClient>>,
  projectId: string,
  fileId: string,
): Promise<{ ok: true; parsed: QafFileParsed } | { ok: false; error: string }> {
  const [fileRes, stepsRes, metricsRes] = await Promise.all([
    supabase.from('qaf_file').select('id, original_file_name, template_type, part_number_from_content').eq('id', fileId).eq('project_id', projectId).maybeSingle(),
    supabase.from('qaf_manufacturing_step').select('raw_values, row_index').eq('file_id', fileId).order('row_index', { ascending: true }),
    supabase.from('qaf_summary_metric').select('metric_key, value, currency, source_cell').eq('file_id', fileId),
  ])
  if (fileRes.error) return { ok: false, error: `qaf_file-Ladefehler: ${fileRes.error.message}` }
  if (!fileRes.data) return { ok: false, error: 'Standard-QAF-Datei nicht gefunden (oder kein Zugriff).' }
  if (stepsRes.error) return { ok: false, error: `qaf_manufacturing_step-Ladefehler: ${stepsRes.error.message}` }
  if (metricsRes.error) return { ok: false, error: `qaf_summary_metric-Ladefehler: ${metricsRes.error.message}` }

  const partNumber = fileRes.data.part_number_from_content as string | null
  const partRes = partNumber
    ? await supabase
        .from('qaf_part')
        .select('part_number, part_name, variant, project_code, supplier, quotation_date, version')
        .eq('project_id', projectId)
        .eq('part_number', partNumber)
        .maybeSingle()
    : { data: null, error: null }
  if (partRes.error) return { ok: false, error: `qaf_part-Ladefehler: ${partRes.error.message}` }

  const parsed = rehydrateFile({
    file: {
      id: fileRes.data.id as string,
      original_file_name: fileRes.data.original_file_name as string,
      template_type: fileRes.data.template_type as string | null,
    },
    part: (partRes.data as never) ?? null,
    steps: (stepsRes.data ?? []).map((r) => r.raw_values as QAFRow),
  })
  parsed.summaryMetrics = summaryMetricsFromRows(
    (metricsRes.data ?? []).map((m) => ({
      metric_key: m.metric_key as string,
      value: m.value as number | null,
      currency: m.currency as string | null,
      source_cell: m.source_cell as string | null,
    })),
    fileRes.data.template_type as string | null,
  )
  return { ok: true, parsed }
}

// ── Multi-QAF Varianten-Match-Override setzen/lösen (KAR-947 — Multi-QAF-
// Vergleichs-Detail-UI) ─────────────────────────────────────────────────────
//
// The FIRST setter action for compare-flow.ts's `VariantMatchOverride`
// plumbing (see that module's own header "Override persistence" — the
// review-workflow types have existed since KAR-936/KAR-942, but no P4 UI
// ever wrote one). Same KAR-912 field-mapping-override.ts pattern, applied
// to variant PAIRS instead of a single row's field value: persists into
// `qaf_comparison.engine_version.variantMatchOverrides` (the existing JSONB
// bag `recompareComparison`'s own `multi_qaf` branch already reads/carries
// forward, no migration needed) and IMMEDIATELY re-runs
// `runMultiQafCompareFlow` so the stored `multiQafComparisonResult` reflects
// the decision without a separate caller-triggered recompute — unlike
// `setManualFieldMappingOverride` (which leaves recompute to an explicit
// "Jetzt neu berechnen" button), a match decision changes what a reviewer is
// looking AT (the same variant moves out of "uncertain"), so an intermediate
// stale render would be actively confusing here.

/** Resolves one variant by its stable id within a container's full
 * active+inactive set — `null` (never throws) when not found, so a caller
 * can return an honest, bilingual "Variante nicht gefunden" error instead of
 * an unhandled exception (the id comes from client-submitted form input, not
 * a value this module derived itself). */
function findVariantInContainer(container: MultiQafContainer, variantId: string): VariantDefinition | null {
  return allMultiQafContainerVariants(container).find((v) => v.stableInternalId === variantId) ?? null
}

/** Builds the re-identification snapshot `matchVariantsWithOverrides`
 * (variant-matcher.ts) verifies an override against on every recompute —
 * compositeCanonicalKey + dimensions, NEVER stableInternalId/column position
 * (see that module's own doc for why: an id can change across a re-parse,
 * the snapshot is what proves "still the same variant"). Delegates to
 * variant-matcher.ts's own `identitySnapshotFor` — this file must never grow
 * a second, independently-drifting copy of that construction. */
function overrideSnapshotFor(v: VariantDefinition): VariantMatchOverrideIdentitySnapshot {
  return identitySnapshotFor(v)
}

/** Replaces a previously-persisted override for the SAME ALT variant — thin
 * wrapper over variant-matcher.ts's own `replaceOverrideForVariant` (see
 * that function's own doc for the adversarial-review-of-#317 F2 bug this
 * fixes: the previous local implementation compared bare
 * `compositeCanonicalKey` strings and skipped replacement entirely for
 * no-identity variants). This file must never grow a second,
 * independently-drifting copy of that logic. */
function upsertVariantMatchOverride(
  existing: readonly VariantMatchOverride[],
  next: VariantMatchOverride,
  altVariant: VariantDefinition,
): VariantMatchOverride[] {
  return replaceOverrideForVariant(existing, next, overrideSnapshotFor(altVariant))
}

/** Shared "drift vs. ambiguous vs. still valid" status carry-forward for a
 * persisted `VariantMatchOverride[]` list, given the `droppedOverrides`
 * `runMultiQafCompareFlow`'s own `matchVariantsWithOverrides` call just
 * reported for THIS recompute (KAR-912 `carryForwardFieldMappingOverrides`
 * precedent, applied here). Matches by full identity-snapshot equality
 * (`identitySnapshotsEqual`, both `left` AND `right`) rather than a
 * `compositeCanonicalKey`-only string key — adversarial-review-of-#317 F4
 * fix support: a bare key-string Set conflates every no-identity
 * (`compositeCanonicalKey === ''`) override with every OTHER no-identity
 * override, so dropping ONE of them would have silently mislabeled ALL of
 * them (including ones that were never actually touched by this recompute)
 * as dropped. Copies the reported `status` verbatim (never hardcodes
 * `'dropped_on_drift'`) so the new `'dropped_ambiguous_identity'` reason
 * survives the round-trip too. */
function carryForwardMultiQafOverrideStatuses(
  persistedOverrides: readonly VariantMatchOverride[],
  droppedOverrides: readonly VariantMatchOverride[],
): VariantMatchOverride[] {
  const overrideRightEqual = (a: VariantMatchOverrideIdentitySnapshot | null, b: VariantMatchOverrideIdentitySnapshot | null): boolean => {
    if (a === null || b === null) return a === null && b === null
    return identitySnapshotsEqual(a, b)
  }
  return persistedOverrides.map((o) => {
    const dropped = droppedOverrides.find((d) => identitySnapshotsEqual(d.left, o.left) && overrideRightEqual(d.right, o.right))
    return dropped ? { ...o, status: dropped.status ?? 'dropped_on_drift' } : o
  })
}

/** Shared recompute+persist tail for both setVariantMatchOverride and
 * clearVariantMatchOverride below — re-runs the full compare-flow with the
 * given override list and writes back `engine_version`/`baseline_status`,
 * exactly like `recompareComparison`'s own `multi_qaf` branch (module header
 * "Override persistence" / "carry-forward" — kept as its own helper here
 * rather than calling `recompareComparison` itself, since that function's
 * `swap`/`pins` option shape does not carry an override list at all). */
async function recomputeAndPersistMultiQafOverrides(
  supabase: Awaited<ReturnType<typeof createClient>>,
  comparisonId: string,
  altContainer: MultiQafContainer,
  neuContainer: MultiQafContainer,
  nextOverrides: readonly VariantMatchOverride[],
): Promise<{ ok: true; result: MultiQafComparisonResult } | { ok: false; error: string }> {
  let result: MultiQafComparisonResult
  try {
    result = runMultiQafCompareFlow(altContainer, neuContainer, { variantMatchOverrides: nextOverrides })
  } catch (e) {
    return { ok: false, error: `Neuberechnung fehlgeschlagen: ${e instanceof Error ? e.message : String(e)}` }
  }
  const carriedOverrides = carryForwardMultiQafOverrideStatuses(nextOverrides, result.droppedOverrides)
  const baselineStatus = multiQafBaselineStatusFor(result)
  const { error: updErr } = await supabase
    .from('qaf_comparison')
    .update({
      baseline_status: baselineStatus,
      engine_version: {
        ...ENGINE_VERSION,
        baselineStatus,
        variantMatchOverrides: carriedOverrides,
        multiQafComparisonResult: { modelVersion: MULTI_QAF_COMPARISON_RESULT_VERSION, result },
      },
      status: 'draft',
    })
    .eq('id', comparisonId)
  if (updErr) return { ok: false, error: `comparison update failed: ${updErr.message}` }
  return { ok: true, result }
}

/** Loads + deserializes both sides' persisted `MultiQafContainer`s for a
 * `multi_qaf` comparison — shared preamble for both actions below. Returns a
 * bilingual-ready error string (never throws) on any failure, same
 * discipline every other action in this file follows. */
async function loadMultiQafComparisonContainers(
  supabase: Awaited<ReturnType<typeof createClient>>,
  comparisonId: string,
): Promise<
  | { ok: true; projectId: string; altContainer: MultiQafContainer; neuContainer: MultiQafContainer; persistedOverrides: VariantMatchOverride[] }
  | { ok: false; error: string }
> {
  const { data: cmp, error: cmpErr } = await supabase
    .from('qaf_comparison')
    .select('id, project_id, baseline_file_id, comparison_file_id, comparison_mode, engine_version')
    .eq('id', comparisonId)
    .maybeSingle()
  if (cmpErr) return { ok: false, error: `comparison load failed: ${cmpErr.message}` }
  if (!cmp || !cmp.baseline_file_id || !cmp.comparison_file_id) {
    return { ok: false, error: 'Vergleich nicht gefunden oder unvollständig.' }
  }
  if (cmp.comparison_mode !== 'multi_qaf') {
    return {
      ok: false,
      error: 'Varianten-Match-Overrides gelten nur für Multi-QAF-Vergleiche. / Variant match overrides only apply to Multi-QAF comparisons.',
    }
  }

  const { data: files, error: filesErr } = await supabase
    .from('qaf_file')
    .select('id, g60_meta')
    .in('id', [cmp.baseline_file_id, cmp.comparison_file_id])
  if (filesErr) return { ok: false, error: `load failed: ${filesErr.message}` }
  const fileById = new Map((files ?? []).map((f) => [f.id as string, f]))
  const altRaw = (fileById.get(cmp.baseline_file_id as string)?.g60_meta as { multiQafContainer?: unknown } | null)?.multiQafContainer
  const neuRaw = (fileById.get(cmp.comparison_file_id as string)?.g60_meta as { multiQafContainer?: unknown } | null)?.multiQafContainer
  if (!altRaw || !neuRaw) {
    return { ok: false, error: 'Multi-QAF-Container fehlt auf mindestens einer Seite — Override nicht möglich.' }
  }

  try {
    const altContainer = deserializeMultiQafContainer(JSON.stringify(altRaw))
    const neuContainer = deserializeMultiQafContainer(JSON.stringify(neuRaw))
    const persistedOverrides = (cmp.engine_version as { variantMatchOverrides?: VariantMatchOverride[] } | null)?.variantMatchOverrides ?? []
    return { ok: true, projectId: cmp.project_id as string, altContainer, neuContainer, persistedOverrides }
  } catch (e) {
    return { ok: false, error: `Multi-QAF-Container ungültig: ${e instanceof Error ? e.message : String(e)}` }
  }
}

/** True when `variant` is genuinely IDENTITY-LESS (`compositeCanonicalKey
 * === ''`) AND its (empty) identity is shared by at least one OTHER variant
 * in `container` — the same narrow ambiguous-identity case
 * `matchVariantsWithOverrides` (variant-matcher.ts, see
 * `resolveOverrideSideIndex`'s own doc for why this is scoped to no-identity
 * variants only) refuses to auto-apply and drops as
 * `'dropped_ambiguous_identity'` (adversarial-review-of-#317 F4).
 * `setVariantMatchOverride` below refuses to even PERSIST an override
 * targeting such a variant — telling the reviewer upfront ("kann nicht
 * eindeutig zugeordnet werden") is more honest than silently accepting the
 * decision and only surfacing the problem later as a dropped override after
 * the next recompute. A non-empty-key collision (two variants that happen to
 * share real, non-empty dimensions) is deliberately NOT flagged here — see
 * `resolveOverrideSideIndex`'s own doc for why that pre-existing "duplicate-
 * looking variant, disambiguated by explicitly picking one via its
 * stableInternalId" workflow must keep working exactly as it always has. */
function variantIdentityIsAmbiguous(container: MultiQafContainer, variant: VariantDefinition): boolean {
  if (variant.compositeCanonicalKey !== '') return false
  return findAllByIdentitySnapshot(allMultiQafContainerVariants(container), identitySnapshotFor(variant)).length > 1
}

/**
 * Sets (or replaces) ONE manual variant-match decision on a `multi_qaf`
 * comparison and immediately recomputes the persisted comparison result —
 * the KAR-947 UI's "uncertain/auto-Match lösen bzw. anders zuordnen"
 * override entry point (task spec), wired through the `matchVariantsWithOverrides`
 * plumbing `runMultiQafCompareFlow` already exercises for a persisted list
 * (see module header above). `decision: 'unmatched'` confirms "this ALT
 * variant genuinely has no NEU counterpart" (mirrors `VariantMatchOverride.
 * right === null`) — `neuVariantId` is ignored (forced to `null`) in that
 * case, never trusted from caller input once `decision` already says there
 * is no counterpart.
 */
export async function setVariantMatchOverride(
  comparisonId: string,
  input: { altVariantId: string; neuVariantId: string | null; decision: 'matched' | 'unmatched'; note: string | null },
): Promise<ActionResult<null>> {
  if (!uuidSchema.safeParse(comparisonId).success) return { ok: false, error: 'invalid comparison id' }
  if (!input.altVariantId) return { ok: false, error: 'ALT-Variante fehlt.' }
  if (input.decision === 'matched' && !input.neuVariantId) {
    return { ok: false, error: 'Ein bestätigtes Match braucht eine NEU-Variante. / A confirmed match needs a NEU variant.' }
  }

  const supabase = await createClient()
  const { data: claims } = await supabase.auth.getClaims()
  if (!claims?.claims) return { ok: false, error: 'unauthorized' }
  const userId = claims.claims.sub as string

  const loaded = await loadMultiQafComparisonContainers(supabase, comparisonId)
  if (!loaded.ok) return loaded

  const altVariant = findVariantInContainer(loaded.altContainer, input.altVariantId)
  if (!altVariant) return { ok: false, error: `ALT-Variante "${input.altVariantId}" nicht im Container gefunden.` }
  if (variantIdentityIsAmbiguous(loaded.altContainer, altVariant)) {
    return {
      ok: false,
      error:
        'Diese ALT-Variante hat keine eindeutige Identität (identische Dimensionen wie mind. eine andere Variante) und kann daher nicht eindeutig für ein Override referenziert werden. / This ALT variant has no unique identity (identical dimensions to at least one other variant) and cannot be unambiguously targeted by an override.',
    }
  }
  let neuVariant: VariantDefinition | null = null
  if (input.decision === 'matched') {
    neuVariant = input.neuVariantId ? findVariantInContainer(loaded.neuContainer, input.neuVariantId) : null
    if (!neuVariant) return { ok: false, error: `NEU-Variante "${input.neuVariantId}" nicht im Container gefunden.` }
    if (variantIdentityIsAmbiguous(loaded.neuContainer, neuVariant)) {
      return {
        ok: false,
        error:
          'Diese NEU-Variante hat keine eindeutige Identität (identische Dimensionen wie mind. eine andere Variante) und kann daher nicht eindeutig für ein Override referenziert werden. / This NEU variant has no unique identity (identical dimensions to at least one other variant) and cannot be unambiguously targeted by an override.',
      }
    }
  }

  const nextOverride: VariantMatchOverride = {
    left: overrideSnapshotFor(altVariant),
    right: neuVariant ? overrideSnapshotFor(neuVariant) : null,
    decision: input.decision,
    note: input.note,
    setBy: userId,
    setAt: new Date().toISOString(),
  }
  const nextOverrides = upsertVariantMatchOverride(loaded.persistedOverrides, nextOverride, altVariant)

  const recomputed = await recomputeAndPersistMultiQafOverrides(supabase, comparisonId, loaded.altContainer, loaded.neuContainer, nextOverrides)
  if (!recomputed.ok) return recomputed

  const { error: auditErr } = await supabase.from('qaf_audit_log').insert({
    project_id: loaded.projectId,
    action: 'variant_match_override_set',
    entity: 'qaf_comparison',
    entity_id: comparisonId,
    detail: {
      by: userId,
      altVariantId: input.altVariantId,
      neuVariantId: input.neuVariantId,
      decision: input.decision,
      reviewRequired: recomputed.result.reviewRequired,
    },
  })
  if (auditErr) logger.error('qaf.variant_match_override_set audit insert failed', { comparisonId, error: auditErr.message })

  return { ok: true, data: null }
}

/**
 * Removes a previously-set manual variant-match override for one ALT
 * variant (reverting it to whatever the automatic cascade decides on the
 * next compute) and immediately recomputes — the "undo" counterpart to
 * `setVariantMatchOverride` above. Matches the override to remove via
 * `identitySnapshotsEqual` (compositeCanonicalKey AND dimensions,
 * variant-matcher.ts) rather than a bare `compositeCanonicalKey ===`
 * compare — adversarial-review-of-#317 F1 fix: the old bare-key compare
 * skipped removal ENTIRELY for no-identity (`compositeCanonicalKey === ''`)
 * variants (every no-identity variant looks like every other one by key
 * alone, so the filter refused to touch ANY of them), yet still returned
 * `{ ok: true }` and wrote a `'variant_match_override_cleared'` audit entry
 * — the override stayed active while the caller was told it had been
 * cleared. Dimensions distinguish genuinely different no-identity variants,
 * so this now removes the correct entry (or entries, for pre-existing
 * duplicates from before this fix — safe to remove ALL of them: they all
 * share this exact variant's identity, so none of them can belong to a
 * DIFFERENT variant). `removedCount === 0` (nothing was actually active for
 * this variant) is an honest no-op: `ok: true`, no audit entry — never a
 * false "cleared" claim, and never a fabricated error either, since there is
 * genuinely nothing wrong with a clear-call that finds nothing to clear.
 */
export async function clearVariantMatchOverride(comparisonId: string, altVariantId: string): Promise<ActionResult<null>> {
  if (!uuidSchema.safeParse(comparisonId).success) return { ok: false, error: 'invalid comparison id' }
  if (!altVariantId) return { ok: false, error: 'ALT-Variante fehlt.' }

  const supabase = await createClient()
  const { data: claims } = await supabase.auth.getClaims()
  if (!claims?.claims) return { ok: false, error: 'unauthorized' }
  const userId = claims.claims.sub as string

  const loaded = await loadMultiQafComparisonContainers(supabase, comparisonId)
  if (!loaded.ok) return loaded

  const altVariant = findVariantInContainer(loaded.altContainer, altVariantId)
  if (!altVariant) return { ok: false, error: `ALT-Variante "${altVariantId}" nicht im Container gefunden.` }

  const { next: nextOverrides, removedCount } = removeActiveOverridesForVariant(loaded.persistedOverrides, overrideSnapshotFor(altVariant))

  const recomputed = await recomputeAndPersistMultiQafOverrides(supabase, comparisonId, loaded.altContainer, loaded.neuContainer, nextOverrides)
  if (!recomputed.ok) return recomputed

  if (removedCount > 0) {
    const { error: auditErr } = await supabase.from('qaf_audit_log').insert({
      project_id: loaded.projectId,
      action: 'variant_match_override_cleared',
      entity: 'qaf_comparison',
      entity_id: comparisonId,
      detail: { by: userId, altVariantId, reviewRequired: recomputed.result.reviewRequired, removedCount },
    })
    if (auditErr) logger.error('qaf.variant_match_override_cleared audit insert failed', { comparisonId, error: auditErr.message })
  }

  return { ok: true, data: null }
}

/**
 * Create a NEW `comparison_mode: 'multi_qaf_variant_vs_standard'`
 * `qaf_comparison` row (Master-Prompt §15 Szenario B): ONE explicitly chosen,
 * active `variantId` inside `multiQafFileId`'s already-assembled
 * `MultiQafContainer`, compared against `standardFileId` via
 * `runVariantVsStandardCompare` (real Summary-identity + SummaryMetrics
 * modules, every steps-dependent module degraded honestly — see that
 * function's own module header). Selection-UI is P4 scope (task instruction)
 * — this action is the complete, callable, RLS-scoped entry point a future
 * UI (or this PR's own tests) drives; both file ids must already be
 * `qaf_file` rows the caller can see under RLS (no separate ownership check
 * beyond the RLS-scoped `createClient()` queries below — same "ownership
 * enforced by the qaf_* _own policies" discipline this whole file already
 * follows, see module header).
 *
 * `comparison_mode` is an unconstrained TEXT column (supabase-migration-qaf-
 * differences.sql — no CHECK, no migration needed for a new value, same
 * "TEXT, kein Constraint" fact `runMultiQafCompareFlow`'s own wiring above
 * already relies on for `'multi_qaf'`). The selected `variantId` is persisted
 * in `engine_version.selectedVariantId` (same "misc JSONB bag on the one
 * column that already exists" discipline `variantMatchOverrides` already
 * established for the sibling MQ<->MQ mode) since `qaf_comparison` has no
 * dedicated variant column.
 */
export async function createVariantVsStandardComparison(
  projectId: string,
  multiQafFileId: string,
  variantId: string,
  standardFileId: string,
): Promise<ActionResult<{ comparisonId: string }>> {
  if (!uuidSchema.safeParse(projectId).success) return { ok: false, error: 'invalid project id' }
  if (!uuidSchema.safeParse(multiQafFileId).success) return { ok: false, error: 'invalid multiQaf file id' }
  if (!uuidSchema.safeParse(standardFileId).success) return { ok: false, error: 'invalid standard file id' }
  if (variantId.trim() === '') return { ok: false, error: 'invalid variant id' }

  const supabase = await createClient()
  const { data: claims } = await supabase.auth.getClaims()
  if (!claims?.claims) return { ok: false, error: 'unauthorized' }
  const userId = claims.claims.sub as string

  const { data: mqFile, error: mqFileErr } = await supabase
    .from('qaf_file')
    .select('id, g60_meta, template_type')
    .eq('id', multiQafFileId)
    .eq('project_id', projectId)
    .maybeSingle()
  if (mqFileErr) return { ok: false, error: `Multi-QAF-Datei-Ladefehler: ${mqFileErr.message}` }
  if (!mqFile) return { ok: false, error: 'Multi-QAF-Datei nicht gefunden (oder kein Zugriff).' }
  if (mqFile.template_type !== 'MULTI_QAF') return { ok: false, error: 'Ausgewählte Datei ist keine Multi-QAF-Datei.' }

  const containerRaw = (mqFile.g60_meta as { multiQafContainer?: unknown } | null)?.multiQafContainer
  if (!containerRaw) return { ok: false, error: 'qaf_file.g60_meta.multiQafContainer fehlt — Container wurde nicht assembliert.' }

  let container: MultiQafContainer
  try {
    container = deserializeMultiQafContainer(JSON.stringify(containerRaw))
  } catch (e) {
    return { ok: false, error: `Multi-QAF-Container konnte nicht deserialisiert werden: ${e instanceof Error ? e.message : String(e)}` }
  }

  const standardLoad = await loadStandardQafFileForVariantCompare(supabase, projectId, standardFileId)
  if (!standardLoad.ok) return { ok: false, error: standardLoad.error }

  let result: VariantVsStandardComparisonResult
  try {
    result = runVariantVsStandardCompare(container, variantId, standardLoad.parsed)
  } catch (e) {
    return { ok: false, error: e instanceof Error ? e.message : String(e) }
  }

  const comparisonId = crypto.randomUUID()
  const baselineStatus = result.reviewRequired ? 'baseline_review' : 'ok'
  const { error: insertErr } = await supabase.from('qaf_comparison').insert({
    id: comparisonId,
    project_id: projectId,
    part_number: null,
    baseline_file_id: standardFileId,
    comparison_file_id: multiQafFileId,
    comparison_mode: 'multi_qaf_variant_vs_standard',
    engine_version: {
      ...ENGINE_VERSION,
      baselineStatus,
      selectedVariantId: variantId,
      variantVsStandardResult: { modelVersion: MULTI_QAF_VARIANT_VS_STANDARD_RESULT_VERSION, result },
    },
    baseline_status: baselineStatus,
    status: 'draft',
    created_by: userId,
  })
  if (insertErr) return { ok: false, error: `qaf_comparison insert failed: ${insertErr.message}` }

  const { error: auditErr } = await supabase.from('qaf_audit_log').insert({
    project_id: projectId,
    action: 'analyze',
    entity: 'qaf_comparison',
    entity_id: comparisonId,
    detail: { mode: 'multi_qaf_variant_vs_standard', variantId, reviewRequired: result.reviewRequired, by: userId },
  })
  if (auditErr) logger.error('qaf.createVariantVsStandardComparison audit insert failed', { comparisonId, error: auditErr.message })

  return { ok: true, data: { comparisonId } }
}

// ── Variant-vs-standard: selection-flow read actions (KAR-948) ─────────────
//
// createVariantVsStandardComparison above was the complete, callable P3
// (KAR-943) entry point — its own doc comment explicitly deferred the
// selection UI to "a future UI (P4)". These three read-only actions are that
// UI's data source: (1) which Multi-QAF containers exist in this project to
// pick a variant FROM, (2) once one is picked, that container's variant list
// (dimensions/volumes/parsing-confidence/active-state — task requirement),
// and (3) which files in this project are STANDARD QAFs to compare the
// chosen variant AGAINST. All three are read-only, RLS-scoped exactly like
// every other query in this file (no extra ownership check beyond the
// RLS-scoped createClient() queries — same discipline
// createVariantVsStandardComparison's own doc comment already documents).

/** The `qaf_file.template_type` vocabulary a STANDARD (non-G60, non-Multi-
 * QAF) QAF actually persists — `SummaryTemplateType` (summary-metrics.ts),
 * written verbatim by ingestQafUpload's `template_type: summaryMetrics?.
 * template ?? null` (the 'summary'-kind branch — see that insert). This is
 * an ALLOW-list (not "everything that isn't G60/MULTI_QAF"), and deliberately
 * excludes a NULL template_type (a file whose summary sheet failed to parse
 * into any recognized template at all) — task requirement: "nur files mit
 * qaf_type standard anbieten — nutze die persistierte Typ-Detection, NIE
 * Dateinamen". A file with no confidently-detected template is not a
 * confirmed standard QAF, so it is not offered here (the user can still
 * reach it via the ordinary G60/Summary comparison flows, which tolerate a
 * null template_type differently — this picker just doesn't surface it as a
 * "standard QAF" option for THIS flow). */
const STANDARD_QAF_TEMPLATE_TYPES = ['QAF_LEGACY_DE_SUMMARY', 'QAF_V9_SUMMARY'] as const

export interface MultiQafContainerFileOption {
  id: string
  fileName: string
  createdAt: string | null
}

/** Lists this project's persisted Multi-QAF container files (`template_type
 * = 'MULTI_QAF'`) — step 1 of the variant-vs-standard creation dialog. Does
 * NOT deserialize/read `g60_meta` (kept cheap for a project with many
 * containers) — `getMultiQafVariantOptions` below does that lazily, only for
 * the ONE container the user actually selects. */
export async function listMultiQafContainerFiles(projectId: string): Promise<ActionResult<MultiQafContainerFileOption[]>> {
  if (!uuidSchema.safeParse(projectId).success) return { ok: false, error: 'invalid project id' }
  const supabase = await createClient()
  const { data: claims } = await supabase.auth.getClaims()
  if (!claims?.claims) return { ok: false, error: 'unauthorized' }

  const { data, error } = await supabase
    .from('qaf_file')
    .select('id, original_file_name, created_at')
    .eq('project_id', projectId)
    .eq('template_type', 'MULTI_QAF')
    .order('created_at', { ascending: false })
  if (error) {
    return {
      ok: false,
      error: `Multi-QAF-Dateiliste konnte nicht geladen werden: ${error.message} / Multi-QAF file list could not be loaded: ${error.message}`,
    }
  }
  return {
    ok: true,
    data: (data ?? []).map((f) => ({
      id: f.id as string,
      fileName: f.original_file_name as string,
      createdAt: f.created_at as string | null,
    })),
  }
}

export interface StandardQafFileOption {
  id: string
  fileName: string
  partNumber: string | null
  createdAt: string | null
}

/** Lists this project's confirmed STANDARD QAF files (see
 * STANDARD_QAF_TEMPLATE_TYPES doc above) — step 3 of the variant-vs-standard
 * creation dialog ("Auswahl eines Standard-QAF-Files desselben Projekts").
 * Never filters/matches by file NAME — the allow-list above is the whole
 * filter. */
export async function listStandardQafFilesForProject(projectId: string): Promise<ActionResult<StandardQafFileOption[]>> {
  if (!uuidSchema.safeParse(projectId).success) return { ok: false, error: 'invalid project id' }
  const supabase = await createClient()
  const { data: claims } = await supabase.auth.getClaims()
  if (!claims?.claims) return { ok: false, error: 'unauthorized' }

  const { data, error } = await supabase
    .from('qaf_file')
    .select('id, original_file_name, part_number_from_content, created_at')
    .eq('project_id', projectId)
    .in('template_type', STANDARD_QAF_TEMPLATE_TYPES)
    .order('created_at', { ascending: false })
  if (error) {
    return {
      ok: false,
      error: `Standard-QAF-Dateiliste konnte nicht geladen werden: ${error.message} / Standard QAF file list could not be loaded: ${error.message}`,
    }
  }
  return {
    ok: true,
    data: (data ?? []).map((f) => ({
      id: f.id as string,
      fileName: f.original_file_name as string,
      partNumber: f.part_number_from_content as string | null,
      createdAt: f.created_at as string | null,
    })),
  }
}

export interface MultiQafContainerVariantOptions {
  /** container.templateFingerprint.family — context for the picker header,
   * same field ContainerOverview (qaf-multi-qaf-detail.tsx) already shows. */
  family: string
  confidence: number
  /** container.auxiliaryScenarios.length + container.helperColumns.length —
   * informational-only count (task requirement "aktiv/aux"); these columns
   * are ColumnClassification, not VariantDefinition, and are structurally
   * never selectable for a variant-vs-standard compare (Master-Prompt §15
   * Szenario B compares ONE variant, never an auxiliary/helper column). */
  auxiliaryCount: number
  /** Both active AND inactive/reserved variants (allMultiQafContainerVariants
   * — same helper qaf-multi-qaf-detail.tsx already uses) — the picker UI
   * shows every variant with its activeState so the user understands WHY an
   * inactive one is disabled (runVariantVsStandardCompare throws for a
   * non-active variantId), rather than silently omitting it as if it did not
   * exist in the file at all. */
  variants: readonly VariantDefinition[]
}

/** Loads ONE Multi-QAF container's variant list — step 2 of the
 * variant-vs-standard creation dialog, called lazily once the user picks a
 * container in step 1 (never eagerly for every container in the project).
 * Same load-and-deserialize shape as createVariantVsStandardComparison
 * above (this file's established, not-abstracted-away pattern — see that
 * function's own doc + the sibling recompareComparison branch). */
export async function getMultiQafVariantOptions(projectId: string, fileId: string): Promise<ActionResult<MultiQafContainerVariantOptions>> {
  if (!uuidSchema.safeParse(projectId).success) return { ok: false, error: 'invalid project id' }
  if (!uuidSchema.safeParse(fileId).success) return { ok: false, error: 'invalid file id' }
  const supabase = await createClient()
  const { data: claims } = await supabase.auth.getClaims()
  if (!claims?.claims) return { ok: false, error: 'unauthorized' }

  const { data: mqFile, error: mqFileErr } = await supabase
    .from('qaf_file')
    .select('id, g60_meta, template_type')
    .eq('id', fileId)
    .eq('project_id', projectId)
    .maybeSingle()
  if (mqFileErr) return { ok: false, error: `Multi-QAF-Datei-Ladefehler: ${mqFileErr.message}` }
  if (!mqFile) return { ok: false, error: 'Multi-QAF-Datei nicht gefunden (oder kein Zugriff). / Multi-QAF file not found (or no access).' }
  if (mqFile.template_type !== 'MULTI_QAF') {
    return { ok: false, error: 'Ausgewählte Datei ist keine Multi-QAF-Datei. / Selected file is not a Multi-QAF file.' }
  }

  const containerRaw = (mqFile.g60_meta as { multiQafContainer?: unknown } | null)?.multiQafContainer
  if (!containerRaw) {
    return {
      ok: false,
      error: 'qaf_file.g60_meta.multiQafContainer fehlt — Container wurde nicht assembliert. / Container was not assembled.',
    }
  }
  let container: MultiQafContainer
  try {
    container = deserializeMultiQafContainer(JSON.stringify(containerRaw))
  } catch (e) {
    const msg = e instanceof Error ? e.message : String(e)
    return { ok: false, error: `Multi-QAF-Container konnte nicht deserialisiert werden: ${msg}` }
  }

  return {
    ok: true,
    data: {
      family: container.templateFingerprint.family,
      confidence: container.confidence,
      auxiliaryCount: container.auxiliaryScenarios.length + container.helperColumns.length,
      variants: allMultiQafContainerVariants(container),
    },
  }
}
