// Loop 10 (actions.ts zerlegen, Schnitt 5 / Plan Schritt A): der Ingest-Kern.
// ingestQafUpload + seine NUR-Ingest-Helfer (IngestResult,
// approxManufacturingHeaderRow, partNumberFromFilename, PARSER_VERSION),
// wortgleich aus actions.ts verschoben. BEWUSST KEINE 'use server'-Datei:
// der Kern ist kein RPC-Endpunkt — beide Aufrufer (analyzeQafBatchFromStorage,
// replaceComparisonFile) reichen ihre Clients als Parameter herein.
// Einzige gewollte Abweichung vom Original: export-Präfix an ingestQafUpload
// und IngestResult (vorher datei-privat).

// 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 { createHash } from 'node:crypto'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import {
  buildManufacturingStepRow,
  buildSummaryMetricRows,
  detectG60,
  parseG60WorkbookGuarded,
  g60ExtractFullTab,
  g60WorkbookFromExcelJs,
  buildG60Rows,
  loadExcelWorkbook,
  parseSummarySheetFromWorkbook,
  summarizeWorkbookSheets,
  runPreParseWorkbookSafetyCheck,
  runPreParseLegacyBiffCheck,
  isLegacyBiffBuffer,
  evaluateSheetDimensions,
  type WorkbookSafetyResult,
  detectForeignForm,
  foreignFormWorkbookFromExcelJs,
  detectMultiQaf,
  multiQafRejectionMessage,
  multiQafDetectionInputFromExcelJs,
  multiQafDetectionMetaFragment,
  type MultiQafDetectionResult,
  resolveMultiQafContainerIngest,
  reviewStatusForMultiQafContainer,
  MULTI_QAF_MODEL_VERSION,
  buildTemplateFingerprint,
  summaryLocatedKeys,
  parseMaterialSheet,
  materialRowsForReconciliation,
  parseSbmSheet,
  sbmRowsForReconciliation,
  parseRmrSheet,
  rmrRowsForReconciliation,
  parseLogisticsSheet,
  logisticsRowsForReconciliation,
  parseLccnSheet,
  lccnForReconciliation,
  parseCo2eSheet,
  co2eMaterialRowsForReconciliation,
  ENGINE_VERSION,
  DEFAULT_ENGINE_CONFIG,
  type MultiQafDetectionConfig,
  type G60ComponentRow,
  type TemplateFingerprintResult,
  type MaterialRow,
  type MaterialFieldKey,
  type MaterialParseMeta,
  type SbmRow,
  type SbmFieldKey,
  type SbmParseMeta,
  type RmrRow,
  type RmrFieldKey,
  type RmrParseMeta,
  type LogisticsRow,
  type LccnFieldKey,
  type LccnSummaryValues,
  type PersistedLccnMeta,
  type Co2eFieldKey,
  type Co2eMaterialRow,
  type PersistedCo2eMeta,
  applyFieldMappingOverrides,
  carryForwardFieldMappingOverrides,
  type FieldMappingOverride,
  type RowIdentity,
  computeWorkbookCapabilityMatrix,
  type WorkbookCapabilityMatrix,
  manufacturingStepFieldCandidates,
  type FieldCandidate,
  detectCostAllocationFamily,
  extractCostAllocationMeta,
  buildCostAllocationCapabilityMatrix,
  resolveCell,
  type CostAllocationFamilyDetection,
  type CostAllocationMetaField,
  type CostAllocationMetaKey,
  detectNotAQaf,
  collectUnknownPattern,
  type NotAQafDetection,
} from '@/lib/qaf-differences'
import { parseQAFTemplate } from '@/lib/qaf-parser'
import { parseLocaleNumber } from '@/lib/qaf-differences'
import {
  QAF_UPLOAD_BUCKET,
  QAF_MAX_FILE_BYTES,
  isAllowedQafFileName,
} from '@/components/qaf-differences/qaf-upload-constants'
import type { QAFRow, QAFFieldKey, QAFParseResult } from '@/lib/qaf-parser'
import { logger } from '@/lib/logger'

// Single source of truth for the parser version (mirrors qaf_comparison.engine_version).
const PARSER_VERSION = ENGINE_VERSION.parser

export interface IngestResult {
  /** KAR-935/Multi-QAF-Programm P2.1: 'multi_qaf' — flag ON, confirmed/
   * probable detection, container assembly succeeded. The file is persisted
   * (visible, provenance-only) but takes NEITHER the G60 nor the summary
   * persistence path (no qaf_manufacturing_step/qaf_summary_metric rows, no
   * qaf_part upsert) — comparison is P2.3+ scope. */
  kind: 'g60' | 'summary' | 'multi_qaf'
  fileId: string
  fileName: string
  /** Summary only */
  stepIds: string[]
  summary?: ReturnType<typeof parseSummarySheetFromWorkbook>['summary']
  summaryMetrics?: ReturnType<typeof parseSummarySheetFromWorkbook>['summaryMetrics']
  steps?: QAFRow[]
  /** Fertigungskosten header-mapping diagnostics (KAR-893/P1.2) — lifted off
   * parseQAFTemplate's QAFParseResult meta, threaded through to QafFileParsed
   * below so compareQafPair can surface a parser_degraded_* plausibility
   * issue. Undefined for the G60 ingest path (no Fertigungskosten parse).
   * `ignoredCandidateSheets` (KAR-927/P0.2) is additive — undefined for
   * every parse where at most one MANUFACTURING-named sheet existed. */
  manufacturingParseMeta?: {
    parseConfidence: number
    unmappedHeaders: string[]
    mappedFieldCount: number
    ignoredCandidateSheets?: string[]
  }
  /**
   * MATERIAL rows (KAR-897/P1.6), tri-state — see QafFileParsed.materialRows
   * doc comment (compare.ts). `undefined` for the G60 ingest path (MATERIAL
   * is a summary/manufacturing-side sheet). For the summary ingest path:
   * `null` when the uploaded file has no MATERIAL sheet at all (or its
   * header was too degraded to trust); an array (possibly empty) otherwise.
   */
  materialRows?: MaterialRow[] | null
  /**
   * MATERIAL parser-candidate diagnostics (KAR-927/P0.2) — see
   * QafFileParsed.materialParseMeta doc comment (compare.ts). `undefined`
   * when no MATERIAL sheet was found at all (materialParsed null — nothing
   * to report), otherwise the ignoredCandidateSheets an ignored-candidate
   * MaterialParseResult carries.
   */
  materialParseMeta?: Pick<MaterialParseMeta, 'ignoredCandidateSheets'>
  /**
   * SBM-DEVICES-FWZ rows (KAR-898/P1.7), tri-state — see
   * QafFileParsed.sbmRows doc comment (compare.ts). `undefined` for the G60
   * ingest path. For the summary ingest path: `null` when the uploaded file
   * has no SBM sheet at all (or its header was too degraded to trust); an
   * array (possibly empty) otherwise.
   */
  sbmRows?: SbmRow[] | null
  /**
   * SBM parser-candidate diagnostics (KAR-927/P0.2) — see
   * QafFileParsed.sbmParseMeta doc comment (compare.ts). `undefined` when no
   * SBM sheet was found at all (sbmParsed null — nothing to report),
   * otherwise the ignoredCandidateSheets an ignored-candidate SbmParseResult
   * carries.
   */
  sbmParseMeta?: Pick<SbmParseMeta, 'ignoredCandidateSheets'>
  /**
   * RAW MATERIAL RISKS rows (KAR-902/P2.3), tri-state — see
   * QafFileParsed.rmrRows doc comment (compare.ts). `undefined` for the G60
   * ingest path. For the summary ingest path: `null` when the uploaded file
   * has no RMR sheet at all (or its header was too degraded to trust); an
   * array (possibly empty) otherwise.
   */
  rmrRows?: RmrRow[] | null
  /**
   * RMR parse-level diagnostics (KAR-902 follow-up fix) — see
   * QafFileParsed.rmrParseMeta doc comment (compare.ts). `undefined` when no
   * RMR sheet was found at all (rmrParsed null — nothing to report),
   * otherwise the RmrParseMeta an RmrParseResult carries (including
   * possibleUnparsedBlockRow).
   */
  rmrParseMeta?: RmrParseMeta
  /**
   * LOGISTICS&CUSTOM rows (KAR-903/P2.4), tri-state — see
   * QafFileParsed.logisticsRows doc comment (compare.ts). `undefined` for the
   * G60 ingest path. For the summary ingest path: `null` when the uploaded
   * file has no LOGISTICS sheet at all (or its header was too degraded to
   * trust); an array (possibly empty) otherwise.
   */
  logisticsRows?: LogisticsRow[] | null
  /**
   * LC-CN Zusammenfassungs-Record (KAR-904/P2.5), tri-state — see
   * QafFileParsed.lccnValues doc comment (compare.ts). `undefined` for the
   * G60 ingest path. For the summary ingest path: `null` when the uploaded
   * file has no LC-CN sheet at all (or it was too degraded to trust); the
   * parsed record otherwise.
   */
  lccnValues?: LccnSummaryValues | null
  /**
   * CO2e-Material rows (KAR-904/P2.5), tri-state — see
   * QafFileParsed.co2eMaterialRows doc comment (compare.ts). `undefined` for
   * the G60 ingest path. For the summary ingest path: `null` when the
   * uploaded file has no usable CO2e-Material row block at all; an array
   * (possibly empty) otherwise.
   */
  co2eMaterialRows?: Co2eMaterialRow[] | null
  /** KAR-895/P1.4 — computed once here, persisted into qaf_file.g60_meta.
   * templateFingerprint. Not currently consumed by the caller (the detail
   * page re-reads the persisted value from qaf_file instead of threading it
   * through the batch pipeline) — kept on the result for callers/tests that
   * want it without a round-trip. */
  templateFingerprint?: TemplateFingerprintResult
  /**
   * Untrusted-Excel-Hardening pre-parse signals (KAR-914/P4.4) — see
   * QafFileParsed.workbookSafety doc comment (compare.ts). Set for BOTH the
   * G60 and the summary ingest path (computed before the kind is even known —
   * see runPreParseWorkbookSafetyCheck call below). BOTH paths also persist
   * it into qaf_file.g60_meta.workbookSafety (adversarial-review F3 fix,
   * 10.07.2026 — same rehydration-staleness fix KAR-899 applied to
   * manufacturingParseMeta): G60 comparisons have no qaf_plausibility_issue
   * channel at all (see g60_meta insert below), and the summary path's live
   * IngestResult is threaded into QafFileParsed for the FIRST compare, but a
   * later refreshPlausibility:true recompare rehydrates from g60_meta
   * instead (recompareComparison's sideOf() (actions.ts), actions.ts) — without also
   * persisting it there, a macro/external-link finding would silently
   * vanish on the file's first replace even though the file never changed.
   */
  workbookSafety: WorkbookSafetyResult
  /**
   * Multi-QAF type detection (KAR-926/Multi-QAF-Programm P0.1) — see
   * QafFileParsed.multiQafDetection doc comment (compare.ts). `null` ONLY
   * when the detector did not run at all (multiQafDetectionConfig.enabled
   * was false for this ingest — since KAR-925 (13.07.2026) that is no
   * longer the permanent default, see engine-config.ts's 1.4.0 note, but it
   * still happens per-comparison via resolveReplaceMultiQafDetectionConfig
   * on a replace, see that function's doc). When the detector DOES run, the
   * result is always a real (non-null) object, classification included —
   * `standard_qaf` is a genuine classification value, not a null/omitted
   * result; see multiQafDetectionMetaFragment (qaf-type-detector.ts) for how
   * that gets persisted. A non-null, non-'standard_qaf' result only ever
   * reaches persistence for 'ambiguous' OR (KAR-935/P2.1)
   * 'confirmed_multi_qaf'/'probable_multi_qaf' when `kind === 'multi_qaf'`
   * — see that field's own doc.
   */
  multiQafDetection: MultiQafDetectionResult | null
  /**
   * KAR-935/Multi-QAF-Programm P2.1 — set only when `kind === 'multi_qaf'`:
   * container assembly succeeded for a confirmed/probable Multi-QAF upload
   * (multiQafDetection.enabled === true). `variantCount` is
   * activeVariants.length + inactiveVariants.length, for the caller's
   * bilingual "N Varianten erkannt" message — never the full container (kept
   * out of the in-memory IngestResult; the caller reads the persisted
   * qaf_file.g60_meta.multiQafContainer instead, same "computed once, read
   * from g60_meta thereafter" discipline templateFingerprint/
   * manufacturingParseMeta already establish).
   */
  multiQafContainerSummary?: { variantCount: number; activeVariantCount: number }
}

