// Loop 6 Etappe 1 (Roadmap): der kanonische JSON-Export — kleinster
// schema-valider Stand für den Standard-Paarvergleich.
//
// Zielform ist schemas/qaf-comparison-canonical-v1.0.json (byte-identisch zu
// docs/qaf-v2/baseline-inputs/03_QAF_Comparison_Canonical_Schema_v1.0.json):
// 21 Pflicht-Top-Level-Keys, additionalProperties:false, alle maßgeblichen
// Dezimalwerte als STRINGS (Schema-$def `decimal` — bewusst kein IEEE-754-
// Float im Vertrag; die Formatierung passiert hier GENAU EINMAL, nicht
// canonicalNumber→String, sonst entstünden zwei Rundungsschichten).
//
// Etappen-Ehrlichkeit (Design-Memo 10.08., Scratchpad loop6-design-memo.md):
// - Etappe 2a (canonical-graph.ts): entities (part je Dokument), facts
//   (NUR SUM — einzige Quelle mit persistierter sheet-Provenienz; Seiten
//   ohne sheet werden withheld gezählt, nicht erfunden), relationships
//   (same_part_as) und difference.base/comparison_fact_ids für SUM.
//   mapping_groups/manual_overrides/unmapped_regions bleiben LEER (Schema
//   erlaubt es — kein minItems); MFG-/MAT-Facts brauchen erst die
//   sheet-Spalte in den Diff-Tabellen (Etappe 2b, DB-Lücke).
// - analysis_areas führt alle 24 Pflicht-Bereiche; nicht gebaute stehen als
//   not_computed mit content:null — kein Bereich wird still ausgelassen.
// - Nur Paar-Form (2 Dokumente) aus der Pair-Engine. Multi-QAF ist
//   deferred_by_product_owner und hier bewusst NICHT angefasst.
//
// Dokumentierte Abweichungen von blinder Schema-Treue (Memo §5):
// - finding/action haben im Schema KEIN difference_ids-Feld, die
//   nicht verhandelbare Regel verlangt aber Difference-IDs an quantitativen
//   Aussagen → die Bezüge stehen im vom Schema vorgesehenen
//   extensions["supplierpulse.current_engine"]-Block.
// - run_manifest verlangt 5 einzelne Policy-Versionen; die Engine führt
//   EINEN configVersion-Stempel → alle 5 tragen denselben Wert (benannt).
//
// Pure. Kein DB-/Framework-/I/O-Zugriff (check:qaf-core-portability) — die
// sha256/byte_size der Originale liefert der App-Layer-Aufrufer.

import type { SummaryMetricDiff } from './summary-metrics'
import { buildCanonicalGraph, validateCanonicalGraph } from './canonical-graph'
import type { DifferenceRecord, DifferenceKind, DeltaNullReason } from './all-differences'
import { QAF_CONVENTIONS } from './export-envelope'

export const CANONICAL_SCHEMA_VERSION = '1.0.0'
export const CANONICAL_EXPORT_PROFILE = 'canonical_full'

/**
 * Verifizierer-Rezept für canonical_content_hash (Review-Befund: ohne
 * Rezept ist Fremdverifizierbarkeit nicht einlösbar): (1) das gelieferte
 * Dokument parsen, (2) run_manifest.canonical_content_hash auf GENAU diesen
 * Platzhalter zurücksetzen, (3) KOMPAKT serialisieren (JSON.stringify ohne
 * Einrückung — die ausgelieferte Datei ist eingerückt, der Hash läuft über
 * die kompakte Form), (4) sha256 hex rechnen und mit dem gelieferten Wert
 * (ohne "sha256:"-Präfix) vergleichen.
 */
export const CANONICAL_HASH_PLACEHOLDER = 'sha256:' + '0'.repeat(64)

// ── Decimal-Strings ──────────────────────────────────────────────────────────

/** Präzision je Domäneneinheit — DIREKT aus QAF_CONVENTIONS.rounding (eine
 * Quelle, keine duplizierten Zahlen; Review-Befund: 4 pauschal war
 * einheitenblind — EUR rundet auf Cent, pct_decimal braucht 6). Einheiten
 * ohne Konventions-Eintrag (z. B. pieces, unbekannt) fallen konservativ auf
 * die feinste Geld-Stufe (EUR_per_piece) zurück. */
