'use server'
// Loop 10 (actions.ts zerlegen, Plan Schritt B): die Replace/Reparse-Familie
// (KAR-845 Teil 2 + Loop 5 Teil 2). Verantwortung: eine Datei eines
// Vergleichs ersetzen bzw. aus der aufbewahrten Originalquelle neu einlesen —
// Ingest-Semantik kommt aus ingest-core (Plan Schritt A), der Recompare
// bleibt in actions.ts und wird serverseitig direkt aufgerufen.
//
// Block wortgleich aus actions.ts verschoben (Muster #490–#496): Importer
// direkt umgestellt, keine Re-Exports, uuidSchema lokal. JSDoc/Kommentarköpfe
// sind MIT den Funktionen gewandert (Naht-Lehren #494/#496).

import { z } from 'zod'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { logger } from '@/lib/logger'
import {
  normalizeComparisonMode,
  resolveReplaceMultiQafDetectionConfig,
  COMPARISON_MODE_RULES,
  type FieldMappingOverride,
  type RowIdentity,
} from '@/lib/qaf-differences'
import { QAF_UPLOAD_BUCKET } from '@/components/qaf-differences/qaf-upload-constants'
import { ingestQafUpload } from './ingest-core'
import { qafStoragePathShape, sanitizeStorageName } from './storage-path-shape'
import { recompareComparison, type ActionResult } from '@/app/qaf-differences/actions'

// Identische Prüfung wie in actions.ts — bewusst lokal (siehe #490).
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')

// ── Operation-Lock je Vergleich+Rolle (Loop 5, Parallelitäts-Mitigation) ──
//
// replaceComparisonFile patcht die Datei-Zeiger last-write-wins; zwei
// gleichzeitige Replace/Reparse-Aufrufe derselben Rolle können Zeiger und
// abgeleitete Zeilen verschränken (benannter Befund, Silent-Failure-Review
// 11.08.). Mutex-Primitiv ist die Tabelle qaf_operation_lock (Migration
// supabase-migration-qaf-operation-lock): Erwerb über den PK-Konflikt,
// Übernahme verwaister Locks über einen locked_at-TTL-CAS, Freigabe über
// delete mit locked_by-Gleichheit. Ein Postgres-Advisory-Lock scheidet aus
// (xact-Lock hält nur eine Transaktion, der Flow ist Multi-Roundtrip;
// Session-Locks scheitern am PostgREST-Pooling). Die Helfer sind bewusst
// NICHT exportiert — jeder Wert-Export dieser 'use server'-Datei wäre ein
// RPC-Endpunkt (Muster #490).
const OPERATION_LOCK_STALE_MS = 15 * 60 * 1000
// Tabelle (noch) nicht vorhanden: 42P01 = Postgres undefined_table,
// PGRST205 = PostgREST "table not found in schema cache". Bis Migration
// #121 appliedt ist, degradiert der Flow ehrlich auf das Verhalten ohne
// Lock — deshalb hat diese Migration KEINE Apply-vor-Deploy-Pflicht.
const OPERATION_LOCK_TABLE_MISSING_CODES = new Set(['42P01', 'PGRST205'])

type OperationLockResult =
  | { state: 'acquired'; opId: string }
  | { state: 'unavailable' }
  | { state: 'busy'; error: string }