/**
 * Best-effort manufacturing header-row estimate for the template fingerprint
 * (KAR-895/P1.4) — the lowest data-row sheet number carried in any parsed
 * step's `sourceCells` (KAR-886 provenance), minus 1. Descriptive metadata
 * only, never used for known/modified/unknown classification (see
 * template-fingerprint.ts TemplateFingerprintManufacturingInput). Returns
 * null when no step carries provenance (empty sheet, or rows from a source
 * that predates KAR-886 — not possible on the ingest path itself, but this
 * helper stays defensive since it reads the same optional field rehydrate.ts
 * treats as optional).
 */
function approxManufacturingHeaderRow(steps: QAFRow[]): number | null {
  let min: number | null = null
  for (const step of steps) {
    for (const cellRef of Object.values(step.sourceCells ?? {})) {
      const m = /(\d+)$/.exec(cellRef ?? '')
      if (!m) continue
      const rowNum = Number(m[1])
      if (min === null || rowNum < min) min = rowNum
    }
  }
  return min === null ? null : min - 1
}

/**
 * Load one uploaded workbook from storage, parse it (G60 or summary) and
 * persist its rows. Shared by batch analyze and file replace (KAR-845) so the
 * ingest semantics cannot drift. Throws on any failure — the caller owns the
 * insertedFileId compensation (qaf_file delete cascades to children).
 */