function precisionFor(unit: string | undefined): number {
  const rounding = QAF_CONVENTIONS.rounding as Record<string, number>
  return unit !== undefined && rounding[unit] !== undefined ? rounding[unit] : QAF_CONVENTIONS.rounding.EUR_per_piece
}

/**
 * Formatiert eine Engine-Zahl GENAU EINMAL als Schema-`decimal`-String
 * (Pattern ^-?(0|[1-9][0-9]*)(\.[0-9]+)?$), einheitenbewusst nach
 * QAF_CONVENTIONS.rounding; trailing zeros gestrippt, -0 → 0.
 * Deterministisch, kein Locale-Einfluss. Wirft bei nicht endlichen Werten
 * und bei Magnituden, für die toFixed in Exponentialschreibweise kippen
 * würde (≥ 1e21 — Schema-Pattern-Bruch wäre sonst still).
 */
export function decimalString(n: number, unit?: string): string {
  if (!Number.isFinite(n)) throw new Error(`decimalString: nicht endlich (${n})`)
  if (Math.abs(n) >= 1e21) throw new Error(`decimalString: Magnitude jenseits toFixed-Bereich (${n})`)
  const fixed = n.toFixed(precisionFor(unit))
  const stripped = fixed.replace(/\.?0+$/, '')
  const out = stripped === '' || stripped === '-' ? '0' : stripped
  return out === '-0' ? '0' : out
}

// ── Enum-Übersetzungen (je Tabelle vollständig, Compile-erzwungen) ──────────

/** DifferenceKind (9 Engine-Arten) → difference_status (8 Schema-Werte). */
export const DIFFERENCE_STATUS_BY_KIND: Record<DifferenceKind, string> = {
  added: 'new',
  removed: 'removed',
  changed: 'changed',
  renamed: 'changed',
  merged: 'changed',
  split: 'changed',
  moved: 'changed',
  newly_filled: 'new',
  cleared: 'zero_cost',
}

/** deltaNullReason → difference_status-Überschreibung (benannte Lücken). */
export const DIFFERENCE_STATUS_BY_NULL_REASON: Record<DeltaNullReason, string> = {
  nicht_berechenbar: 'not_available',
  nicht_anwendbar: 'not_comparable',
  blockiert: 'not_available',
}

/** Engine-Capability-Status (KAR-959) → Schema-capabilityStatus.status. */
export const CANONICAL_CAPABILITY_STATUS: Record<string, string> = {
  AVAILABLE: 'available',
  // DERIVABLE ist eine Engine-Nuance ohne Schema-Entsprechung — als partial
  // gemappt (dokumentierte Konvention, Design-Memo §2).
  PARTIAL: 'partial',
  DERIVABLE: 'partial',
  MISSING: 'not_computed',
  PARSE_FAILED: 'blocked',
}

/** Plausibilitäts-Severity (DE) → Schema-finding.severity. */
export const FINDING_SEVERITY: Record<'hinweis' | 'pruefen' | 'kritisch', string> = {
  hinweis: 'info',
  pruefen: 'medium',
  kritisch: 'critical',
}

/** Reconciliation-Status (DE-Engine) → Schema-reconciliation.status. */
export function canonicalReconStatus(statusDe: 'bestanden' | 'abweichung' | 'nicht_pruefbar', deltaIsZero: boolean): string {
  if (statusDe === 'bestanden') return deltaIsZero ? 'closed' : 'within_tolerance'
  if (statusDe === 'abweichung') return 'mismatch'
  return 'not_verifiable'
}

// ── Input (enge Struktur-Subsets; der Aufrufer liefert plain objects) ───────

export interface CanonicalDocumentInput {
  id: string
  fileName: string
  /** SHA-256 (hex, 64) der Originalbytes — vom App-Layer berechnet. */
  sha256: string
  byteSize: number
  /** null = Legacyquelle fehlt (storage_key nie geschrieben, Loop 5). */
  storageKey: string | null
  templateType: string | null
  /** Persistierte Capability-Matrix (g60_meta.capability_matrix) oder null. */
  capabilityMatrix: unknown
  supplier?: string | null
  partNumber?: string | null
  partName?: string | null
  variant?: string | null
  quotationDate?: string | null
}