async function acquireOperationLock(
  supabase: Awaited<ReturnType<typeof createClient>>,
  args: { comparisonId: string; role: 'alt' | 'neu'; projectId: string; operation: string },
): Promise<OperationLockResult> {
  // Zufällige Operations-ID statt auth.uid(): derselbe Nutzer in zwei Tabs
  // darf sich nicht gegenseitig entsperren.
  const opId = crypto.randomUUID()
  const row = {
    comparison_id: args.comparisonId,
    role: args.role,
    project_id: args.projectId,
    operation: args.operation,
    locked_by: opId,
    locked_at: new Date().toISOString(),
  }
  const { data: inserted, error: insErr } = await supabase
    .from('qaf_operation_lock')
    .upsert(row, { onConflict: 'comparison_id,role', ignoreDuplicates: true })
    .select('locked_by')
  if (insErr) {
    if (OPERATION_LOCK_TABLE_MISSING_CODES.has(insErr.code)) {
      logger.warn('qaf.replace operation lock unavailable (migration not applied) — proceeding unlocked', {
        comparisonId: args.comparisonId,
      })
      return { state: 'unavailable' }
    }
    // Fail-closed: ein Lock-Infrastrukturfehler blockiert die Operation mit
    // benanntem Grund, statt ungeschützt weiterzulaufen.
    return { state: 'busy', error: `Sperre nicht erwerbbar: ${insErr.message}` }
  }
  if (inserted.length > 0) return { state: 'acquired', opId }

  // Lock vergeben — verwaiste Locks (abgestürzter Flow) nach TTL übernehmen.
  // CAS über lt(locked_at): genau ein Konkurrent gewinnt die Übernahme.
  const cutoff = new Date(Date.now() - OPERATION_LOCK_STALE_MS).toISOString()
  const { data: taken, error: takeErr } = await supabase
    .from('qaf_operation_lock')
    .update({ locked_by: opId, locked_at: new Date().toISOString(), operation: args.operation })
    .eq('comparison_id', args.comparisonId)
    .eq('role', args.role)
    .lt('locked_at', cutoff)
    .select('locked_by')
  if (takeErr) return { state: 'busy', error: `Sperre nicht erwerbbar: ${takeErr.message}` }
  if (taken.length > 0) return { state: 'acquired', opId }
  return {
    state: 'busy',
    error:
      'Für diesen Stand läuft bereits ein Ersetzen bzw. Neu-Einlesen. Bitte warten, bis der laufende Vorgang abgeschlossen ist.',
  }
}

async function releaseOperationLock(
  supabase: Awaited<ReturnType<typeof createClient>>,
  comparisonId: string,
  role: 'alt' | 'neu',
  opId: string,
): Promise<void> {
  // locked_by-Gleichheit: nur das eigene Lock lösen — ein per TTL
  // übernommenes Lock gehört inzwischen dem Übernehmer.
  const { error } = await supabase
    .from('qaf_operation_lock')
    .delete()
    .eq('comparison_id', comparisonId)
    .eq('role', role)
    .eq('locked_by', opId)
  // Fehlgeschlagene Freigabe wird geloggt, nicht geworfen — das Lock
  // verfällt über den TTL-Takeover, das Operations-Ergebnis bleibt gültig.
  if (error) logger.error('qaf.replace lock release failed', { comparisonId, role, error: error.message })
}

// ── Replace one comparison file (KAR-845 part 2) ──────────────────────────────

/**
 * Replace the ALT or NEU file of a comparison with a newly uploaded workbook
 * (already in the qaf-uploads bucket): ingest → re-point the comparison →
 * summary comparisons recompute via recompareComparison. The previous
 * qaf_file row is kept (history + storage original).
 *
 * KAR-899: recompareComparison is called with `refreshPlausibility: true` —
 * swapping a FILE genuinely changes the fk_detail_sum/scrap-/material-/
 * sbm_detail_sum/Kaskaden/R-Regeln/Struktur findings, so the previously
 * persisted qaf_plausibility_issue rows (which describe the OLD, now
 * replaced, file) are refreshed alongside the other derived tables — before
 * this fix they were never refreshed at all, showing stale findings from a
 * file the user had already replaced.
 */