export async function ingestQafUpload(
  supabase: Awaited<ReturnType<typeof createClient>>,
  admin: ReturnType<typeof createAdminClient>,
  ctx: {
    projectId: string
    upsertPart?: boolean
    /** KAR-912/P4.2 — carried forward across a file replace (see
     * replaceComparisonFile (file-replace-actions.ts)): the OLD file's persisted
     * qaf_file.g60_meta.fieldMappingOverrides. NOT applied blindly — run
     * through carryForwardFieldMappingOverrides' identity guard (KAR-912
     * adversarial-review F1 fix, PR #291) against `carryForwardOldRowIdentities`
     * vs. the freshly parsed `steps` first, so an override only survives
     * onto the NEW file when the row at its Positionsnummer still
     * identifies the same process step (Prozessbezeichnung match) — a BMW // allow-customer-string
     * re-quote can renumber Positionsnummer assignments on resubmission,
     * and blindly trusting a repeated position number would risk silently
     * writing the OLD override's value onto an unrelated NEW row. The
     * (possibly status-updated) result is persisted verbatim into the NEW
     * file's own g60_meta so a later recompute keeps applying/reporting it
     * (recompareComparison's sideOf() (actions.ts)). Undefined for a brand-new upload —
     * there is nothing to carry forward for a file that never existed
     * before, so `fieldMappingOverrides: []` is written instead (see the
     * g60_meta insert below), a true no-op. */
    existingFieldMappingOverrides?: FieldMappingOverride[]
    /** KAR-912 F1 fix — the OLD file's row identities (Positionsnummer +
     * Prozessbezeichnung), required alongside existingFieldMappingOverrides
     * to run the carry-forward identity guard. Ignored when
     * existingFieldMappingOverrides is empty/undefined. */
    carryForwardOldRowIdentities?: RowIdentity[]
    /** KAR-926 adversarial-review F1 fix (12.07.2026), refined by the KAR-925
     * adversarial-review F1 fix (13.07.2026) — the EXISTING comparison's
     * multiQafDetection section, passed by replaceComparisonFile (file-replace-actions.ts) via
     * resolveReplaceMultiQafDetectionConfig (rehydrate.ts) — NOT the general
     * resolvePersistedEngineConfig fallback (see that function's doc for why
     * a bare fallback became unsafe here once the default flipped to
     * enabled:true). Falls back to DEFAULT_ENGINE_CONFIG.multiQafDetection
     * when absent (a brand-new upload via analyzeQafBatchFromStorage — see
     * the detection call site below for why that path can never reach a
     * per-comparison override: no qaf_comparison row exists yet at ingest
     * time for a new upload, same "no comparison to consult" gap
     * KAR-894/P1.3's own comment documents for g60StructureGuard). A file
     * REPLACE, in contrast, always has an existing qaf_comparison row to
     * read the override from — so this is the one ingest sub-path where a
     * persisted per-comparison engineConfig override is actually reachable
     * and must be honored, instead of silently staying inert like the
     * g60StructureGuard precedent. */
    multiQafDetectionConfig?: MultiQafDetectionConfig
  },
  upload: { path: string; name: string },
  onFileInserted: (fileId: string) => void,
): Promise<IngestResult> {
  if (!isAllowedQafFileName(upload.name)) {
    throw new Error('unsupported file type (.xlsx/.xlsm/.xls only)')
  }
  // Size-gate on the object's stored metadata BEFORE downloading — the
  // client-reported size at mint time is untrusted, and buffering first
  // would reopen the memory-DoS vector this cap exists for.
  const [dir, objectName] = [upload.path.slice(0, upload.path.indexOf('/')), upload.path.slice(upload.path.indexOf('/') + 1)]
  const { data: listed, error: listErr } = await admin.storage
    .from(QAF_UPLOAD_BUCKET)
    .list(dir, { search: objectName, limit: 1 })
  if (listErr) throw new Error(`storage stat failed: ${listErr.message}`)
  const meta = listed?.find((o) => o.name === objectName)
  if (!meta) throw new Error('storage object not found')
  const storedSize = (meta.metadata as { size?: number } | null)?.size
  if (typeof storedSize === 'number' && storedSize > QAF_MAX_FILE_BYTES) {
    throw new Error(`file too large (${Math.round(storedSize / 1024 / 1024)} MB, max 20 MB)`)
  }
  const { data: blob, error: dlErr } = await admin.storage.from(QAF_UPLOAD_BUCKET).download(upload.path)
  if (dlErr || !blob) throw new Error(`storage download failed: ${dlErr?.message ?? 'not found'}`)
  const buffer = Buffer.from(await blob.arrayBuffer())
  if (buffer.byteLength > QAF_MAX_FILE_BYTES) {
    throw new Error(`file too large (${Math.round(buffer.byteLength / 1024 / 1024)} MB, max 20 MB)`)
  }
  const fileHash = createHash('sha256').update(buffer).digest('hex')

  // Untrusted-Excel-Hardening (KAR-914/P4.4, Master-Prompt §20): a zip-ratio
  // guard runs BEFORE loadExcelWorkbook. It reads the ZIP Central Directory
  // for entry metadata, but the actual zip-bomb decision is NOT based on the
  // Central Directory's declared (attacker-controlled) uncompressedSize —
  // KAR-914-F1 (10.07.2026) replaced that with a REAL, capped decompression:
  // every DEFLATE entry is inflated via node:zlib's inflateRawSync with
  // maxOutputLength bounded to the remaining shared byte budget, so Node
  // itself throws ERR_BUFFER_TOO_LARGE the instant a crafted entry would
  // inflate past the cap — no gigabyte-scale buffer is ever allocated,
  // regardless of what the entry's own metadata claims. This closes the
  // exact gap the wire-size cap above never covered (see the
  // QAF_MAX_FILE_BYTES comment: "bounds the wire size only, not the
  // decompressed size") — and closes it for real, not just for a declared-
  // size lie. See workbook-safety.ts module header for the full design
  // (also covers macro/external-link detection, read from the same Central
  // Directory pass).
  // Verzweigung am Container, nicht an der Endung: inspectZipStructure() wirft
  // für jeden Nicht-ZIP-Puffer, eine .xls-Datei käme also nie bis zum Reader.
  // Der BIFF-Zweig hat eine eigene Vor-Parse-Prüfung (Größe blockierend,
  // Makro-Erkennung als Hinweis, externe Verknüpfungen ausdrücklich ungeprüft).
  const preParseCheck = isLegacyBiffBuffer(buffer)
    ? runPreParseLegacyBiffCheck(buffer)
    : runPreParseWorkbookSafetyCheck(buffer)
  if (!preParseCheck.zipBomb.ok) {
    throw new Error(`${preParseCheck.zipBomb.reasonDe} / ${preParseCheck.zipBomb.reasonEn}`)
  }

  // ONE ExcelJS load per file — G60 detection and summary parse share it.
  const excelWb = await loadExcelWorkbook(buffer)

  // Sheet-dimensions cap (KAR-914/P4.4): runs immediately AFTER the workbook
  // load (ExcelJS's own rowCount/columnCount counters, no extra read pass)
  // but BEFORE any cell iteration below (worksheetToGrid et al. inside
  // detectG60/parseSummarySheetFromWorkbook/parseMaterialSheet/…) — an
  // oversized sheet is rejected wholesale, never partially parsed.
  //
  // KAR-959 review finding #6 fix (PR #326, cleanup): summarizeWorkbookSheets
  // used to be recomputed here AND at the G60 templateFingerprint call, the
  // non-G60 templateFingerprint call, and the capabilityMatrix call below —
  // 3-4 redundant ExcelJS worksheet walks per ingest for data that never
  // changes within one call (excelWb is loaded once and never mutated).
  // Hoisted once here and threaded through every one of those call sites.
  const workbookSheets = summarizeWorkbookSheets(excelWb)
  const dimensionVerdict = evaluateSheetDimensions(workbookSheets)
  if (!dimensionVerdict.ok) {
    throw new Error(`${dimensionVerdict.reasonDe} / ${dimensionVerdict.reasonEn}`)
  }

  // WAF/LAF/LEK format detection (KAR-920/P5.3): a separate BMW Excel file // allow-customer-string
  // (Werkzeuganalyseformular/Logistikkostenanalyseformular/Lieferantenentwicklungskosten)
  // uploaded where a QAF is expected must be rejected clearly HERE — before
  // either QAF branch below (G60 vs. Summary) gets a chance to misparse it
  // into confusing/wrong numbers. See foreign-form-detection.ts module header.
  const foreignForm = detectForeignForm(foreignFormWorkbookFromExcelJs(excelWb))
  if (foreignForm) {
    throw new Error(`${foreignForm.reasonDe} / ${foreignForm.reasonEn}`)
  }

  // Multi-QAF type detection + safe degradation (KAR-926 / Multi-QAF-Programm
  // P0.1, qaf-type-detector.ts): Kadi-v2's engine has no concept of "Multi-
  // QAF"/"Variante" (multiple product variants side by side in one file) —
  // fed one today, the commercial-delta layer silently reads a plausible-
  // looking but WRONG cell instead of failing loudly (see
  // 20-capability-matrix-ist.md). `multiQafDetection.enabled` is `true` by
  // default since KAR-925 (13.07.2026, Kais' Go TG 8475) — with it false
  // (still the value replaceComparisonFile pins for a pre-flip comparison's
  // first replace, see resolveReplaceMultiQafDetectionConfig/rehydrate.ts),
  // the detector is not invoked at all and the ingest path stays
  // behaviourally and performance-wise byte-identical to pre-KAR-926. With
  // it true (the default for every brand-new upload), every ingest pays a
  // bounded scan (HEADER_SCAN_ROWS=20 SUMMARY rows + MATERIAL_FORMULA_SCAN_
  // ROWS=200 rows per MATERIAL-alias sheet, qaf-type-detector.ts) — cheap in
  // absolute terms but a real, measurable per-upload cost that did not exist
  // before this PR. Since KAR-935/P2.1, confirmed/probable no longer rejects
  // the upload up front — resolveMultiQafContainerIngest (below) tries to
  // assemble a full Multi-QAF container first and only falls back to the
  // pre-KAR-935 reject on assembly failure (fail-closed by construction);
  // ambiguous does NOT block (task instruction) — the finding is instead
  // threaded through as a plain in-memory result and surfaced via the same
  // `multiQafDetectionToPlausibilityIssue`/g60_meta-JSONB-bag pattern
  // `workbookSafety`/`templateFingerprint` already use, never silently
  // dropped; `standard_qaf` is a no-op either way but — unlike the
  // detector-never-ran case — DOES now get persisted into
  // qaf_file.g60_meta.multiQafDetection on every ordinary ingest (see
  // multiQafDetectionMetaFragment, qaf-type-detector.ts) as a diagnostic
  // "the detector ran and saw a normal file" record.
  //
  // KAR-926 adversarial-review F1 fix (12.07.2026): `ctx.multiQafDetectionConfig`
  // falls back to DEFAULT_ENGINE_CONFIG.multiQafDetection when the caller
  // didn't pass one — which is EVERY call from analyzeQafBatchFromStorage (a
  // brand-new upload has no qaf_comparison row yet at ingest time, so there is
  // nothing to read a per-comparison override FROM; same documented gap
  // KAR-894/P1.3's own comment above (g60StructureGuard) already established
  // for this ingest function — a future DB-backed engine config would need to
  // reach the ingest action itself, not just compareQafPair, to change this
  // for a new upload). replaceComparisonFile, which DOES have an existing
  // qaf_comparison row, passes the resolved override explicitly (see that
  // function) — this is the one ingest sub-path where a persisted
  // per-comparison engineConfig override is actually reachable today.
  const multiQafDetectionConfig = ctx.multiQafDetectionConfig ?? DEFAULT_ENGINE_CONFIG.multiQafDetection
  let multiQafDetection: MultiQafDetectionResult | null = null
  if (multiQafDetectionConfig.enabled) {
    multiQafDetection = detectMultiQaf(multiQafDetectionInputFromExcelJs(excelWb), multiQafDetectionConfig)
    const rejection = multiQafRejectionMessage(multiQafDetection)
    if (rejection) {
      // KAR-935/Multi-QAF-Programm P2.1: confirmed/probable no longer means
      // an automatic reject — try to assemble a full MultiQafContainer first
      // (container-assembly.ts). Fail-closed by construction
      // (resolveMultiQafContainerIngest's own try/catch): assembly failure
      // falls straight back to today's rejection message, never a half-built
      // persisted container. The file gets its OWN persistence path below
      // (kind:'multi_qaf') — no G60/summary parse, no qaf_manufacturing_step/
      // qaf_summary_metric rows, no qaf_part upsert (compare is P2.3+ scope).
      const outcome = await resolveMultiQafContainerIngest(excelWb, multiQafDetection, rejection, {
        fileName: upload.name,
        fileHash,
      })
      if (!outcome.ok) {
        // KAR-954/Multi-QAF-Programm P5.2 (Rollout-Runbook Monitoring-Gap):
        // fail-closed container-assembly rejection had no structured log —
        // an operator watching for Multi-QAF parse-failure volume could only
        // find this in the per-file fileErrors UI text, not in application
        // logs. Minimal warn, same message/context shape every other
        // logger.warn call site in this file already uses.
        logger.warn('qaf.multi_qaf.container_assembly_failed', {
          fileName: upload.name,
          classification: multiQafDetection.classification,
          reasonEn: outcome.reasonEn,
        })
        throw new Error(`${outcome.reasonDe} / ${outcome.reasonEn}`)
      }
      const { container, virtualVariants } = outcome
      const reviewStatus = reviewStatusForMultiQafContainer(container)

      const { data: multiQafFileRow, error: multiQafFileErr } = await supabase
        .from('qaf_file')
        .insert({
          project_id: ctx.projectId,
          original_file_name: upload.name,
          file_hash: fileHash,
          // Loop 5 (SRC-001) — siehe Summary-Insert unten.
          storage_key: upload.path,
          part_number_from_content: null,
          part_number_from_filename: partNumberFromFilename(upload.name),
          parser_version: PARSER_VERSION,
          template_type: 'MULTI_QAF',
          g60_meta: {
            // Same versioned envelope serializeMultiQafContainer/
            // deserializeVirtualQafVariants already define (P1.1
            // serialization.ts) — a P2.3+ reader rehydrates via
            // deserializeMultiQafContainer(JSON.stringify(g60_meta.multiQafContainer)).
            multiQafContainer: { modelVersion: MULTI_QAF_MODEL_VERSION, container },
            multiQafVirtualVariants: { modelVersion: MULTI_QAF_MODEL_VERSION, variants: virtualVariants },
            workbookSafety: preParseCheck.safety,
            ...multiQafDetectionMetaFragment(multiQafDetection),
          },
          status: 'parsed',
          // KAR-935 adversarial-review F4 fix: pulled the actual decision out
          // into container-assembly.ts's own reviewStatusForMultiQafContainer
          // (unit-tested there) — a container carrying at least one
          // 'critical' warning, OR one explicitly flagged `reviewRelevant`
          // (canonical-key collisions, material variants unmatched to any
          // Summary identity, ambiguous cost-profile bindings; see
          // MultiQafWarning.reviewRelevant's own doc comment for why this is
          // a separate axis from `severity`), needs a human look before it
          // is trusted for the P2.3+ compare pass — same review_status
          // semantics the summary path already uses for a missing content
          // part number. Before this fix, this branch checked ONLY
          // `severity === 'critical'`, which no producer in the multi-qaf
          // module set ever emits — the gate was permanently dead, so every
          // Multi-QAF file persisted as review_status 'ok' regardless of
          // genuine identity ambiguity.
          review_status: reviewStatus,
        })
        .select('id')
        .single()
      if (multiQafFileErr || !multiQafFileRow) throw new Error(multiQafFileErr?.message ?? 'qaf_file insert failed (multi_qaf)')
      const multiQafFileId = multiQafFileRow.id as string
      onFileInserted(multiQafFileId)

      // KAR-954/Multi-QAF-Programm P5.2 (Rollout-Runbook Monitoring-Gap):
      // review_status persistence had no accompanying log — the
      // reviewRequired-Quote (Master-Prompt §25 monitoring ask) was only
      // ever visible via an ad-hoc qaf_file query, never in application logs.
      if (reviewStatus === 'review_required') {
        logger.warn('qaf.multi_qaf.review_required', {
          fileId: multiQafFileId,
          fileName: upload.name,
          classification: multiQafDetection.classification,
          warningCount: container.warnings.length,
        })
      }

      return {
        kind: 'multi_qaf',
        fileId: multiQafFileId,
        fileName: upload.name,
        stepIds: [],
        workbookSafety: preParseCheck.safety,
        multiQafDetection,
        multiQafContainerSummary: {
          variantCount: container.activeVariants.length + container.inactiveVariants.length,
          activeVariantCount: container.activeVariants.length,
        },
      }
    }
  }

  // ── G60 detail QAF? Own persistence path (migration #105 tables). ─────────
  const g60wb = g60WorkbookFromExcelJs(excelWb)
  if (detectG60(g60wb)) {
    // KAR-888: structure-guarded parse — a tab whose header-row anchors are
    // hard-mismatched (e.g. a shifted row) is excluded from parsedG60.tabs
    // before anything is persisted; a hard-broken INPUT rate-card excludes
    // every tab for this file. See structure-guard.ts module header.
    // KAR-894/P1.3 (PR #276 review): G60 parsing happens exactly once, here,
    // at INGEST time — compareQafPair's `engineConfig`/`options.engineConfig`
    // overrides run later, at COMPARE time, and never touch this call. Before
    // this line, DEFAULT_ENGINE_CONFIG.g60StructureGuard was never actually
    // read anywhere in the ingest path (parseG60WorkbookGuarded fell back to
    // its own module-local G60_STRUCTURE_GUARD_CONFIG default) — the two
    // happened to be the same object, so behaviour was correct by accident,
    // not by wiring. Passing it explicitly here makes EngineConfig the one
    // true source for this section at the only place it is actually
    // consulted; a future DB-backed engine config (P1.5 follow-up, see
    // engine-config.ts module header) would need to reach the ingest action
    // itself for a G60 file, not compareQafPair, to have any effect.
    const parsedG60 = parseG60WorkbookGuarded(g60wb, DEFAULT_ENGINE_CONFIG.g60StructureGuard)
    // KAR-894/P1.3 adversarial-review follow-up: the full component rows
    // (scenario/what-if base — g60RecomputeTab/recomputeScenario read
    // EXCLUSIVELY from these, never from parsedG60.tabs' aggregate) must use
    // the SAME anchor-column relocation as the aggregate re-extraction above,
    // or a relocated tab shows correct numbers in the detail view but a
    // silently wrong base the moment a user touches the what-if calculator.
    // columnOverridesByTab is computed once inside parseG60WorkbookGuarded
    // (same locateG60ColumnAnchors call that decided the aggregate override)
    // — reused here instead of re-derived, so the two paths cannot drift.
    const fullTabs: Record<string, G60ComponentRow[]> = {}
    for (const name of Object.keys(parsedG60.tabs)) {
      const sheet = g60wb.sheet(name)
      if (sheet) fullTabs[name] = g60ExtractFullTab(sheet, parsedG60.columnOverridesByTab[name])
    }

    // KAR-895/P1.4: computed ONCE here from the already-guarded parse result —
    // never re-derived at rehydrate/render (see template-fingerprint.ts module
    // header). No qaf_template_profile table in this PR (P5 territory) — the
    // result is embedded in the same g60_meta JSONB this file kind already
    // uses for rates/volumes/inputStructure/excludedTabs (see PR body for why
    // this field and not a new column).
    const g60TemplateFingerprint = buildTemplateFingerprint({
      sheets: workbookSheets,
      summary: null,
      manufacturing: null,
      g60: {
        tabCount: Object.keys(parsedG60.tabs).length,
        inputStructureOk: parsedG60.inputStructure.ok,
        softMismatchTabCount: Object.keys(parsedG60.tabStructure).length,
        excludedTabCount: Object.keys(parsedG60.excludedTabs).length,
        // PR #328 review fix (finding [6]): the pre-KAR-961
        // `g.tabCount === 0` special case (red 'unknown' classification)
        // used to be the ONLY way a hard-broken rate card was classified —
        // it fired unconditionally because a hard-broken rate card used to
        // empty `.tabs` entirely. Since KAR-961/P4 `.tabs` stays populated
        // (only SGA/Profit are withheld per tab, see structure-guard.ts), so
        // tabCount===0 no longer fires for this condition — passing the
        // hard/soft distinction explicitly lets classifyTemplateFingerprint
        // keep the same 'unknown' (red) severity for the actual condition
        // that used to trigger it, instead of silently falling through to
        // the generic 'modified' (yellow) deviation path.
        inputRatesHardBroken: parsedG60.inputStructure.confidence === 0,
      },
    })

    const { data: g60FileRow, error: g60FileErr } = await supabase
      .from('qaf_file')
      .insert({
        project_id: ctx.projectId,
        original_file_name: upload.name,
        file_hash: fileHash,
        // Loop 5 (SRC-001) — siehe Summary-Insert unten.
        storage_key: upload.path,
        part_number_from_content: null,
        part_number_from_filename: partNumberFromFilename(upload.name),
        parser_version: PARSER_VERSION,
        template_type: 'BMW_DETAIL_TABS_G60',
        g60_meta: {
          rates: parsedG60.rates,
          volumes: parsedG60.volumes,
          inputStructure: parsedG60.inputStructure,
          excludedTabs: parsedG60.excludedTabs,
          templateFingerprint: g60TemplateFingerprint,
          // PR #328 review fix (finding [2]/[3]): parseG60WorkbookGuarded
          // already computes `rateDegradation` (structure-guard.ts) — it was
          // never persisted before this fix, so a later render/rehydrate had
          // no additive, dedicated signal for "SGA/Profit were withheld on
          // this file" beyond re-deriving it from `inputStructure`. Same
          // misc-JSONB-bag pattern as the other g60_meta fields; `undefined`
          // (the non-degraded, overwhelming majority case) is dropped by
          // JSON serialization, same as every other optional field here.
          rateDegradation: parsedG60.rateDegradation,
          // KAR-914/P4.4: G60 comparisons have no qaf_plausibility_issue
          // channel (compareQafPair is never called for the G60 path — see
          // IngestResult.workbookSafety doc) — the misc-JSONB-bag pattern
          // this facet field already uses for templateFingerprint above is
          // reused so the macro/external-link advisory signal is still
          // persisted and inspectable for reviewers, without inventing a
          // new G60-specific issue-surfacing UI in this PR.
          workbookSafety: preParseCheck.safety,
          // KAR-926/Multi-QAF-Programm P0.1: same misc-JSONB-bag pattern as
          // workbookSafety above — G60 files structurally have no SUMMARY
          // sheet, so multiQafDetection is 'standard_qaf' on every real G60
          // file in practice (when the detector ran at all), persisted
          // consistently for both file kinds rather than silently only
          // covering the summary path. Key omitted entirely when the
          // detector didn't run (adversarial-review F8 fix) — see
          // multiQafDetectionMetaFragment doc comment.
          ...multiQafDetectionMetaFragment(multiQafDetection),
        },
        status: 'parsed',
        review_status: 'ok',
      })
      .select('id')
      .single()
    if (g60FileErr || !g60FileRow) throw new Error(g60FileErr?.message ?? 'qaf_file insert failed')
    const g60FileId = g60FileRow.id as string
    onFileInserted(g60FileId)

    const rowset = buildG60Rows({ projectId: ctx.projectId, fileId: g60FileId }, parsedG60, fullTabs)
    // Chunked: real G60 files carry ~112 tabs with large JSONB payloads —
    // one request per 25 rows stays well under request-body limits.
    const TAB_CHUNK = 25
    for (let i = 0; i < rowset.tabRows.length; i += TAB_CHUNK) {
      const { error } = await supabase.from('qaf_g60_tab').insert(rowset.tabRows.slice(i, i + TAB_CHUNK))
      if (error) throw new Error(`qaf_g60_tab insert failed: ${error.message}`)
    }
    if (rowset.cardRows.length) {
      const { error } = await supabase.from('qaf_input_card').insert(rowset.cardRows)
      if (error) throw new Error(`qaf_input_card insert failed: ${error.message}`)
    }
    return {
      kind: 'g60',
      fileId: g60FileId,
      fileName: upload.name,
      stepIds: [],
      templateFingerprint: g60TemplateFingerprint,
      workbookSafety: preParseCheck.safety,
      multiQafDetection,
    }
  }

  const { summary, summaryMetrics, summaryFieldCandidates } = parseSummarySheetFromWorkbook(excelWb)
  // KAR-958/P1 fix (PR #325 review finding #1, KAR-964 follow-up tracks any
  // further hardening): parseQAFTemplate() THROWS when the Fertigungskosten/
  // Manufacturing-costs sheet header is unreadable (qaf-parser.ts: zero
  // worksheets, or no header row scores above HEADER_MATCH_MIN) — before
  // this fix that reject propagated straight out of ingestQafUpload and
  // discarded the ALREADY-PARSED summary/summaryMetrics above right along
  // with it. That is exactly the facet-coupling bug workbook-adapter.ts's
  // parseQafFile (KAR-958/P1, Promise.allSettled) was built to fix — except
  // parseQafFile was never actually wired into this production ingest path
  // (confirmed by PR #325 review; only a standalone script called it, so the
  // CHANGELOG's real-corpus numbers never exercised production code). Rather
  // than switching this call to parseQafFile itself — which re-loads and
  // re-parses the whole workbook via its own internal parseSummarySheet
  // (buffer), while this function already loaded `excelWb` ONCE above and
  // reuses it for every other facet parse (MATERIAL/SBM/RMR/LOGISTICS/LC-CN/
  // CO2e all repeat the "no second ExcelJS load" comment) — this isolates
  // the SAME two facets inline, minimally: a manufacturing-facet failure now
  // degrades `steps` to an empty, zero-confidence QAFParseResult (the exact
  // shape parseQafFile's own manufacturingParseMeta uses on failure) instead
  // of failing the whole upload. `summary`/`summaryMetrics` above and every
  // other facet parsed below persist exactly as they would for a file that
  // simply has no manufacturing sheet at all — compensation/persistence
  // logic below is untouched.
  //
  // KAR-959 review finding #1 fix (PR #326): this catch used to discard the
  // thrown error entirely — `capabilityMatrix` (computed further below from
  // this same `steps`) had no way to tell "parseQAFTemplate was never even
  // invoked / found no matching sheet" apart from "it WAS invoked, matched a
  // sheet, and threw", so facetSignalFromManufacturingSteps reported every
  // P1-catch case as DERIVABLE/NOT_YET_SUPPORTED ("parser does not exist
  // yet") instead of PARSE_FAILED ("parser exists, ran, and failed") — the
  // same PARSER_NOT_IMPLEMENTED/NOT_AVAILABLE conflation Master-Prompt §11
  // explicitly forbids, see capability-detector.ts's facetSignalFromManufacturingSteps
  // doc comment. `manufacturingDegradation` now carries that failure
  // (structurally identical to workbook-adapter.ts's parseQafFile — the P1
  // reference implementation this inline catch mirrors, see this function's
  // own comment above) through to the capabilityMatrix call below.
  let steps: QAFParseResult
  let manufacturingDegradation: { facet: 'manufacturing'; reason: 'PARSE_FAILED'; sheet: null; message: string } | undefined
  try {
    steps = await parseQAFTemplate(new File([new Uint8Array(buffer)], upload.name))
  } catch (err) {
    steps = Object.assign([], { parseConfidence: 0, unmappedHeaders: [], mappedFieldCount: 0 }) as QAFParseResult
    manufacturingDegradation = {
      facet: 'manufacturing',
      reason: 'PARSE_FAILED',
      sheet: null,
      message: err instanceof Error ? err.message : String(err),
    }
  }
  // KAR-912/P4.2 (+ KAR-912 adversarial-review F1 fix, PR #291): carry
  // forward any field-mapping overrides from a file replace onto the
  // freshly parsed rows — BEFORE they are persisted / feed the template
  // fingerprint — but ONLY after the identity guard confirms the row at
  // each override's Positionsnummer still identifies the same process step
  // on both the OLD and the NEW file (see ctx.existingFieldMappingOverrides
  // doc comment above). `overridesForPersistence` is what gets written into
  // the NEW file's g60_meta (status-updated: 'active' or
  // 'dropped_on_replace' per override) — NOT the raw, unguarded
  // ctx.existingFieldMappingOverrides. `stepsForPersistence` is a plain
  // QAFRow[] (the .map() inside applyFieldMappingOverrides naturally drops
  // the QAFParseMeta extra properties `steps` carries) — every read of
  // parseConfidence/unmappedHeaders/mappedFieldCount below deliberately
  // stays on the original `steps` (the diagnostic describes the PARSE
  // itself, which an override does not change); only the row VALUES
  // persisted/returned use the corrected array.
  const overridesForPersistence = ctx.existingFieldMappingOverrides?.length
    ? carryForwardFieldMappingOverrides(ctx.carryForwardOldRowIdentities ?? [], steps, ctx.existingFieldMappingOverrides)
        .overrides
    : (ctx.existingFieldMappingOverrides ?? [])
  const stepsForPersistence = overridesForPersistence.length ? applyFieldMappingOverrides(steps, overridesForPersistence) : steps

  // KAR-960/P3, Hebel A: process-scoped FieldCandidate[] from the
  // OVERRIDE-APPLIED `stepsForPersistence` — see manufacturing-field-
  // candidates.ts module header.
  //
  // KAR-960 review fix (PR #327 finding 4): this used to read the PRE-
  // override `steps` array instead of `stepsForPersistence`, so
  // g60_meta.field_candidates.manufacturing silently diverged from
  // qaf_manufacturing_step.raw_values (built from stepsForPersistence a few
  // lines below, see that loop's own doc comment) whenever a KAR-912
  // field-mapping override was active for this file — the corrected value
  // landed in raw_values, but the STALE pre-correction value stayed in
  // field_candidates.manufacturing. `stepsForPersistence` is what every
  // OTHER consumer of the corrected values reads from (buildProductionRateRows
  // via qaf_manufacturing_step, calculatorParamsFromSummaryStep via the same
  // table) — this producer must read the same array.
  //
  // The sheet name is not carried on QAFParseMeta (qaf-parser.ts) — derived
  // from the first per-field source cell any row actually located
  // ("Fertigungskosten!G12" — same "Sheet!A1" shape persistence-mapper.ts's
  // buildManufacturingStepRow already relies on), null when no row carries
  // any provenance at all (degraded/empty parse). Source-cell references are
  // unaffected by field-mapping overrides (only VALUES are corrected), so
  // scanning the original `steps` for provenance here is still correct.
  const manufacturingSheetName = ((): string | null => {
    for (const step of steps) {
      for (const cell of Object.values(step.sourceCells ?? {})) {
        if (!cell) continue
        const bang = cell.lastIndexOf('!')
        if (bang > 0) return cell.slice(0, bang)
      }
    }
    return null
  })()
  const manufacturingFieldCandidates: FieldCandidate<number>[] = manufacturingStepFieldCandidates(
    stepsForPersistence,
    manufacturingSheetName,
    steps.parseConfidence,
  )

  // MATERIAL sheet (KAR-897/P1.6) — additive: files without a MATERIAL sheet
  // parse and persist EXACTLY as before this PR (materialParsed stays null,
  // nothing below changes shape for them). Reuses the already-loaded
  // workbook (excelWb) — no second ExcelJS load, same "ONE load per file"
  // discipline as the G60 detection above.
  const materialParsed = await parseMaterialSheet(excelWb)

  // SBM-DEVICES-FWZ sheet (KAR-898/P1.7) — additive, same "reuses the
  // already-loaded workbook, no second ExcelJS load" discipline as the
  // MATERIAL parse above. Files without an SBM sheet parse and persist
  // EXACTLY as before this PR (sbmParsed stays null).
  const sbmParsed = await parseSbmSheet(excelWb)

  // RAW MATERIAL RISKS sheet (KAR-902/P2.3) — additive, same "reuses the
  // already-loaded workbook, no second ExcelJS load" discipline as the
  // MATERIAL/SBM parses above. Files without an RMR sheet parse and persist
  // EXACTLY as before this PR (rmrParsed stays null).
  const rmrParsed = await parseRmrSheet(excelWb)

  // LOGISTICS&CUSTOM sheet (KAR-903/P2.4) — additive, same "reuses the
  // already-loaded workbook, no second ExcelJS load" discipline as the
  // MATERIAL/SBM/RMR parses above. Files without a LOGISTICS sheet parse and
  // persist EXACTLY as before this PR (logisticsParsed stays null).
  const logisticsParsed = await parseLogisticsSheet(excelWb)

  // LC-CN sheet (KAR-904/P2.5) — additive, same "reuses the already-loaded
  // workbook, no second ExcelJS load" discipline as MATERIAL/SBM/RMR/
  // LOGISTICS above. Files without a Type=LC-CN sheet parse and persist
  // EXACTLY as before this PR (lccnParsed stays null).
  const lccnParsed = await parseLccnSheet(excelWb)

  // CO2e sheet (KAR-904/P2.5) — additive, same discipline as above. Files
  // without a CO2e-relevant sheet parse and persist EXACTLY as before this
  // PR (co2eParsed stays null).
  const co2eParsed = await parseCo2eSheet(excelWb)

  // KAR-895/P1.4: computed ONCE here — never re-derived at rehydrate/render
  // (see template-fingerprint.ts module header). No qaf_template_profile
  // table in this PR (P5 territory) — persisted by embedding into the same
  // g60_meta JSONB the G60 ingest path above already uses (this file kind
  // just never populated it before; see PR body for why this field and not
  // a new column). matchedFieldKeys is read off the already-parsed steps'
  // KAR-886 sourceCells provenance, not by touching qaf-parser.ts.
  //
  // summaryLocatedKeys() — NOT `.value !== null` — deliberately: most
  // summary money metrics are legitimately optional/blank on a real
  // quotation (no customs, no one-time payments, ...); using value presence
  // as the coverage signal would misclassify every normal file with an
  // unused optional block as "modified" (adversarial-review finding on the
  // original KAR-895 PR). summaryLocatedKeys() looks at whether the LABEL
  // was structurally located (confidence > 0), independent of the value.
  const templateFingerprint = buildTemplateFingerprint({
    sheets: workbookSheets,
    summary: summaryMetrics
      ? {
          template: summaryMetrics.template,
          locatedMetricKeys: summaryLocatedKeys(summaryMetrics.metrics),
        }
      : null,
    manufacturing: steps.length
      ? {
          matchedFieldKeys: [...new Set(steps.flatMap((s) => Object.keys(s.sourceCells ?? {})))] as QAFFieldKey[],
          approxHeaderRow: approxManufacturingHeaderRow(steps),
        }
      : null,
    // MATERIAL facet (KAR-897/P1.6) — omitted (undefined, not null) when no
    // MATERIAL parse was attempted at all is not reachable here (materialParsed
    // is always either null or a MaterialParseResult once parseMaterialSheet
    // ran); null (no MATERIAL sheet in this file) correctly yields no facet,
    // same "absence is not a modified signal" semantics as
    // classifyTemplateFingerprint documents.
    // PR #329 review fix (finding [0], KAR-962 follow-up) — REVERTS the
    // KAR-962/P5 "known-threshold calibration" gate that used to live here
    // (`parsed && parsed.length ? {...} : null`). That gate conflated two
    // genuinely different situations: "this MATERIAL/SBM/RMR/LC-CN/CO2e sheet
    // was never filled in" (a pristine/blank template's normal state) and
    // "this sheet WAS filled in, then had every data row deleted" (a
    // supplier hiding a cost breakdown). Both produce zero matched field
    // keys, and gating on `.length` treated both as "facet never attempted"
    // — silently EXCLUDING the facet from `classification` entirely, so a
    // deliberately-emptied MATERIAL sheet on an otherwise-intact file reached
    // `known` with no warning (the false-known finding).
    //
    // The parser's own null-vs-non-null return already distinguishes what
    // actually matters here: `parseMaterialSheet`/`parseSbmSheet`/
    // `parseRmrSheet`/`parseLccnSheet`/`parseCo2eSheet` return `null` ONLY
    // when the workbook has NO sheet of that role at all (see each parser's
    // own doc comment) — a FOUND-but-empty sheet still returns a real,
    // non-null result. Gating on that signal instead (`parsed ? {...} :
    // null`) means: sheet not found at all -> facet omitted (absence is not
    // a modified signal, unchanged); sheet found, however many rows -> facet
    // participates with its real (possibly 0/N) coverageRatio, exactly like
    // every OTHER facet's genuine-deviation case already works.
    //
    // Cost, stated honestly (task instruction: no gilded numbers): this
    // necessarily also lowers coverage for a LEGITIMATELY unfilled optional
    // module on a real submission (there is no way to distinguish "never
    // filled in" from "filled in then stripped" from the file alone) — the
    // known-rate measured in qaf-corpus/reports/p5-metrics.md is therefore
    // LOWER after this fix than the 78%/71% the KAR-962/P5 PR first reported;
    // see that report's updated numbers. That reduction is the accepted
    // trade-off for closing a false-known hole (a false "modified" costs a
    // reviewer a few extra seconds of double-checking; a false "known" hides
    // real tampering). Regression guard: golden-fixtures.test.ts's "golden
    // MATERIAL sheet ... stripped ... does NOT reach known" mutation test
    // (PR #329 finding [0]) is RED against the old `.length`-gated code and
    // GREEN against this one.
    material: materialParsed
      ? { matchedFieldKeys: [...new Set(materialParsed.flatMap((r) => Object.keys(r.sourceCells ?? {})))] as MaterialFieldKey[] }
      : null,
    sbm: sbmParsed
      ? { matchedFieldKeys: [...new Set(sbmParsed.flatMap((r) => Object.keys(r.sourceCells ?? {})))] as SbmFieldKey[] }
      : null,
    rmr: rmrParsed
      ? { matchedFieldKeys: [...new Set(rmrParsed.flatMap((r) => Object.keys(r.sourceCells ?? {})))] as RmrFieldKey[] }
      : null,
    // LC-CN facet (KAR-904/P2.5) — single record, not rows; same PR #329
    // finding [0] fix as material/sbm/rmr above — gated on the parser having
    // found the sheet (`lccnParsed` non-null), not on whether its record
    // carries any matched field keys.
    lccn: lccnParsed
      ? { matchedFieldKeys: [...new Set(Object.keys(lccnParsed.sourceCells ?? {}))] as LccnFieldKey[] }
      : null,
    // CO2e facet (KAR-904/P2.5) — combines both sub-parts co2e-parser.ts
    // extracts (summary panel + material rows) into one matchedFieldKeys
    // union; same PR #329 finding [0] fix — gated on co2eParsed itself
    // (sheet found), not on the union being non-empty.
    co2e: co2eParsed
      ? {
          matchedFieldKeys: [
            ...new Set([...Object.keys(co2eParsed.summary.sourceCells ?? {}), ...co2eParsed.materialRows.flatMap((r) => Object.keys(r.sourceCells ?? {}))]),
          ] as Co2eFieldKey[],
        }
      : null,
    g60: null,
  })

  const contentPn = summary.partNumber.value
  // MATERIAL persistence (KAR-897/P1.6): interim decision, see PR body —
  // qaf_manufacturing_step is NOT sheet-agnostic (position_number/process_
  // name/machine_name/part_name columns are MANUFACTURING-specific, no
  // module discriminator), so a new qaf_material_row table would be needed
  // for a first-class relational store. That table (+ RLS) is deliberately
  // NOT part of this PR — MATERIAL rows are embedded as raw JSONB on
  // qaf_file.g60_meta.material instead, the same "misc JSONB bag" field the
  // G60 ingest path (and this path's own templateFingerprint) already use.
  // `rows` is spread into a plain array (Object.assign'd meta properties on
  // a MaterialParseResult are NOT own-index array elements — JSON.stringify
  // would silently drop them if the array itself were stored verbatim).
  const materialMeta = materialParsed
    ? {
        rows: [...materialParsed] as MaterialRow[],
        parseMeta: {
          parseConfidence: materialParsed.parseConfidence,
          unmappedHeaders: materialParsed.unmappedHeaders,
          mappedFieldCount: materialParsed.mappedFieldCount,
          coreFieldsFound: materialParsed.coreFieldsFound,
          // KAR-927/P0.2 — additive, undefined (key dropped by JSON
          // serialization into g60_meta) whenever at most one MATERIAL-named
          // sheet existed.
          ignoredCandidateSheets: materialParsed.ignoredCandidateSheets,
        },
      }
    : null
  // SBM persistence (KAR-898/P1.7): same interim decision as MATERIAL above
  // (see PR body) — qaf_manufacturing_step is not sheet-agnostic, so a
  // dedicated qaf_sbm_row table is deliberately NOT part of this PR. SBM
  // rows are embedded as raw JSONB on qaf_file.g60_meta.sbm instead, the same
  // "misc JSONB bag" field material/templateFingerprint already use. `rows`
  // is spread into a plain array for the same JSON.stringify/Object.assign
  // reason materialMeta documents.
  const sbmMeta = sbmParsed
    ? {
        rows: [...sbmParsed] as SbmRow[],
        parseMeta: {
          parseConfidence: sbmParsed.parseConfidence,
          unmappedHeaders: sbmParsed.unmappedHeaders,
          mappedFieldCount: sbmParsed.mappedFieldCount,
          coreFieldsFound: sbmParsed.coreFieldsFound,
          // KAR-927/P0.2 — additive, undefined (key dropped by JSON
          // serialization into g60_meta) whenever at most one SBM-named
          // sheet existed.
          ignoredCandidateSheets: sbmParsed.ignoredCandidateSheets,
        },
      }
    : null
  // RMR persistence (KAR-902/P2.3): same interim decision as MATERIAL/SBM
  // above (see PR body) — qaf_manufacturing_step is not sheet-agnostic, so a
  // dedicated qaf_rmr_row table is deliberately NOT part of this PR (deviates
  // from the backlog item's own "Migrations-Impact: ja — neue Tabelle
  // qaf_rmr_row" note, same deviation MATERIAL/SBM already established). RMR
  // rows are embedded as raw JSONB on qaf_file.g60_meta.rmr instead, the same
  // "misc JSONB bag" field material/sbm/templateFingerprint already use.
  // `rows` is spread into a plain array for the same JSON.stringify/
  // Object.assign reason materialMeta/sbmMeta document.
  // parseMeta.possibleUnparsedBlockRow (KAR-902 follow-up fix) is included
  // verbatim below — it is the field rmrParseMetaToPlausibilityIssue
  // (compare.ts) reads to (re)generate the rmr_possible_unparsed_block
  // Sicherheitsnetz issue, both at live-ingest time and on rehydration.
  const rmrMeta = rmrParsed
    ? {
        rows: [...rmrParsed] as RmrRow[],
        parseMeta: {
          parseConfidence: rmrParsed.parseConfidence,
          unmappedHeaders: rmrParsed.unmappedHeaders,
          mappedFieldCount: rmrParsed.mappedFieldCount,
          coreFieldsFound: rmrParsed.coreFieldsFound,
          possibleUnparsedBlockRow: rmrParsed.possibleUnparsedBlockRow,
          // KAR-958/P3 review fix (PR #325 finding #4) — MUST be persisted,
          // not just carried on the in-memory IngestResult: rmrRowsFromPersistedMeta
          // (rehydration path, e.g. replaceComparisonFile/recompareComparison)
          // needs these to do the per-block reconciliation filter. Omitting
          // them here would silently fall back to rmrRowsForReconciliation's
          // pre-fix blanket-null legacy path on every rehydration, even for
          // a file ingested with THIS fix already live — exactly the
          // manufacturingParseMeta/workbookSafety persistence bug KAR-899/
          // KAR-914 already fixed once for other facets (see those modules'
          // own comments above).
          degradedRowRanges: rmrParsed.degradedRowRanges,
          anyIntactBlockParsed: rmrParsed.anyIntactBlockParsed,
        },
      }
    : null
  // LOGISTICS&CUSTOM persistence (KAR-903/P2.4): same interim decision as
  // MATERIAL/SBM/RMR above (see PR body) — qaf_manufacturing_step is not
  // sheet-agnostic, so a dedicated qaf_logistics_row table is deliberately
  // NOT part of this PR (deviates from the backlog item's own "Migrations-
  // Impact: ja — neue Tabelle qaf_logistics_row" note, same deviation
  // MATERIAL/SBM/RMR already established — task instruction explicitly asks
  // for the JSONB shape here, matching the RMR precedent). LOGISTICS rows are
  // embedded as raw JSONB on qaf_file.g60_meta.logistics instead, the same
  // "misc JSONB bag" field material/sbm/rmr/templateFingerprint already use.
  // `rows` is spread into a plain array for the same JSON.stringify/
  // Object.assign reason materialMeta/sbmMeta/rmrMeta document.
  const logisticsMeta = logisticsParsed
    ? {
        rows: [...logisticsParsed] as LogisticsRow[],
        parseMeta: {
          parseConfidence: logisticsParsed.parseConfidence,
          unmappedHeaders: logisticsParsed.unmappedHeaders,
          mappedFieldCount: logisticsParsed.mappedFieldCount,
          coreFieldsFound: logisticsParsed.coreFieldsFound,
        },
      }
    : null
  // LC-CN persistence (KAR-904/P2.5): same interim decision as MATERIAL/SBM/
  // RMR/LOGISTICS above (see PR body) — a single-record parser has no "rows"
  // array, so the persisted shape is the LccnParseResult itself
  // (PersistedLccnMeta === LccnParseResult, see lccn-parser.ts doc comment).
  // Embedded as raw JSONB on qaf_file.g60_meta.lccn, the same "misc JSONB
  // bag" field material/sbm/rmr/logistics/templateFingerprint already use.
  const lccnMeta: PersistedLccnMeta | null = lccnParsed
  // CO2e persistence (KAR-904/P2.5): same interim decision as above. Embedded
  // as raw JSONB on qaf_file.g60_meta.co2e — `rows` (materialRows) is spread
  // into a plain array for the same JSON.stringify/Object.assign reason
  // materialMeta/sbmMeta/rmrMeta/logisticsMeta document.
  const co2eMeta: PersistedCo2eMeta | null = co2eParsed
    ? {
        summary: {
          values: co2eParsed.summary.values,
          sourceCells: co2eParsed.summary.sourceCells,
          parseMeta: co2eParsed.summary.meta,
        },
        material: {
          rows: [...co2eParsed.materialRows],
          parseMeta: {
            parseConfidence: co2eParsed.materialRows.parseConfidence,
            unmappedHeaders: co2eParsed.materialRows.unmappedHeaders,
            mappedFieldCount: co2eParsed.materialRows.mappedFieldCount,
            coreFieldsFound: co2eParsed.materialRows.coreFieldsFound,
          },
        },
      }
    : null
  // Fertigungskosten-Header-Degradation (KAR-893/P1.2) — persisted (KAR-899
  // follow-up, adversarial-review finding confidence 90 on the original
  // KAR-899 PR): this diagnostic was computed here but only ever attached to
  // the in-memory IngestResult, never written into qaf_file.g60_meta —
  // unlike material/sbm/templateFingerprint below. Harmless before KAR-899
  // (recompareComparison never touched qaf_plausibility_issue at all), but
  // once replaceComparisonFile's refreshPlausibility:true deletes+reinserts
  // the persisted issues, a parser_degraded_manufacturing_headers finding
  // could never be regenerated via recompareComparison's sideOf() (actions.ts) (no live
  // parse to read it from there) — it would silently vanish forever on the
  // first file replace. Embedded in the same g60_meta JSONB bag
  // material/sbm/templateFingerprint already use.
  const manufacturingParseMeta = {
    parseConfidence: steps.parseConfidence,
    unmappedHeaders: steps.unmappedHeaders,
    mappedFieldCount: steps.mappedFieldCount,
    // KAR-927/P0.2 — additive, undefined (key dropped by JSON serialization
    // into g60_meta) whenever at most one MANUFACTURING-named sheet existed.
    ignoredCandidateSheets: steps.ignoredCandidateSheets,
  }
  // Capability-Kern (KAR-959/P2, Master-Prompt §11/§32) — computed ONCE here,
  // AFTER every existing facet parse above (task instruction: "NACH dem
  // bestehenden Parsen"), from data already in memory — no second workbook
  // read. Persisted additively into g60_meta.capability_matrix, the same
  // JSONB-additive pattern KAR-935's multiQafContainer already established:
  // old qaf_file rows without this key are completely unaffected, no
  // migration, and no UI reads this key in this PR (see internal/capability/
  // module headers for the full component breakdown — sheet-resolver.ts,
  // field-registry.ts, capability-detector.ts, field-candidates.ts,
  // module-capability-resolver.ts). Wrapped defensively: a capability-
  // detection bug must never fail the whole ingest — this is a diagnostic
  // foundation for a future P3/P4 gate migration, not load-bearing for any
  // existing behavior in this PR.
  let capabilityMatrix: WorkbookCapabilityMatrix | null = null
  try {
    capabilityMatrix = computeWorkbookCapabilityMatrix({
      sheets: workbookSheets,
      summary,
      summaryMetrics,
      manufacturingSteps: {
        length: steps.length,
        parseConfidence: steps.parseConfidence,
        mappedFieldCount: steps.mappedFieldCount,
        unmappedHeaders: steps.unmappedHeaders,
        degradation: manufacturingDegradation,
      },
      material: materialParsed,
      sbm: sbmParsed,
      rmr: rmrParsed,
      logistics: logisticsParsed,
      lccn: lccnParsed,
      co2eMaterial: co2eParsed?.materialRows ?? null,
      co2eSummary: co2eParsed?.summary ?? null,
    })
  } catch (err) {
    logger.warn('qaf.capability_matrix.compute_failed', {
      fileName: upload.name,
      error: err instanceof Error ? err.message : String(err),
    })
  }

  // KAR-961/P4 — "Umlageschema-Kalkulation" template family onboarding
  // (gate-audit.md capability-matrix.md finding #3): a small, structurally
  // DIFFERENT template generation (no INPUT rate-card, no SUMMARY/
  // Fertigungskosten split, no multi-column variant grid) that all three
  // EXISTING detectors above (MultiQAF/G60/the generic
  // computeWorkbookCapabilityMatrix call just above) correctly do not
  // recognize — resulting, before this block, in a uniformly-MISSING
  // capabilityMatrix and 0 extracted fields for such a file. Detected here,
  // AFTER every existing detector/parse above (never gates/short-circuits
  // any of them — Master-Prompt §0: a template/family label is a hint,
  // never a blocking gate) — see cost-allocation-family.ts module header
  // for the full detection/extraction scope and its deliberately
  // conservative limits. Only REPLACES `capabilityMatrix` (same
  // WorkbookCapabilityMatrix type, same additive g60_meta.capability_matrix
  // JSONB key below, no schema change) when this family actually matched —
  // every other file's capabilityMatrix is completely unaffected. Wrapped
  // defensively for the same reason as the block above: a detection bug
  // must never fail the whole ingest.
  let costAllocationFamily: CostAllocationFamilyDetection | null = null
  try {
    const sheetNames = workbookSheets.map((s) => s.name)
    costAllocationFamily = detectCostAllocationFamily(sheetNames)
    if (costAllocationFamily.matched) {
      let meta: Record<CostAllocationMetaKey, CostAllocationMetaField> | null = null
      const kalkulationSheetName = costAllocationFamily.sheets.kalkulation
      if (kalkulationSheetName) {
        const ws = excelWb.getWorksheet(kalkulationSheetName)
        if (ws) meta = extractCostAllocationMeta((addr) => resolveCell(ws.getCell(addr)))
      }
      capabilityMatrix = buildCostAllocationCapabilityMatrix(sheetNames, costAllocationFamily, meta)
    }
  } catch (err) {
    logger.warn('qaf.cost_allocation_family.detect_failed', {
      fileName: upload.name,
      error: err instanceof Error ? err.message : String(err),
    })
  }

  // KAR-962/P5 — not-a-QAF detection (Master-Prompt §30, task scope point 3):
  // AFTER both real-family detectors above (G60 already excluded by this
  // point — see the early G60 return earlier in this function — and
  // Umlageschema-Kalkulation just above), never gates/short-circuits either
  // (Master-Prompt §0, same discipline as costAllocationFamily). A
  // SUSPECTED-not-a-QAF file still gets a best-effort comparison attempt —
  // this only adds a clear, bilingual warning (surfaced via the existing
  // PlausibilityIssue channel at compare time, notAQafToPlausibilityIssue)
  // INSTEAD OF letting an essentially-empty result speak for itself
  // unexplained. Wrapped defensively for the same reason as the two blocks
  // above: a detection bug must never fail the whole ingest.
  let notAQaf: NotAQafDetection | null = null
  try {
    const sheetNames = workbookSheets.map((s) => s.name)
    // PR #329 review fix (finding [5]): reuse computeWorkbookCapabilityMatrix's
    // already-computed sheetRoles (assigned above, possibly replaced by
    // buildCostAllocationCapabilityMatrix's own sheetRoles when that family
    // matched) instead of re-running resolveSheetRoles a second time on the
    // same sheet list. Safe when costAllocationFamily matched too — detectNotAQaf
    // short-circuits on costAllocationFamilyMatched BEFORE ever touching
    // opts.sheetRoles, so which sheetRoles object is passed there is moot.
    // Falls back to detectNotAQaf's own internal computation (undefined) when
    // computeWorkbookCapabilityMatrix's try/catch above failed.
    notAQaf = detectNotAQaf(sheetNames, {
      g60Detected: false,
      costAllocationFamilyMatched: costAllocationFamily?.matched ?? false,
      sheetRoles: capabilityMatrix?.sheetRoles,
    })
  } catch (err) {
    logger.warn('qaf.not_a_qaf.detect_failed', {
      fileName: upload.name,
      error: err instanceof Error ? err.message : String(err),
    })
  }
  // UnknownPatternCollector (task scope point 3) — a review-queue record,
  // built ONLY when notAQaf actually matched (collectUnknownPattern returns
  // null otherwise) and persisted in the same g60_meta.notAQaf bag below —
  // "the persisted list" is a plain query across qaf_file rows (see
  // not-a-qaf-detector.ts module header), not a new table.
  const unknownPattern = notAQaf ? collectUnknownPattern(notAQaf, new Date().toISOString()) : null

  const { data: fileRow, error: fileErr } = await supabase
    .from('qaf_file')
    .insert({
      project_id: ctx.projectId,
      original_file_name: upload.name,
      file_hash: fileHash,
      // Loop 5 (SRC-001): der Zeiger zur aufbewahrten Originaldatei — die
      // Bytes lagen seit KAR-840 im Bucket, ohne dass eine qaf_file-Zeile
      // sie wiederfinden konnte. NULL bei Bestandszeilen = „Legacyquelle
      // fehlt", wird explizit angezeigt statt stillgeschwiegen.
      storage_key: upload.path,
      part_number_from_content: contentPn,
      part_number_from_filename: partNumberFromFilename(upload.name),
      parser_version: PARSER_VERSION,
      template_type: summaryMetrics?.template ?? null,
      g60_meta: {
        templateFingerprint,
        material: materialMeta,
        sbm: sbmMeta,
        rmr: rmrMeta,
        logistics: logisticsMeta,
        lccn: lccnMeta,
        co2e: co2eMeta,
        manufacturingParseMeta,
        // KAR-914/P4.4 adversarial-review F3 fix: workbookSafety was
        // originally only attached to the in-memory IngestResult, exactly
        // the manufacturingParseMeta bug the KAR-899 follow-up above already
        // fixed once — a refreshPlausibility:true recompare (replaceComparisonFile)
        // deletes+reinserts qaf_plausibility_issue, and without persisting
        // this here, security_macro_present/security_external_links findings
        // for a file with an actual macro/external link would silently
        // vanish forever on the very first file replace, even though the
        // file itself never changed. Same g60_meta JSONB bag pattern as
        // manufacturingParseMeta — rehydrated in recompareComparison's
        // sideOf() below.
        workbookSafety: preParseCheck.safety,
        // KAR-912/P4.2 (+ KAR-912 F1 fix, PR #291): carried forward from the
        // OLD file on a replace, but through the identity guard
        // (overridesForPersistence, NOT the raw ctx.existingFieldMappingOverrides
        // — a dropped override is status:'dropped_on_replace', not silently
        // applied to an unrelated row). `[]` for a brand-new upload (nothing
        // to carry forward). Same "misc JSONB bag" pattern as every facet
        // above — rehydrated in recompareComparison's sideOf() (actions.ts) below, and
        // re-carried-forward (through the SAME identity guard again) by the
        // NEXT replaceComparisonFile call, so a correction (or a drop
        // decision) survives an arbitrary chain of replaces, not just one.
        fieldMappingOverrides: overridesForPersistence,
        // KAR-926/Multi-QAF-Programm P0.1: same misc-JSONB-bag pattern as
        // workbookSafety above — rehydrated in recompareComparison's
        // sideOf() below and consumed by compareQafPair's
        // multiQafDetectionIssues (compare.ts), same optional-field contract
        // as workbookSafety. Key omitted entirely when the detector didn't
        // run (adversarial-review F8 fix) — see multiQafDetectionMetaFragment
        // doc comment.
        ...multiQafDetectionMetaFragment(multiQafDetection),
        // KAR-959/P2 (Capability-Kern, see comment above the try/catch that
        // computes this) — additive, null only when the computation itself
        // threw (never blocks the ingest). No UI reads this key in this PR.
        capability_matrix: capabilityMatrix,
        // KAR-961/P4 — diagnostic record of the family detector's OWN
        // finding (matched/profile/confidence/evidence + which sheets it
        // resolved), kept separate from capability_matrix above (which only
        // carries the per-module RESULT) so a reviewer/future UI can see
        // WHY a file was recognized as this family without re-running the
        // detector. Same additive-JSONB, no-UI-consumer-yet pattern as
        // every other key in this bag. `null` when the detector never
        // matched (the overwhelming majority of files) — never persisted as
        // a false/empty object for a file this PR does not concern.
        costAllocationFamily: costAllocationFamily?.matched ? costAllocationFamily : null,
        // KAR-962/P5 — not-a-QAF detection finding + UnknownPatternCollector
        // record (task scope point 3), same "null when not matched, never a
        // false/empty object" discipline as costAllocationFamily above. The
        // PlausibilityIssue itself (notAQafToPlausibilityIssue) is derived
        // from this at COMPARE time, not persisted separately — same
        // provenance-recompute-from-persisted-facts pattern templateFingerprint's
        // own read-back already establishes (see that field's comment).
        notAQaf: notAQaf?.matched ? { ...notAQaf, pattern: unknownPattern } : null,
        // KAR-960/P3 — Hebel A/B FieldCandidate[] (process-scoped manufacturing
        // parameters + global-scope Summary master rates/Summen), same
        // additive misc-JSONB-bag pattern as capability_matrix above.
        //
        // KAR-960 review fix (PR #327 finding 4) — comment correction: only
        // `field_candidates.summary` is actually read back today, by
        // app/qaf-differences/[id]/page.tsx (fileMetaById) and
        // getCalculatorParams (Preis-Kalkulator ALT/NEU chips for
        // Summary-mode comparisons, both via masterRatesOf/buildMasterRateRows).
        // `field_candidates.manufacturing` (built from `stepsForPersistence`,
        // see manufacturingFieldCandidates above) has NO consumer yet — the
        // Produktionssicht section (§6, production-view.ts
        // buildProductionRateRows) reads `qaf_manufacturing_step.raw_values`
        // directly, not this JSONB copy. Kept here for future parity with
        // the FieldCandidate pipeline (and so a future consumer has
        // override-consistent data to read, per finding 4's fix above) — but
        // a claim that it is "read back by the Produktionssicht/Anomalien
        // sections" was factually wrong and has been removed.
        field_candidates: {
          manufacturing: manufacturingFieldCandidates,
          summary: summaryFieldCandidates,
        },
      },
      status: 'parsed',
      review_status: contentPn ? 'ok' : 'review_required',
    })
    .select('id')
    .single()
  if (fileErr || !fileRow) throw new Error(fileErr?.message ?? 'qaf_file insert failed')
  const fileId = fileRow.id as string
  onFileInserted(fileId)

  // Persist steps in order; collect ids for FK resolution. source_cells/normalized
  // (KAR-886) come from the row's own parse-time provenance (lib/qaf-parser.ts).
  // stepsForPersistence (KAR-912/P4.2) — the override-applied rows, so a
  // corrected value carried forward from a file replace is what actually
  // lands in qaf_manufacturing_step.raw_values, not the parser's raw output.
  const stepIds: string[] = []
  for (let i = 0; i < stepsForPersistence.length; i++) {
    const { data: stepRow, error: stepErr } = await supabase
      .from('qaf_manufacturing_step')
      .insert(buildManufacturingStepRow({ projectId: ctx.projectId, fileId }, i, stepsForPersistence[i]))
      .select('id')
      .single()
    if (stepErr || !stepRow) throw new Error(stepErr?.message ?? 'qaf_manufacturing_step insert failed')
    stepIds.push(stepRow.id as string)
  }

  if (contentPn && ctx.upsertPart !== false) {
    const { error: partErr } = await supabase.from('qaf_part').upsert(
      {
        project_id: ctx.projectId,
        part_number: contentPn,
        part_name: summary.partName.value,
        variant: summary.variant.value,
        supplier: summary.supplier.value,
        quotation_date: summary.quotationDate.value ? summary.quotationDate.value.slice(0, 10) : null,
        version: summary.requestVersion.value,
      },
      { onConflict: 'project_id,part_number' },
    )
    if (partErr) throw new Error(`qaf_part upsert failed: ${partErr.message}`)
  }

  // Persist the extracted summary money metrics (KAR-840 foundation) —
  // the on-screen KPI/Formular/Brücke sections read qaf_summary_metric.
  if (summaryMetrics) {
    const metricRows = buildSummaryMetricRows({ projectId: ctx.projectId, fileId, partNumber: contentPn }, summaryMetrics)
    if (metricRows.length) {
      const { error } = await supabase.from('qaf_summary_metric').insert(metricRows)
      if (error) throw new Error(`qaf_summary_metric insert failed: ${error.message}`)
    }
  }

  // QVS-P4 (KAR-973): sum_planned_capacity/sum_lot_size ride along in this
  // SAME per-file, RLS-covered table — deliberately NOT via
  // buildSummaryMetricRows/SUMMARY_METRIC_KEYS above (that union is the 20
  // MONEY metrics only; these two are plain counts, no currency). This is
  // additive rows with new metric_key values on an unconstrained TEXT column
  // (no CHECK/enum on qaf_summary_metric.metric_key) — reader side:
  // lib/qaf-value-stream/internal/qaf-source.ts. QafSummary.plannedCapacity/
  // lotSize themselves stay live-parse-only (like their 4 KAR-910 siblings,
  // see rehydrate.ts) — QVS needs a durable read long after ingest, which is
  // exactly what this table (unlike a rehydrated QafSummary) provides.
  const fileLevelCountRows = [
    { key: 'plannedCapacity', raw: summary.plannedCapacity.value },
    { key: 'lotSize', raw: summary.lotSize.value },
  ]
    // Review-Fix 9 (KAR-973 adversarial review): reuses the shared, already-
    // tested lib/qaf-differences parseLocaleNumber (normalizer.ts) instead of
    // an ad-hoc reimplementation — parseLocaleNumber correctly disambiguates
    // German thousands-dot ("221.500" -> 221500) from a genuine decimal
    // ("221.5" -> 221.5), which the deleted parseQvsFileLevelCount did not
    // (it would have misparsed "221.500" as 221.5, a silent 1000x error).
    .map(({ key, raw }) => ({ key, value: parseLocaleNumber(raw) }))
    .filter((r): r is { key: string; value: number } => r.value !== null)
    .map((r) => ({
      project_id: ctx.projectId,
      file_id: fileId,
      part_number: contentPn,
      metric_key: r.key,
      currency: null,
      value: r.value,
      source_cell: null,
    }))
  if (fileLevelCountRows.length) {
    const { error } = await supabase.from('qaf_summary_metric').insert(fileLevelCountRows)
    if (error) throw new Error(`qaf_summary_metric insert failed (QVS file-level counts): ${error.message}`)
  }

  return {
    kind: 'summary',
    fileId,
    fileName: upload.name,
    stepIds,
    summary,
    summaryMetrics: summaryMetrics ?? undefined,
    // KAR-912/P4.2: the override-applied rows (see stepsForPersistence doc
    // comment above) — a caller of ingestQafUpload that uses this live
    // IngestResult (e.g. a first-time compareQafPair, not just the persisted
    // path) sees the same corrected values that were written to
    // qaf_manufacturing_step.
    steps: stepsForPersistence,
    // materialRowsForReconciliation/sbmRowsForReconciliation (NOT a naive
    // `parsed ? [...parsed] : null`, adversarial-review finding on the
    // original KAR-898 PR, confidence 82): SbmParseResult/MaterialParseResult
    // are arrays, and the parser returns an EMPTY array (truthy!) for a
    // degraded/unusable header, not `null` — a naive truthy check would let a
    // degraded SBM header masquerade as "confirmed zero rows", which
    // sbm_detail_sum's "both sides empty -> bestanden" shortcut could then
    // silently report as clean. The helper functions collapse
    // coreFieldsFound:false to `null` explicitly, see their doc comments.
    materialRows: materialRowsForReconciliation(materialParsed),
    // KAR-927/P0.2: threaded through unconditionally to `undefined` when
    // materialParsed is null (no MATERIAL sheet at all — mirrors
    // manufacturingParseMeta's "computed, but only when there's something to
    // report" contract) so compare.ts's materialParseMetaToPlausibilityIssue
    // can (re)generate the parser_ignored_material_candidate_sheets finding.
    materialParseMeta: materialParsed ? { ignoredCandidateSheets: materialParsed.ignoredCandidateSheets } : undefined,
    sbmRows: sbmRowsForReconciliation(sbmParsed),
    // KAR-927/P0.2, same contract as materialParseMeta above.
    sbmParseMeta: sbmParsed ? { ignoredCandidateSheets: sbmParsed.ignoredCandidateSheets } : undefined,
    rmrRows: rmrRowsForReconciliation(rmrParsed),
    logisticsRows: logisticsRowsForReconciliation(logisticsParsed),
    // lccnForReconciliation collapses both "no LC-CN sheet at all" (null
    // input) and "sheet found but too degraded to trust" (coreFieldsFound:
    // false) into null — `?.values ?? null` reads the record's values in the
    // usable case, stays null in both unusable cases (never `undefined` here,
    // since a parse was always attempted on this path).
    lccnValues: lccnForReconciliation(lccnParsed)?.values ?? null,
    co2eMaterialRows: co2eMaterialRowsForReconciliation(co2eParsed),
    // KAR-902 follow-up fix: threaded through unconditionally to `undefined`
    // when rmrParsed is null (no RMR sheet at all — mirrors
    // manufacturingParseMeta's "computed, but only when there's something to
    // report" contract) so compare.ts's rmrParseMetaToPlausibilityIssue can
    // (re)generate the rmr_possible_unparsed_block Sicherheitsnetz issue.
    rmrParseMeta: rmrParsed
      ? {
          parseConfidence: rmrParsed.parseConfidence,
          unmappedHeaders: rmrParsed.unmappedHeaders,
          mappedFieldCount: rmrParsed.mappedFieldCount,
          coreFieldsFound: rmrParsed.coreFieldsFound,
          possibleUnparsedBlockRow: rmrParsed.possibleUnparsedBlockRow,
          // KAR-958/P3 review fix (PR #325 finding #4) — additive fields on
          // RmrParseMeta consumed by rmrRowsForReconciliation on rehydration
          // (rmrRowsFromPersistedMeta); threaded through unconditionally so
          // a re-ingested file's g60_meta.rmr.parseMeta carries them from
          // day one, not just a future re-parse.
          degradedRowRanges: rmrParsed.degradedRowRanges,
          anyIntactBlockParsed: rmrParsed.anyIntactBlockParsed,
        }
      : undefined,
    templateFingerprint,
    manufacturingParseMeta,
    workbookSafety: preParseCheck.safety,
    multiQafDetection,
  }
}

function partNumberFromFilename(name: string): string | null {
  // Part numbers are 7-digit or 7-char alphanumeric tokens in the filename. // allow-customer-string
  // Delimit with explicit non-alphanumeric lookarounds rather than \b: JS \b treats
  // "_" as a word char, so \b never fires inside underscore-delimited names like
  // "QAF_1234567_v2.xlsx" (the dominant supplier convention). The lookarounds must
  // exclude BOTH cases ([0-9A-Za-z]) — otherwise a token flanked by lowercase
  // ("blende1234567rev") would falsely match — while still allowing "_"/"."/"-"
  // delimiters and rejecting a 7-char slice inside a longer alphanumeric run.
  const m = name.match(/(?<![0-9A-Za-z])([0-9]{7}|[0-9A-Z]{7})(?![0-9A-Za-z])/)
  return m ? m[1] : null
}