export interface CanonicalFindingInput {
  id: string
  category: string
  severityDe: 'hinweis' | 'pruefen' | 'kritisch'
  statementDe: string
  differenceIds?: readonly string[]
}

export interface CanonicalReconciliationInput {
  id: string
  documentRole: 'award' | 'current'
  ruleId: string
  statusDe: 'bestanden' | 'abweichung' | 'nicht_pruefbar'
  calculated?: number | null
  reported?: number | null
  difference?: number | null
  tolerance?: number | null
  explanationDe?: string
}

export interface CanonicalActionInput {
  id: string
  titleDe: string
  priority: 'low' | 'medium' | 'high' | 'critical'
  findingIds?: readonly string[]
  differenceIds?: readonly string[]
  potentialAmount?: number | null
  potentialUnit?: string
}

export interface CanonicalValidationCheckInput {
  checkId: string
  status: 'pass' | 'fail' | 'not_verifiable' | 'not_applicable'
  severity: 'info' | 'warning' | 'critical'
  message?: string
}

export interface CanonicalExportInput {
  comparisonId: string
  language: 'de-DE' | 'en-GB' | 'en-US' | 'mixed'
  award: CanonicalDocumentInput
  current: CanonicalDocumentInput
  differences: readonly DifferenceRecord[]
  /** metric_id je differenceId (SUM: metricKey; sonst deterministischer
   * Fallback aus area+anchorRow). */
  metricIdByDifferenceId?: ReadonlyMap<string, string>
  /** Die SUM-Metrik-Diffs MIT Zell-Provenienz (OverviewRun.summaryDiffs) —
   * Quelle des Entity/Fact-Graphen (Etappe 2a). Leer = keine Facts, das
   * ist bei leerem Lauf wahr; der Aufrufer MUSS die echte Liste geben. */
  summaryDiffs: readonly SummaryMetricDiff[]
  findings: readonly CanonicalFindingInput[]
  reconciliations: readonly CanonicalReconciliationInput[]
  actions: readonly CanonicalActionInput[]
  validationChecks: readonly CanonicalValidationCheckInput[]
  engineVersion: string
  engineConfigVersion: string
  parserVersion: string
  /** true nur, wenn der zugrunde liegende Lauf KI-frei war — const-Zusage. */
  aiSafeToSummarize: boolean
  aiBlockingReasonsDe?: readonly string[]
}

// ── Bausteine ────────────────────────────────────────────────────────────────

const AREA_KEYS = [
  'metadata',
  'input_workbooks',
  'comparison_classification',
  'comparison_methodology',
  'capabilities_and_coverage',
  'executive_summary',
  'summary_cost_bridge',
  'identity_and_premise_changes',
  'formula_and_value_changes',
  'material_analysis',
  'manufacturing_analysis',
  'sbm_tooling_analysis',
  'logistics_and_customs_analysis',
  'raw_material_and_risk_analysis',
  'local_content_and_co2e_analysis',
  'workbook_structure_comparison',
  'reconciliation',
  'data_quality',
  'negotiation_and_actions',
  'all_differences',
  'traceability_index',
  'ai_guide',
  'presentation_package',
  'validation',
] as const

function canonicalCapabilityModules(
  matrix: unknown,
): Record<string, { status: string; confidence: string; reason_code: string | null }> {
  const out: Record<string, { status: string; confidence: string; reason_code: string | null }> = {}
  if (matrix === null || matrix === undefined || typeof matrix !== 'object') return out
  const modules = (matrix as { modules?: unknown }).modules
  if (!Array.isArray(modules)) return out
  for (const m of modules) {
    if (m === null || typeof m !== 'object') continue
    const name = (m as { module?: unknown }).module
    const status = (m as { status?: unknown }).status
    const confidence = (m as { confidence?: unknown }).confidence
    if (typeof name !== 'string' || typeof status !== 'string') continue
    out[name] = {
      status: CANONICAL_CAPABILITY_STATUS[status] ?? 'not_computed',
      confidence: decimalString(typeof confidence === 'number' && Number.isFinite(confidence) ? confidence : 0),
      reason_code: null,
    }
  }
  return out
}