export async function replaceComparisonFile(
  comparisonId: string,
  role: 'alt' | 'neu',
  upload: { path: string; name: string },
  // Loop 5 (Review-Rest): ein Reparse lief bisher als 'replace_file' ins
  // Audit-Log und war von einem echten Datei-Tausch nicht unterscheidbar —
  // gleicher Dateiname, gleiche action. Der Aufrufer benennt jetzt die
  // Absicht; Default bleibt der Bestand (jeder existierende Aufrufer
  // unverändert). Kein DB-Constraint auf action (TEXT) — kein Migrationsbedarf.
  options?: { auditAction?: 'replace_file' | 'reparse_file' },
): 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) return { ok: false, error: 'Vergleich nicht gefunden oder kein Zugriff.' }

  if (!qafStoragePathShape(cmp.project_id as string).test(upload.path)) return { ok: false, error: 'invalid storage path' }

  // KAR-926 adversarial-review F1 fix (12.07.2026): unlike a brand-new upload
  // (analyzeQafBatchFromStorage), this comparison ALREADY EXISTS — so, unlike
  // the ingest-time g60StructureGuard gap KAR-894/P1.3 documented (no
  // comparison to consult an override FROM at that point), a persisted
  // per-comparison multiQafDetection override IS reachable here.
  //
  // KAR-925 adversarial-review F1 fix (13.07.2026): this call deliberately
  // does NOT use resolvePersistedEngineConfig(cmp.engine_version)
  // .multiQafDetection the way ruleEngine/reconciliation/g60StructureGuard/
  // differBands/matching/formulaEngine/businessRules do at compare/export-
  // reproduction time (rehydrate.ts) — that function's "sections not present
  // fall back to the CURRENT default" contract is correct for those sections
  // (and for live recompute in general, see recompareComparison), but
  // multiQafDetection.enabled flipping to `true` as the CURRENT default
  // (KAR-925) means the general fallback would silently activate the
  // detector for THIS replace on every pre-flip comparison that never
  // persisted an override (the common case, since no comparison persists
  // this override today) — exactly the "missing key silently upgrades an old
  // comparison to a stricter mode" rehydrate.ts's own invariant forbids.
  // resolveReplaceMultiQafDetectionConfig (rehydrate.ts) reads the
  // comparison's own configVersion stamp instead: `>= '1.4.0'` inherits
  // today's default (a genuine no-op for a comparison already computed under
  // it), otherwise pins `enabled: false` — see that function's doc for the
  // full reasoning.
  const replaceMultiQafDetectionConfig = resolveReplaceMultiQafDetectionConfig(cmp.engine_version)

  const admin = createAdminClient()
  let insertedFileId: string | null = null
  const compensate = async () => {
    if (insertedFileId) {
      const { error } = await supabase.from('qaf_file').delete().eq('id', insertedFileId)
      if (error) logger.error('qaf.replace compensate delete failed', { insertedFileId, error: error.message })
    }
    // The rejected upload's storage object must not orphan in the bucket.
    const { error: rmErr } = await admin.storage.from(QAF_UPLOAD_BUCKET).remove([upload.path])
    if (rmErr) logger.error('qaf.replace storage cleanup failed', { path: upload.path, error: rmErr.message })
  }

  // Operation-Lock je (comparisonId, role) — NACH den billigen Guards (uuid,
  // Zugriff, Pfad-Shape: Abweisungen ohne Lock-Verkehr), VOR dem teuren,
  // verschränkbaren Teil (Old-File-Load → Ingest → Zeiger-Patch → Recompare).
  // 'busy' bricht ehrlich ab, bevor irgendetwas geschrieben wurde; das
  // Upload-Objekt bleibt wie bei jedem frühen Guard-Abbruch liegen (der
  // Reparse-Wrapper räumt seine Kopie selbst nach).
  const lock = await acquireOperationLock(supabase, {
    comparisonId,
    role,
    projectId: cmp.project_id as string,
    operation: options?.auditAction ?? 'replace_file',
  })
  if (lock.state === 'busy') return { ok: false, error: lock.error }

  try {
    // KAR-912/P4.2: load the file THIS role currently points at, so any manual
    // field-mapping overrides the user set on it survive the replace — a
    // supplier resubmission is "the same logical file, corrected", so a
    // Positionsnummer-keyed override should keep applying to its successor
    // (see field-mapping-override.ts module header). A comparison with no file
    // yet on this role (first upload via replaceComparisonFile, e.g. filling a
    // one-sided draft) simply carries forward nothing.
    //
    // KAR-912 adversarial-review F1 fix (PR #291, Sev 85): the OLD file's row
    // identities (position_number/process_name — dedicated qaf_manufacturing_step
    // columns, no raw_values fetch needed) are loaded ALONGSIDE the overrides
    // so ingestQafUpload can run carryForwardFieldMappingOverrides' identity
    // guard against the freshly parsed NEW rows, instead of blindly trusting
    // that a repeated Positionsnummer still means the same process step (a BMW // allow-customer-string
    // re-quote can renumber Positionsnummer assignments on resubmission).
    const oldFileIdForRole = role === 'alt' ? (cmp.baseline_file_id as string | null) : (cmp.comparison_file_id as string | null)
    let existingFieldMappingOverrides: FieldMappingOverride[] | undefined
    let carryForwardOldRowIdentities: RowIdentity[] = []
    if (oldFileIdForRole) {
      const [oldFileRes, oldStepsRes] = await Promise.all([
        supabase.from('qaf_file').select('g60_meta').eq('id', oldFileIdForRole).maybeSingle(),
        supabase.from('qaf_manufacturing_step').select('position_number, process_name').eq('file_id', oldFileIdForRole),
      ])
      if (oldFileRes.error) return { ok: false, error: `old file load failed: ${oldFileRes.error.message}` }
      if (oldStepsRes.error) return { ok: false, error: `old file steps load failed: ${oldStepsRes.error.message}` }
      const oldOverrides = (oldFileRes.data?.g60_meta as { fieldMappingOverrides?: FieldMappingOverride[] } | null)
        ?.fieldMappingOverrides
      if (oldOverrides?.length) existingFieldMappingOverrides = oldOverrides
      carryForwardOldRowIdentities = (oldStepsRes.data ?? []).map((r) => ({
        positionsnummer: r.position_number as string | null,
        prozessbezeichnung: r.process_name as string | null,
      }))
    }

    // Part upsert is deferred until the business guards below passed —
    // a rejected wrong-part file must not touch shared identity data.
    const ingested = await ingestQafUpload(
      supabase,
      admin,
      {
        projectId: cmp.project_id as string,
        upsertPart: false,
        existingFieldMappingOverrides,
        carryForwardOldRowIdentities,
        multiQafDetectionConfig: replaceMultiQafDetectionConfig,
      },
      upload,
      (id) => {
        insertedFileId = id
      },
    )

    // KAR-943 adversarial-review F2 fix (13.07.2026): reads the expected file
    // kind for THIS role from the central COMPARISON_MODE_RULES registry
    // instead of an ad-hoc, mode-count-many if-chain. KAR-942 adversarial-
    // review F1 already fixed the binary isG60Comparison-only version of this
    // bug for 'multi_qaf' (every non-G60 comparison used to fall into the
    // Summary branch, so a correctly-uploaded MULTI_QAF replacement file was
    // always rejected AND deleted via compensate()); this PR's own F2 found
    // the SAME bug for 'multi_qaf_variant_vs_standard' — its container role
    // (role 'neu', comparison_file_id) needs `kind: 'multi_qaf'`, not
    // `'summary'`, and its standard role (role 'alt', baseline_file_id) needs
    // `kind: 'summary'`. The registry-driven `roleRule` below gets this right
    // per role AND makes a FUTURE mode's guard a registry entry, not a fifth
    // copy of this if-chain.
    const normalizedMode = normalizeComparisonMode(cmp.comparison_mode as string | null)
    const modeRule = COMPARISON_MODE_RULES[normalizedMode]
    const roleRule = role === 'alt' ? modeRule.alt : modeRule.neu
    // KAR-993: Mengen- statt Einzelvergleich, weil 'supplier_benchmark' je
    // Rolle mehrere Dateiformate zulässt (Multi-QAF ODER Summary). Für die
    // vier Vorgänger-Modi mit genau einem Eintrag ist das verhaltensgleich.
    if (!roleRule.expectedKinds.includes(ingested.kind)) {
      await compensate()
      return { ok: false, error: roleRule.wrongKindError }
    }
    // Part-number-must-match guard only makes sense for a role that actually
    // expects a Summary-shaped upload with its own partNumber (g60/multi_qaf
    // container roles have none; 'multi_qaf_variant_vs_standard' never has a
    // `cmp.part_number` to begin with — see createVariantVsStandardComparison
    // — so this is a no-op for that mode regardless, kept mode-agnostic via
    // roleRule for correctness rather than relying on that coincidence).
    // KAR-993: liest das explizite Registry-Flag statt aus dem erwarteten
    // Dateityp zu schließen. Die alte Ableitung (`expectedKind === 'summary'`)
    // hätte für 'supplier_benchmark' genau falsch gegriffen: der Modus nimmt
    // Summary-Dateien an, vergleicht aber absichtlich Angebote für
    // VERSCHIEDENE Teile — eine Sachnummern-Gleichheit zu fordern würde den
    // Zweck des Modus abweisen. Für die vier Vorgänger-Modi sind die
    // Flag-Werte 1:1 aus der alten Bedingung übernommen, also verhaltensgleich.
    if (roleRule.partNumberMustMatch && cmp.part_number && ingested.summary?.partNumber.value !== cmp.part_number) {
      await compensate()
      return {
        ok: false,
        error: `Sachnummer weicht ab: Datei hat "${ingested.summary?.partNumber.value ?? '—'}", der Vergleich gehört zu "${cmp.part_number}".`,
      }
    }

    // Guards passed — now persist the part identity (skipped during ingest).
    const contentPn = ingested.summary?.partNumber.value ?? null
    if (ingested.kind === 'summary' && contentPn) {
      const { error: partErr } = await supabase.from('qaf_part').upsert(
        {
          project_id: cmp.project_id,
          part_number: contentPn,
          part_name: ingested.summary!.partName.value,
          variant: ingested.summary!.variant.value,
          supplier: ingested.summary!.supplier.value,
          quotation_date: ingested.summary!.quotationDate.value
            ? ingested.summary!.quotationDate.value.slice(0, 10)
            : null,
          version: ingested.summary!.requestVersion.value,
        },
        { onConflict: 'project_id,part_number' },
      )
      if (partErr) {
        await compensate()
        return { ok: false, error: `qaf_part upsert failed: ${partErr.message}` }
      }
    }

    // Re-read the pointers right before patching — the ingest above is slow
    // and a concurrent recompare/swap may have moved them.
    const { data: fresh, error: freshErr } = await supabase
      .from('qaf_comparison')
      .select('baseline_file_id, comparison_file_id, part_number')
      .eq('id', comparisonId)
      .maybeSingle()
    if (freshErr || !fresh) {
      await compensate()
      return { ok: false, error: 'comparison re-read failed' }
    }
    const prevAlt = fresh.baseline_file_id as string | null
    const prevNeu = fresh.comparison_file_id as string | null
    const patch: Record<string, unknown> =
      role === 'alt' ? { baseline_file_id: ingested.fileId } : { comparison_file_id: ingested.fileId }
    // Data changed → a previous 'reviewed' sign-off no longer applies.
    patch.status = 'draft'
    // Backfill the part number when the comparison never had one — the
    // recompare's qaf_part join depends on it.
    if (!fresh.part_number && contentPn) patch.part_number = contentPn
    const { error: updErr } = await supabase.from('qaf_comparison').update(patch).eq('id', comparisonId)
    if (updErr) {
      await compensate()
      return { ok: false, error: `comparison update failed: ${updErr.message}` }
    }

    if (normalizedMode !== 'g60') {
      // refreshPlausibility: true (KAR-899) — see this function's doc comment
      // (a no-op for the multi_qaf/multi_qaf_variant_vs_standard branches,
      // which have no plausibility channel). KAR-942 F1 fix: this call
      // already routes multi_qaf comparisons into recompareComparison's own
      // multi_qaf branch (rehydrate both containers off the newly-replaced
      // file, re-run runMultiQafCompareFlow, carry forward
      // VariantMatchOverrides through its identity/drift guard); KAR-943 F2
      // fix: the file-kind guard above now actually lets a valid
      // 'multi_qaf_variant_vs_standard' replacement (either role) reach this
      // call at all, which routes it into recompareComparison's own
      // 'multi_qaf_variant_vs_standard' branch (re-run
      // runVariantVsStandardCompare for the persisted selectedVariantId) — no
      // separate invocation needed here, only the file-kind guard above had
      // to learn about each mode.
      const rec = await recompareComparison(comparisonId, { pins: [], refreshPlausibility: true })
      if (!rec.ok) {
        // Roll the pointer back so the comparison stays consistent with its
        // (still present) derived rows, then drop the new file row.
        await supabase
          .from('qaf_comparison')
          .update({ baseline_file_id: prevAlt, comparison_file_id: prevNeu })
          .eq('id', comparisonId)
        await compensate()
        return { ok: false, error: `Neu-Berechnung fehlgeschlagen: ${rec.error}` }
      }
    }

    // Committed — an audit-insert failure must not tell the user to retry.
    const { error: auditErr } = await supabase.from('qaf_audit_log').insert({
      project_id: cmp.project_id,
      action: options?.auditAction ?? 'replace_file',
      entity: 'qaf_comparison',
      entity_id: comparisonId,
      detail: { role, newFileId: insertedFileId, fileName: upload.name, by: userId },
    })
    if (auditErr) logger.error('qaf.replace audit insert failed', { comparisonId, error: auditErr.message })
    return { ok: true, data: null }
  } catch (e) {
    await compensate()
    return { ok: false, error: e instanceof Error ? e.message : 'replace failed' }
  } finally {
    // Deckt Happy-Path, jeden Guard-Abbruch und jeden Wurf — das Lock darf
    // nie über das Operations-Ende hinaus leben. 'unavailable' (Tabelle
    // fehlt) hat nichts zu lösen.
    if (lock.state === 'acquired') await releaseOperationLock(supabase, comparisonId, role, lock.opId)
  }
}

/**
 * Loop 5 Teil 2 (SRC-001 „Reparse jederzeit möglich"): einen Stand des
 * Vergleichs aus seiner aufbewahrten Originaldatei NEU parsen — mit dem
 * aktuellen Parser, inklusive frischer Zell-Provenienz (sheet/formula/
 * value_state) und damit mehr kanonischen Facts (Etappe 2a).
 *
 * Bewusst KEIN eigener Parse-/Persist-Pfad: Reparse ist ein
 * replaceComparisonFile mit einer BUCKET-KOPIE der eigenen Originaldatei.
 * Dadurch erbt er die komplette Schutz-Mechanik (Parse vor jedem
 * DB-Schreiben, Kind-/Sachnummer-Guards, Override-Carry-Forward,
 * insert-first-Rollback, refreshPlausibility:true) statt sie als zweite
 * Wahrheit zu duplizieren.
 *
 * Warum eine Kopie statt des Original-Pfads: replaceComparisonFile
 * behandelt upload.path als frisch hochgeladenes Objekt und RÄUMT ES BEI
 * FEHLERN WEG (compensate → storage.remove). Mit dem Original-Pfad würde
 * ein fehlgeschlagener Reparse die Originaldatei löschen — die Kopie
 * (eigener UUID-Pfad, wie jeder Upload) hält das Original unantastbar und
 * gibt dem neuen Parse-Stand einen eigenen storage_key (keine
 * Lifecycle-Kopplung zweier qaf_file-Zeilen an ein Objekt). Die alte
 * qaf_file-Zeile bleibt wie beim Replace als nachvollziehbare Historie
 * stehen.
 */