function canonicalDocument(doc: CanonicalDocumentInput, role: 'award' | 'current', input: CanonicalExportInput) {
  return {
    document_id: doc.id,
    source_file_name: doc.fileName,
    source_sha256: `sha256:${doc.sha256}`,
    display_name: doc.fileName,
    role,
    qaf_family: doc.templateType ?? 'unknown',
    adapter: {
      adapter_id: 'kadi-v2-qaf-parser',
      adapter_version: input.parserVersion,
      confidence: '1',
    },
    // Ehrlich „teilweise semantisch": Werte und Zellen sind erschlossen,
    // Formeln/Blattnamen hängen am Lauf (Kriterium-2-Spalten nur bei
    // frischen Ingests) — semantic_complete wäre eine Übertreibung.
    extraction_status: 'partial_semantic',
    source_artifact_id: `artifact:${doc.id}`,
    ...(doc.supplier != null ? { supplier: doc.supplier } : {}),
    ...(doc.partNumber != null ? { part_number: doc.partNumber } : {}),
    ...(doc.partName != null ? { part_designation: doc.partName } : {}),
    ...(doc.variant != null ? { variant: doc.variant } : {}),
    ...(doc.quotationDate != null ? { quotation_date: doc.quotationDate } : {}),
  }
}

function canonicalSourceArtifact(doc: CanonicalDocumentInput) {
  return {
    artifact_id: `artifact:${doc.id}`,
    document_id: doc.id,
    original_file_name: doc.fileName,
    sha256: `sha256:${doc.sha256}`,
    byte_size: doc.byteSize,
    mime_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    immutability: 'original_read_only',
    availability: doc.storageKey !== null ? 'available' : 'legacy_reference_missing',
    ...(doc.storageKey !== null ? { storage_object_key: doc.storageKey } : {}),
  }
}

function metricIdFor(r: DifferenceRecord, map: ReadonlyMap<string, string> | undefined): string {
  const mapped = map?.get(r.differenceId)
  if (mapped !== undefined) return mapped
  return `${r.area.toLowerCase()}:row:${r.anchorRow ?? 'na'}`
}

function canonicalDifference(
  r: DifferenceRecord,
  input: CanonicalExportInput,
  factIdsByMetricId: ReadonlyMap<string, { base: string[]; comparison: string[] }>,
) {
  const status =
    r.deltaNullReason !== null
      ? DIFFERENCE_STATUS_BY_NULL_REASON[r.deltaNullReason]
      : DIFFERENCE_STATUS_BY_KIND[r.kind]
  const metricId = metricIdFor(r, input.metricIdByDifferenceId)
  // Etappe 2a: Fact-Verweise nur, wo Facts existieren (SUM mit Provenienz) —
  // leere Arrays werden weggelassen, nicht erfunden (Felder sind optional).
  const factIds = factIdsByMetricId.get(metricId)
  return {
    difference_id: r.differenceId,
    base_document_id: input.award.id,
    comparison_document_id: input.current.id,
    metric_id: metricId,
    difference_status: status,
    ...(r.delta !== null ? { delta_absolute: decimalString(r.delta, r.unit) } : {}),
    ...(r.unit !== '' ? { unit: r.unit } : {}),
    ...(r.formulaFindingKind !== null ? { formula_status: r.formulaFindingKind } : {}),
    ...(factIds !== undefined && factIds.base.length > 0 ? { base_fact_ids: [...factIds.base] } : {}),
    ...(factIds !== undefined && factIds.comparison.length > 0
      ? { comparison_fact_ids: [...factIds.comparison] }
      : {}),
  }
}

// ── presentation_package (Loop 12 PR 1) ─────────────────────────────────────

export interface PresentationSlide {
  slide_id: string
  title_de: string
  kind: 'overview' | 'top_differences' | 'data_quality' | 'actions'
  /** Referenzen in die kanonischen Sammlungen — Exit-Kriterium Loop 12:
   * „Presentation Package referenziert Difference IDs". Der Renderer (PR 2)
   * liest AUSSCHLIESSLICH diese Struktur und rechnet nichts selbst. */
  difference_ids: string[]
  finding_ids: string[]
  action_ids: string[]
  /** Wiedergabe-Kopien aus den kanonischen Sammlungen (keine Neuberechnung —
   * jede Zahl existiert wortgleich in differences[]/actions[]). */
  content: Record<string, unknown>
}

/** Deterministische Folien-Struktur aus den BEREITS gemappten kanonischen
 * Sammlungen. Pure; Top-N nach |delta_absolute| (Strings → Zahlvergleich nur
 * fürs Sortieren, die Werte selbst bleiben die kanonischen Strings). */
export function buildPresentationPackage(
  differences: ReadonlyArray<Record<string, unknown>>,
  findings: ReadonlyArray<Record<string, unknown>>,
  actions: ReadonlyArray<Record<string, unknown>>,
  topN = 10,
): { slides: PresentationSlide[] } {
  const byAbsDelta = [...differences].sort((a, b) => {
    const da = a.delta_absolute !== undefined ? Math.abs(Number(a.delta_absolute)) : -1
    const db = b.delta_absolute !== undefined ? Math.abs(Number(b.delta_absolute)) : -1
    if (db !== da) return db - da
    // Ordinal statt localeCompare (Review-Befund): ohne Locale-Argument
    // hinge der Tiebreak an der Host-Locale — ordinale Vergleiche sind
    // spec-garantiert umgebungsunabhängig (Azure-Migration!).
    const ida = String(a.difference_id)
    const idb = String(b.difference_id)
    return ida < idb ? -1 : ida > idb ? 1 : 0
  })
  const top = byAbsDelta.slice(0, topN)

  const slides: PresentationSlide[] = [
    {
      slide_id: 'slide-overview',
      title_de: 'Überblick',
      kind: 'overview',
      difference_ids: differences.map((d) => String(d.difference_id)),
      finding_ids: findings.map((f) => String(f.finding_id)),
      action_ids: actions.map((a) => String(a.action_id)),
      content: {
        difference_count: differences.length,
        finding_count: findings.length,
        action_count: actions.length,
      },
    },
    {
      slide_id: 'slide-top-differences',
      title_de: `Größte Differenzen (Top ${top.length})`,
      kind: 'top_differences',
      difference_ids: top.map((d) => String(d.difference_id)),
      finding_ids: [],
      action_ids: [],
      content: {
        rows: top.map((d) => ({
          difference_id: d.difference_id,
          metric_id: d.metric_id,
          difference_status: d.difference_status,
          ...(d.delta_absolute !== undefined ? { delta_absolute: d.delta_absolute } : {}),
          ...(d.unit !== undefined ? { unit: d.unit } : {}),
        })),
      },
    },
  ]
  if (findings.length > 0) {
    slides.push({
      slide_id: 'slide-data-quality',
      title_de: 'Datenqualität',
      kind: 'data_quality',
      difference_ids: [],
      finding_ids: findings.map((f) => String(f.finding_id)),
      action_ids: [],
      content: {
        rows: findings.map((f) => ({ finding_id: f.finding_id, severity: f.severity, statement: f.statement })),
      },
    })
  }
  if (actions.length > 0) {
    slides.push({
      slide_id: 'slide-actions',
      title_de: 'Maßnahmen',
      kind: 'actions',
      difference_ids: [],
      finding_ids: [],
      action_ids: actions.map((a) => String(a.action_id)),
      content: {
        rows: actions.map((a) => ({ action_id: a.action_id, title: a.title, priority: a.priority })),
      },
    })
  }
  return { slides }
}

/**
 * complete NUR, wenn jeder Pflichtbereich einen zulässigen End-Zustand hat
 * (populated/empty_verified/not_applicable — not_computed/blocked sind
 * ehrliche Unfertigkeit) UND die Validierung besteht (Exit-Kriterium).
 */
export function deriveAnalysisStatus(
  areaStates: Readonly<Record<string, string>>,
  validationPassed: boolean,
): 'complete' | 'partial' | 'blocked' | 'failed' {
  const states = Object.values(areaStates)
  if (states.some((s) => s === 'blocked')) return 'blocked'
  if (!validationPassed) return 'failed'
  if (states.every((s) => s === 'populated' || s === 'empty_verified' || s === 'not_applicable')) {
    return 'complete'
  }
  return 'partial'
}