export async function reparseComparisonFile(
  comparisonId: string,
  role: 'alt' | 'neu',
): 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 { data: cmp, error: cmpErr } = await supabase
    .from('qaf_comparison')
    .select('id, project_id, baseline_file_id, comparison_file_id, comparison_mode')
    .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.' }

  // G60 und Multi-QAF sind deferred_by_product_owner — Reparse gibt es nur
  // für den Summary-Paarvergleich (dieselbe Grenze wie beim QVS-Einstieg).
  if (normalizeComparisonMode(cmp.comparison_mode) !== 'summary') {
    return { ok: false, error: 'Neu einlesen ist nur für Summary-Vergleiche verfügbar.' }
  }

  const fileId = role === 'alt' ? (cmp.baseline_file_id as string | null) : (cmp.comparison_file_id as string | null)
  if (fileId === null) return { ok: false, error: 'Dieser Stand hat keine Datei.' }

  try {
    const { data: file, error: fileErr } = await supabase
      .from('qaf_file')
      .select('id, storage_key, original_file_name')
      .eq('id', fileId)
      .maybeSingle()
    if (fileErr) return { ok: false, error: `file load failed: ${fileErr.message}` }
    if (!file) return { ok: false, error: 'Datei nicht gefunden oder kein Zugriff.' }
    if (file.storage_key === null) {
      return { ok: false, error: 'Originalquelle nicht hinterlegt (Upload vor der Quellen-Aufbewahrung) — Neu einlesen nicht möglich.' }
    }
    // Confused-Deputy-Guard wie an jeder Signier-/Download-Stelle: der
    // Admin-Client kopiert nur Objekte, deren Pfad zur Projekt-Form gehört.
    if (!qafStoragePathShape(cmp.project_id as string).test(file.storage_key)) {
      logger.error('qaf.reparse storage_key path mismatch', undefined, {
        comparisonId,
        fileId,
        storageKey: file.storage_key,
      })
      return { ok: false, error: 'Quellpfad gehört nicht zu diesem Projekt — Neu einlesen abgelehnt.' }
    }

    const admin = createAdminClient()
    const copyPath = `${cmp.project_id}/${crypto.randomUUID()}-${sanitizeStorageName(file.original_file_name)}`
    const { error: copyErr } = await admin.storage.from(QAF_UPLOAD_BUCKET).copy(file.storage_key, copyPath)
    if (copyErr) {
      return { ok: false, error: `Original nicht kopierbar (${copyErr.message}) — alter Stand bleibt unverändert.` }
    }

    const result = await replaceComparisonFile(comparisonId, role, { path: copyPath, name: file.original_file_name }, { auditAction: 'reparse_file' })
    if (!result.ok) {
      // replaceComparisonFile räumt die Kopie in seinen compensate-Pfaden
      // weg; bei frühen Guard-Abbrüchen bleibt sie liegen — hier nachräumen.
      // Trifft dieses remove ein schon entferntes Objekt, wird der Fehler
      // als warn geloggt und ändert das zurückgegebene Ergebnis nicht —
      // der Nutzer sieht immer den echten replace-Fehler.
      const { error: rmErr } = await admin.storage.from(QAF_UPLOAD_BUCKET).remove([copyPath])
      if (rmErr) logger.warn('qaf.reparse copy cleanup skipped', { copyPath, error: rmErr.message })
    }
    return result
  } catch (e) {
    // SDK-Schichten (storage-js/auth-js) werfen für unerwartete Fehlerklassen
    // statt {error} zu liefern; ohne Boundary unter app/qaf-differences würde
    // das die ganze Seite ersetzen. Das Original ist in jedem Wurf-Zeitpunkt
    // unangetastet (copy liest nur, remove zielt nur auf die Kopie).
    return { ok: false, error: e instanceof Error ? e.message : 'Neu einlesen fehlgeschlagen.' }
  }
}