// ── Hauptfunktion ────────────────────────────────────────────────────────────

/**
 * Baut das kanonische Dokument OHNE canonical_content_hash — der Hash wird
 * vom Aufrufer über GENAU dieses Objekt gerechnet und dann via
 * `withCanonicalContentHash` eingesetzt (Selbstbezug: das Feld, das den
 * Hash trägt, geht nicht in den gehashten Inhalt ein).
 */
export function buildCanonicalExport(input: CanonicalExportInput): Record<string, unknown> {
  const documents = [canonicalDocument(input.award, 'award', input), canonicalDocument(input.current, 'current', input)]
  const sourceArtifacts = [canonicalSourceArtifact(input.award), canonicalSourceArtifact(input.current)]

  // Etappe 2a: Einheiten je metric_id aus den bestehenden Differenz-Records
  // ableiten (eine Quelle — keine zweite Einheiten-Registry im Graph).
  const unitByMetricId = new Map<string, string>()
  for (const r of input.differences) {
    if (r.unit !== '' && r.unit !== null) {
      const mid = metricIdFor(r, input.metricIdByDifferenceId)
      if (!unitByMetricId.has(mid)) unitByMetricId.set(mid, r.unit)
    }
  }
  const graph = buildCanonicalGraph({
    award: {
      id: input.award.id,
      partNumber: input.award.partNumber ?? null,
      partName: input.award.partName ?? null,
      variant: input.award.variant ?? null,
      supplier: input.award.supplier ?? null,
    },
    current: {
      id: input.current.id,
      partNumber: input.current.partNumber ?? null,
      partName: input.current.partName ?? null,
      variant: input.current.variant ?? null,
      supplier: input.current.supplier ?? null,
    },
    summaryDiffs: input.summaryDiffs,
    unitByMetricId,
    decimal: decimalString,
  })
  const graphIssues = validateCanonicalGraph(graph, [input.award.id, input.current.id])

  const differences = input.differences.map((r) => canonicalDifference(r, input, graph.factIdsByMetricId))

  const findings = input.findings.map((f) => ({
    finding_id: f.id,
    category: f.category,
    severity: FINDING_SEVERITY[f.severityDe],
    statement: f.statementDe,
  }))

  const reconciliations = input.reconciliations.map((rec) => ({
    reconciliation_id: rec.id,
    document_id: rec.documentRole === 'award' ? input.award.id : input.current.id,
    rule_id: rec.ruleId,
    status: canonicalReconStatus(rec.statusDe, (rec.difference ?? 0) === 0),
    ...(rec.calculated != null ? { calculated_value: decimalString(rec.calculated, 'EUR_per_piece') } : {}),
    ...(rec.reported != null ? { reported_value: decimalString(rec.reported, 'EUR_per_piece') } : {}),
    ...(rec.difference != null ? { difference: decimalString(rec.difference, 'EUR_per_piece') } : {}),
    ...(rec.tolerance != null ? { tolerance: decimalString(rec.tolerance, 'EUR_per_piece') } : {}),
    ...(rec.explanationDe !== undefined ? { explanation: rec.explanationDe } : {}),
  }))

  const actions = input.actions.map((a) => ({
    action_id: a.id,
    title: a.titleDe,
    priority: a.priority,
    status: 'open',
    ...(a.findingIds !== undefined && a.findingIds.length > 0 ? { finding_ids: [...a.findingIds] } : {}),
    ...(a.potentialAmount != null
      ? { potential: { amount: decimalString(a.potentialAmount, a.potentialUnit ?? 'EUR_per_piece'), unit: a.potentialUnit ?? 'EUR_per_piece' } }
      : {}),
  }))

  const checks = input.validationChecks.map((c) => ({
    check_id: c.checkId,
    status: c.status,
    severity: c.severity,
    ...(c.message !== undefined ? { message: c.message } : {}),
  }))
  // Etappe 2a: ajv validiert nur die Form — Querverweise (fact→entity,
  // difference→fact, relationship-Enden) prüft validateCanonicalGraph;
  // ein Verstoß ist ein benannter fail, kein stiller Durchmarsch.
  checks.push({
    check_id: 'entity_graph_integrity',
    status: graphIssues.length === 0 ? 'pass' : 'fail',
    severity: 'critical',
    ...(graphIssues.length > 0 ? { message: graphIssues.join('; ') } : {}),
  })
  const counts = {
    pass: checks.filter((c) => c.status === 'pass').length,
    fail: checks.filter((c) => c.status === 'fail').length,
    not_verifiable: checks.filter((c) => c.status === 'not_verifiable').length,
    not_applicable: checks.filter((c) => c.status === 'not_applicable').length,
  }
  const validationPassed = counts.fail === 0

  // Areas: echte Belege wo Daten existieren, sonst ehrlich not_computed.
  const areaState: Record<string, string> = {}
  const areas: Record<string, unknown> = {}
  const populatedArea = (key: string, refs: Partial<Record<'difference_ids' | 'finding_ids' | 'reconciliation_ids' | 'action_ids', string[]>>, content: unknown) => {
    areaState[key] = 'populated'
    areas[key] = { state: 'populated', applicability: 'required', content, ...refs }
  }
  for (const key of AREA_KEYS) {
    areaState[key] = 'not_computed'
    areas[key] = { state: 'not_computed', applicability: 'required', content: null }
  }
  populatedArea('metadata', {}, {
    comparison_id: input.comparisonId,
    documents: documents.map((d) => ({ document_id: d.document_id, role: d.role, file_name: d.source_file_name })),
  })
  populatedArea('input_workbooks', {}, {
    artifacts: sourceArtifacts.map((a) => ({ artifact_id: a.artifact_id, availability: a.availability })),
  })
  populatedArea('capabilities_and_coverage', {}, {
    award: canonicalCapabilityModules(input.award.capabilityMatrix),
    current: canonicalCapabilityModules(input.current.capabilityMatrix),
  })
  if (differences.length > 0) {
    populatedArea('all_differences', { difference_ids: differences.map((d) => d.difference_id as string) }, {
      count: differences.length,
    })
  }
  if (findings.length > 0) {
    populatedArea('data_quality', { finding_ids: findings.map((f) => f.finding_id) }, { count: findings.length })
  }
  if (reconciliations.length > 0) {
    populatedArea('reconciliation', { reconciliation_ids: reconciliations.map((r) => r.reconciliation_id) }, {
      count: reconciliations.length,
    })
  }
  if (actions.length > 0) {
    populatedArea('negotiation_and_actions', { action_ids: actions.map((a) => a.action_id) }, { count: actions.length })
  }
  // Loop 12 PR 1: der Präsentations-Inhalt — deterministische Folien aus den
  // bereits gemappten Sammlungen, jede mit Referenz-IDs (der PPTX-Renderer
  // liest NUR diese Struktur und rechnet nichts selbst).
  const praesentation = buildPresentationPackage(differences, findings, actions)
  populatedArea(
    'presentation_package',
    { difference_ids: [...new Set(praesentation.slides.flatMap((s) => s.difference_ids))] },
    praesentation,
  )
  populatedArea('validation', {}, { passed: validationPassed, counts })

  const analysisStatus = deriveAnalysisStatus(areaState, validationPassed)

  // Differenz-Bezüge von findings/actions: Schema hat kein difference_ids-
  // Feld an diesen Objekten — der Schema-eigene extensions-Block trägt sie
  // (dokumentierte Abweichung, Design-Memo §5.1).
  const findingDifferenceIds = Object.fromEntries(
    input.findings.filter((f) => (f.differenceIds ?? []).length > 0).map((f) => [f.id, [...(f.differenceIds ?? [])]]),
  )
  const actionDifferenceIds = Object.fromEntries(
    input.actions.filter((a) => (a.differenceIds ?? []).length > 0).map((a) => [a.id, [...(a.differenceIds ?? [])]]),
  )

  // Zurückgehaltene Facts (Metrik-Seite ohne sheet-Provenienz) sichtbar
  // machen — nichts verschwindet still (Etappe-2a-Regel).
  const factsWithheld = graph.withheldFacts.map((w) => ({
    metric_id: w.metric_id,
    document_role: w.document_role,
    reason: w.reason,
  }))

  return {
    schema_version: CANONICAL_SCHEMA_VERSION,
    export_profile: CANONICAL_EXPORT_PROFILE,
    analysis_status: analysisStatus,
    comparison_set: {
      comparison_set_id: input.comparisonId,
      comparison_mode: 'pair_detail',
      document_ids: [input.award.id, input.current.id],
      selected_pair: [input.award.id, input.current.id],
      language: input.language,
    },
    source_artifacts: sourceArtifacts,
    documents,
    capabilities: {
      documents: [
        { document_id: input.award.id, modules: canonicalCapabilityModules(input.award.capabilityMatrix) },
        { document_id: input.current.id, modules: canonicalCapabilityModules(input.current.capabilityMatrix) },
      ],
      comparison_modules: {},
    },
    entities: graph.entities,
    facts: graph.facts,
    relationships: graph.relationships,
    // Cross-Dokument-Identität (mapping_groups) braucht eine eigene
    // mapping_type/method-Entscheidung und grenzt an Multi-QAF-Identität
    // (deferred) — bleibt benannt leer, kein minItems im Schema.
    mapping_groups: [],
    differences,
    reconciliations,
    findings,
    actions,
    manual_overrides: [],
    unmapped_regions: [],
    analysis_areas: areas,
    // Loop 13 (Review-Fix #487): das Gate ist ABGELEITET, kein Durchreich-Flag.
    // safe_to_summarize ist nur dann true, wenn der Aufrufer keinen
    // strukturellen Blocker meldet UND die Kern-Validierung dieses Dokuments
    // ohne fail durchläuft — die gescheiterten Prüfungen stehen als Gründe
    // dabei. Vorher stand hier der Etappe-1-Stub (hart false), der die
    // gesamte KI-Fläche für jeden Vergleich toterklärt hätte.
    ai_ready: {
      safe_to_summarize: input.aiSafeToSummarize && validationPassed,
      numeric_generation_by_ai_prohibited: true,
      sections: {},
      ...(() => {
        const gruende = [
          ...(input.aiBlockingReasonsDe ?? []),
          ...checks
            .filter((c) => c.status === 'fail')
            .map((c) => `Kern-Prüfung fehlgeschlagen: ${c.check_id}`),
        ]
        return gruende.length > 0 ? { blocking_reasons: gruende } : {}
      })(),
    },
    validation: { passed: validationPassed, checks, counts },
    run_manifest: {
      canonical_schema_version: CANONICAL_SCHEMA_VERSION,
      comparison_engine_version: input.engineVersion,
      // Die Engine führt EINEN configVersion-Stempel — alle Policy-Felder
      // tragen ihn, bis eigene Versionen existieren (Design-Memo §2).
      mapping_ruleset_version: input.engineConfigVersion,
      capability_ruleset_version: input.engineConfigVersion,
      rounding_profile: input.engineConfigVersion,
      currency_policy: input.engineConfigVersion,
      locale_policy: input.engineConfigVersion,
      adapter_versions: { 'kadi-v2-qaf-parser': input.parserVersion },
      source_hashes: {
        [input.award.id]: `sha256:${input.award.sha256}`,
        [input.current.id]: `sha256:${input.current.sha256}`,
      },
      source_artifact_ids: sourceArtifacts.map((a) => a.artifact_id),
      ai_used_for_comparison: false,
      ui_used_for_calculation: false,
      canonical_content_hash: CANONICAL_HASH_PLACEHOLDER,
    },
    ...(Object.keys(findingDifferenceIds).length > 0 ||
    Object.keys(actionDifferenceIds).length > 0 ||
    factsWithheld.length > 0
      ? {
          extensions: {
            'supplierpulse.current_engine': {
              finding_difference_ids: findingDifferenceIds,
              action_difference_ids: actionDifferenceIds,
              ...(factsWithheld.length > 0 ? { facts_withheld: factsWithheld } : {}),
            },
          },
        }
      : {}),
  }
}

/** Setzt den über das Platzhalter-Dokument gerechneten Hash ein. */
export function withCanonicalContentHash(doc: Record<string, unknown>, hashHex: string): Record<string, unknown> {
  const manifest = doc.run_manifest as Record<string, unknown>
  return { ...doc, run_manifest: { ...manifest, canonical_content_hash: `sha256:${hashHex}` } }
}
