// Summary view-model (KAR-840): KPI tiles + QAF form lines from persisted
// qaf_summary_diff rows. Ports V11's kpi()/formLines() for the pairwise case —
// the six form lines mirror the official QAF sheet positions, so their labels
// stay in the sheet's English wording. Pure; UI-independent and unit-tested.
//
// Single-metric lines pass the PERSISTED delta_percent through verbatim (same
// source the KPI tiles read) — only the combined scrap line computes locally,
// so one metric can never show two different percentages on one screen.

import { METRIC_LABELS_DE, SUMMARY_METRIC_KEYS, type SummaryMetricKey } from './summary-metrics'
import {
  additiveRowSpecs,
  SUMMARY_ROW_REGISTRY,
  type SummaryRowRole,
  type SummaryRowSpec,
} from './summary-row-registry'

/** Subset of qaf_summary_diff columns this view-model consumes. */
export interface SummaryDiffRowData {
  metric_key: string
  alt_value: number | null
  neu_value: number | null
  delta_absolute: number | null
  delta_percent: number | null
  currency: string | null
  /** Engine status band (anstieg/senkung/…) — shown in the metrics table. */
  status: string | null
  /** A1 source cells (provenance) — shown in the metrics table. */
  source_alt: string | null
  source_neu: string | null
  /** Zeilenlabel wörtlich aus der jeweiligen Datei (KAR-996 / V2 R-04).
   *  Optional, weil ältere persistierte Vergleiche die Spalten nicht haben. */
  label_file_alt?: string | null
  label_file_neu?: string | null
  label_verified_alt?: boolean | null
  label_verified_neu?: boolean | null
}

/**
 * Der in-memory-Zwilling einer persistierten Diff-Zeile: exakt die Felder von
 * `SummaryMetricDiff` (summary-metrics.ts), die `SummaryDiffRowData` trägt.
 * Bewusst als Struktur-Subset statt Import (kein neuer Import-Kreis; dasselbe
 * Muster wie ManufacturingParseDegradation in plausibility.ts).
 */
export interface MetricDiffLike {
  metricKey: string
  altValue: number | null
  neuValue: number | null
  deltaAbsolute: number | null
  deltaPercent: number | null
  currency: string | null
  status: string
  sourceAlt: string | null
  sourceNeu: string | null
  labelFileAlt?: string | null
  labelFileNeu?: string | null
  labelVerifiedAlt?: boolean | null
  labelVerifiedNeu?: boolean | null
}

/**
 * Übersetzt einen frisch gerechneten Summary-Diff in die Row-Form, die
 * `buildBuckets`/`buildBridge`/`buildMetricsTable` konsumieren — Feld für
 * Feld, ohne Neuberechnung. Zweck: eine Seite, die den Diff bereits im
 * Speicher hat (overview-run), kann Kostenstruktur und Brücke aus GENAU
 * dieser Quelle speisen statt aus einem zweiten, unabhängig gerechneten
 * Stand — sonst können Kacheln und Brücke einander widersprechen
 * (Ein-Quellen-Invariante oben; Review-Befund 10.08.2026). Pure.
 */
export function rowsFromMetricDiffs(diffs: readonly MetricDiffLike[]): SummaryDiffRowData[] {
  return diffs.map((d) => ({
    metric_key: d.metricKey,
    alt_value: d.altValue,
    neu_value: d.neuValue,
    delta_absolute: d.deltaAbsolute,
    delta_percent: d.deltaPercent,
    currency: d.currency,
    status: d.status,
    source_alt: d.sourceAlt,
    source_neu: d.sourceNeu,
    label_file_alt: d.labelFileAlt ?? null,
    label_file_neu: d.labelFileNeu ?? null,
    label_verified_alt: d.labelVerifiedAlt ?? null,
    label_verified_neu: d.labelVerifiedNeu ?? null,
  }))
}

export interface KpiValue {
  alt: number | null
  neu: number | null
  deltaAbsolute: number | null
  deltaPercent: number | null
}

export interface SummaryKpis {
  quotationPrice: KpiValue
  materialCosts: KpiValue
  totalProductionCosts: KpiValue
  currency: string | null
}

export interface FormLine {
  label: string
  alt: number | null
  neu: number | null
  /** Persisted engine delta ((NEU − ALT) / |ALT|); locally summed only for scrap. */
  deltaPercent: number | null
  /** Bold sum line (TPC, QP). */
  total: boolean
  /** True when both sides are present and (near-)identical. */
  same: boolean
}

const KPI_KEYS = ['quotationPrice', 'materialCosts', 'totalProductionCosts'] as const
const SAME_EPSILON = 0.005

function byKey(rows: SummaryDiffRowData[]): Map<string, SummaryDiffRowData> {
  return new Map(rows.map((r) => [r.metric_key, r]))
}

/**
 * Deterministic currency pick: first metric in canonical key order that carries
 * one — independent of the (unordered) row order the DB returns.
 */
export function pickCurrency(rows: SummaryDiffRowData[]): string | null {
  const m = byKey(rows)
  for (const key of SUMMARY_METRIC_KEYS) {
    const c = m.get(key)?.currency
    if (c) return c
  }
  return null
}

function kpiOf(row: SummaryDiffRowData | undefined): KpiValue {
  return {
    alt: row?.alt_value ?? null,
    neu: row?.neu_value ?? null,
    deltaAbsolute: row?.delta_absolute ?? null,
    deltaPercent: row?.delta_percent ?? null,
  }
}

/**
 * The three V11 headline tiles (Angebotspreis, Material, Herstellkosten).
 * Null when none of the three metrics was extracted — callers hide the section.
 */
export function buildSummaryKpis(rows: SummaryDiffRowData[]): SummaryKpis | null {
  const m = byKey(rows)
  if (!KPI_KEYS.some((k) => m.has(k))) return null
  return {
    quotationPrice: kpiOf(m.get('quotationPrice')),
    materialCosts: kpiOf(m.get('materialCosts')),
    totalProductionCosts: kpiOf(m.get('totalProductionCosts')),
    currency: pickCurrency(rows),
  }
}

function sameOf(alt: number | null, neu: number | null): boolean {
  return alt !== null && neu !== null && Math.abs(neu - alt) < SAME_EPSILON
}

/** Line backed 1:1 by a persisted diff row — deltas pass through verbatim. */
function lineFromRow(label: string, row: SummaryDiffRowData | undefined, total: boolean): FormLine {
  const alt = row?.alt_value ?? null
  const neu = row?.neu_value ?? null
  return { label, alt, neu, deltaPercent: row?.delta_percent ?? null, total, same: sameOf(alt, neu) }
}

/** Sum two optional components: null only when both are missing (V11 scrap rule). */
function partialSum(a: number | null, b: number | null): number | null {
  if (a === null && b === null) return null
  return (a ?? 0) + (b ?? 0)
}

/**
 * The single source of the V11 scrap-combination rule: scrap per side =
 * scrapMaterial + scrapManufacturing, null only when both are missing.
 * Used by both the form line ('4. Scrap costs') and the ScrapB chart bucket.
 */
function scrapPair(m: Map<string, SummaryDiffRowData>): { alt: number | null; neu: number | null } {
  return {
    alt: partialSum(m.get('scrapMaterial')?.alt_value ?? null, m.get('scrapManufacturing')?.alt_value ?? null),
    neu: partialSum(m.get('scrapMaterial')?.neu_value ?? null, m.get('scrapManufacturing')?.neu_value ?? null),
  }
}

// ── Cost-structure buckets (V11 section 1, summary mode) ─────────────────────

export type BucketKey = 'Material' | 'Labor' | 'Manufacturing' | 'FWZ' | 'ScrapB' | 'SGA' | 'Profit'

export interface BucketDatum {
  key: BucketKey
  /** V11 display label (DE). */
  label: string
  /** Chart values — absent metrics coerce to 0 (V11 stands rule). */
  alt: number
  neu: number
  /** Persisted delta; null when a side was not extracted. */
  delta: number | null
}

/** Single source for the bucket display labels (chart + bridge + tooltips). */
export const BUCKET_LABEL_DE: Record<BucketKey | 'Other', string> = {
  Material: 'Material',
  Labor: 'Labor',
  Manufacturing: 'Manufacturing',
  // Befund F-03: dieser Block landete mangels eigener Definition im
  // Sammelposten „Übrige" — im Anlassfall war „Übrige" exakt der FWZ-Wert.
  FWZ: 'Vorrichtungen und Folgewerkzeuge',
  ScrapB: 'Scrap',
  SGA: 'SG&A',
  Profit: 'Profit',
  Other: 'Übrige',
}

// Buckets backed 1:1 by a persisted metric; the combined Scrap bucket is
// derived via scrapPair below (no placeholder metric key).
const BUCKET_METRIC_DEFS: Array<{ key: Exclude<BucketKey, 'ScrapB'>; metric: string }> = [
  { key: 'Material', metric: 'materialCosts' },
  { key: 'Manufacturing', metric: 'manufacturingCosts' },
  { key: 'FWZ', metric: 'devicesAndTools' },
  { key: 'SGA', metric: 'otherSurcharges' },
]

/**
 * The four summary-mode cost buckets (V11 buckets order for pairwise files).
 * Null when none of the bucket metrics was extracted — callers hide the chart.
 */
export function buildBuckets(rows: SummaryDiffRowData[]): BucketDatum[] | null {
  const m = byKey(rows)
  const scrap = scrapPair(m)

  const metricBucket = (def: (typeof BUCKET_METRIC_DEFS)[number]): BucketDatum => {
    const row = m.get(def.metric)
    return {
      key: def.key,
      label: BUCKET_LABEL_DE[def.key],
      alt: row?.alt_value ?? 0,
      neu: row?.neu_value ?? 0,
      delta: row?.delta_absolute ?? null,
    }
  }

  // Reihenfolge = Reihenfolge im Preisblock: 1. Material · 2. Fertigung ·
  // 3. FWZ · 4. Ausschuss · 5. Zuschläge.
  const data: BucketDatum[] = [
    metricBucket(BUCKET_METRIC_DEFS[0]),
    metricBucket(BUCKET_METRIC_DEFS[1]),
    metricBucket(BUCKET_METRIC_DEFS[2]),
    {
      key: 'ScrapB',
      label: BUCKET_LABEL_DE.ScrapB,
      alt: scrap.alt ?? 0,
      neu: scrap.neu ?? 0,
      delta: scrap.alt !== null && scrap.neu !== null ? scrap.neu - scrap.alt : null,
    },
    metricBucket(BUCKET_METRIC_DEFS[3]),
  ]

  const anyPresent =
    scrap.alt !== null || scrap.neu !== null || BUCKET_METRIC_DEFS.some(({ metric }) => m.has(metric))
  return anyPresent ? data : null
}

// ── Cost bridge / waterfall (V11 section 2, buildBridgeSummary) ───────────────

export interface BridgeStep {
  /** Bucket the metric feeds (colours the waterfall step composition-wise). */
  bucket: BucketKey | 'Other'
  label: string
  delta: number
}

export interface BridgeCompositionPart {
  key: BucketKey | 'Other'
  label: string
  value: number
}

export interface BridgeData {
  /** ALT quotation price (waterfall anchor). */
  start: number
  /** NEU quotation price. */
  end: number
  delta: number
  steps: BridgeStep[]
  /** NEU price decomposed into buckets + residual 'Übrige'. */
  endComposition: BridgeCompositionPart[]
  /**
   * Was die additiven Schritte NICHT erklären (V2 R-06). Früher wurde dieser
   * Betrag als Schritt „Übrige" eingebucht und die Brücke sah geschlossen aus —
   * im Anlassfall verdeckte das exakt zwei Fehler zugleich (Befund F-01).
   * Er wird jetzt ausgewiesen, nicht verteilt.
   */
  residual: number
  /** Liegt `residual` innerhalb der Toleranz? `false` ist ein Befund, kein Rundungsrest. */
  residualWithinTolerance: boolean
}

// Die Schritte der Brücke sind genau die additiven Zeilen des Preisblocks —
// nicht mehr eine handgepflegte Liste. Die frühere Liste (V11-Erbe) enthielt
// `packagingTransportIncluded` und `customsIncluded`: beides Memo-Unterzeilen,
// die laut Template in keiner Summe auftauchen. Genau sie erzeugten Befund
// F-01. Die Registry ist jetzt die einzige Quelle (V2 R-05/R-06), damit die
// Fehlklasse nicht über eine zweite Liste zurückkommen kann.
const BRIDGE_STEP_KEYS: SummaryMetricKey[] = additiveRowSpecs().map((spec) => spec.metricKey)

/**
 * Die Kandidaten der Verhandlungslisten (V11 POSTEN). Bewusst NICHT dieselbe
 * Menge wie die Brückenschritte: eine Memo-Zeile darf in keiner Summe stehen,
 * ist als Verhandlungsthema aber sehr wohl relevant — „Enthaltene Verpackung
 * und Transport" ist im Anlassfall um 54,6 % gestiegen, das gehört auf den
 * Tisch, nur eben nicht in die Preisbrücke.
 *
 * (Die Spezifikation führt diese Listen später auf die Positionsebene zurück,
 * R-20/R-21 — bis dahin bleiben es Blattzeilen.)
 */
const NEGOTIATION_METRIC_KEYS: SummaryMetricKey[] = [
  'materialCosts',
  'manufacturingCosts',
  'packagingTransportIncluded',
  'customsIncluded',
  'devicesAndTools',
  'scrapMaterial',
  'scrapManufacturing',
  'otherSurcharges',
  'rawMaterialPriceShareMaterial',
  'rawMaterialPriceShareEnergy',
  'customsSupplierToBMW',
  'transportSupplierToBMW',
]

// V11 KEYMAP: metric → bucket; unmapped metrics count as 'Other'.
const BRIDGE_BUCKET: Partial<Record<SummaryMetricKey, BucketKey>> = {
  materialCosts: 'Material',
  manufacturingCosts: 'Manufacturing',
  devicesAndTools: 'FWZ',
  scrapMaterial: 'ScrapB',
  scrapManufacturing: 'ScrapB',
  otherSurcharges: 'SGA',
}

// V11 thresholds: steps below MIN_STEP are noise; a residual below
// MIN_RESIDUAL means the metric deltas explain the QP delta fully.
// Exported: the G60 bridge applies the same thresholds (single source).
export const BRIDGE_MIN_STEP = 0.00005
export const BRIDGE_MIN_RESIDUAL = 0.0005
const MIN_STEP = BRIDGE_MIN_STEP
const MIN_RESIDUAL = BRIDGE_MIN_RESIDUAL

/**
 * Beitrag einer additiven Zeile zur Preisdifferenz.
 *
 * `computeNumericDelta` liefert für einen einseitig fehlenden Wert bewusst
 * `null` (Status `entfallen` bzw. `neu`) — als allgemeine Delta-Aussage ist das
 * richtig, denn ohne Gegenwert lässt sich keine Veränderung *messen*.
 *
 * Für die Brücke gilt aber ein engerer, harter Zusammenhang: der Angebotspreis
 * IST die Summe seiner additiven Blöcke. Fällt ein Block weg, sinkt der Preis
 * um genau seinen bisherigen Betrag; kommt einer hinzu, steigt er um seinen
 * neuen. Das ist keine Annahme über die Datei, sondern die Definition der
 * Summe — und die Brücke prüft sich selbst daran: geht das Residual nicht auf,
 * wird es ausgewiesen statt verteilt.
 *
 * Genau dieser fehlende Beitrag war die zweite Hälfte von Befund F-01: der
 * entfallene Block tauchte nirgends auf, und der Restposten sprang ein.
 */
function bridgeContribution(r: SummaryDiffRowData): number | null {
  if (r.delta_absolute !== null && r.delta_absolute !== undefined) return r.delta_absolute
  if (r.alt_value !== null && r.neu_value === null) return -r.alt_value
  if (r.alt_value === null && r.neu_value !== null) return r.neu_value
  return null
}

/**
 * Waterfall from ALT to NEU quotation price (V11 buildBridgeSummary): one step
 * per non-zero metric delta plus a residual 'Übrige'. Null without both QP sides.
 */
export function buildBridge(rows: SummaryDiffRowData[]): BridgeData | null {
  const m = byKey(rows)
  const qp = m.get('quotationPrice')
  if (!qp || qp.alt_value === null || qp.neu_value === null) return null

  const start = qp.alt_value
  const end = qp.neu_value
  const delta = qp.delta_absolute ?? end - start

  const steps: BridgeStep[] = []
  for (const key of BRIDGE_STEP_KEYS) {
    const r = m.get(key)
    const d = r === undefined ? null : bridgeContribution(r)
    if (d === null || Math.abs(d) <= MIN_STEP) continue
    steps.push({ bucket: BRIDGE_BUCKET[key] ?? 'Other', label: METRIC_LABELS_DE[key], delta: d })
  }
  // Kein Ausgleichsposten: was die additiven Zeilen nicht erklären, wird
  // ausgewiesen. Ein eingebuchter Rest lässt die Brücke geschlossen aussehen
  // und verdeckt genau die Fehler, die er kompensiert (F-01).
  const residual = delta - steps.reduce((s, x) => s + x.delta, 0)
  const residualWithinTolerance = Math.abs(residual) <= MIN_RESIDUAL

  const scrapNeu = scrapPair(m).neu ?? 0
  const bucketsNeu: Array<[BucketKey, number]> = [
    ['Material', m.get('materialCosts')?.neu_value ?? 0],
    ['Manufacturing', m.get('manufacturingCosts')?.neu_value ?? 0],
    ['FWZ', m.get('devicesAndTools')?.neu_value ?? 0],
    ['ScrapB', scrapNeu],
    ['SGA', m.get('otherSurcharges')?.neu_value ?? 0],
  ]
  const otherNeu = end - bucketsNeu.reduce((s, [, v]) => s + v, 0)
  // A stacked composition is only honest when every part is non-negative —
  // with extraction noise the buckets can exceed the NEU price. Then the chart
  // falls back to a solid NEU bar instead of a stack breaking below the axis.
  const parts: BridgeCompositionPart[] = [
    ...bucketsNeu.map(([key, value]) => ({ key, label: BUCKET_LABEL_DE[key], value })),
    { key: 'Other' as const, label: BUCKET_LABEL_DE.Other, value: otherNeu },
  ]
  const endComposition = parts.every((p) => p.value >= -MIN_STEP) ? parts : []

  return { start, end, delta, steps, endComposition, residual, residualWithinTolerance }
}

// ── summary_lines: die Preisblock-Zeilen mit ihrer Bedeutung (Kap. 7.2) ───────

/**
 * Eine Zeile des Preisblocks, wie die Spezifikation sie versteht.
 *
 * Die Kennzahlen-Tabelle zeigt Werte; diese Sicht sagt zusätzlich, *was* die
 * Zeile ist: welche Nummer sie im Preisblock trägt, ob sie in eine Summe
 * eingeht, in welcher Einheit sie steht und ob ihr Label von der Datei gedeckt
 * ist. Das Fehlen genau dieser Angaben ist der gemeinsame Nenner der Befunde
 * F-01 (Memo-Zeile als Summand), F-02 (Config-Label ohne Deckung) und F-10
 * (unqualifizierte Quellen).
 */
export interface SummaryLine {
  /** Stabiler, sprachneutraler Registry-Schlüssel. */
  rowKey: string
  /** Kompatibilitätsschlüssel zur Persistenz. */
  metricKey: SummaryMetricKey
  /** Nummer im Preisblock ("1".."8"), null für alles Nicht-Additive. */
  blockNo: string | null
  role: SummaryRowRole
  unit: SummaryRowSpec['unit']
  /** Anzeige-Label: Datei schlägt Registry (F-02). */
  label: string
  labelFileAward: string | null
  labelFileCurrent: string | null
  labelVerifiedAward: boolean | null
  labelVerifiedCurrent: boolean | null
  valueAward: number | null
  valueCurrent: number | null
  delta: number | null
  deltaPercent: number | null
  status: string | null
  sourceAward: string | null
  sourceCurrent: string | null
  /** Lag für diese Zeile eine persistierte Diff-Zeile vor? */
  present: boolean
}

/**
 * Alle Zeilen des Preisblocks in Registry-Reihenfolge — vollständig, in jedem
 * Lauf (R-22). Eine Zeile ohne Daten erscheint mit `null`-Werten und
 * `present: false`; weggelassen wird nichts.
 */
export function buildSummaryLines(rows: SummaryDiffRowData[]): SummaryLine[] {
  const m = byKey(rows)
  return SUMMARY_ROW_REGISTRY.map((spec) => {
    const row = m.get(spec.metricKey)
    return {
      rowKey: spec.rowKey,
      metricKey: spec.metricKey,
      blockNo: spec.blockNo,
      role: spec.role,
      unit: spec.unit,
      label: row?.label_file_neu ?? row?.label_file_alt ?? METRIC_LABELS_DE[spec.metricKey],
      labelFileAward: row?.label_file_alt ?? null,
      labelFileCurrent: row?.label_file_neu ?? null,
      labelVerifiedAward: row?.label_verified_alt ?? null,
      labelVerifiedCurrent: row?.label_verified_neu ?? null,
      valueAward: row?.alt_value ?? null,
      valueCurrent: row?.neu_value ?? null,
      delta: row?.delta_absolute ?? null,
      deltaPercent: row?.delta_percent ?? null,
      status: row?.status ?? null,
      sourceAward: row?.source_alt ?? null,
      sourceCurrent: row?.source_neu ?? null,
      present: row !== undefined,
    }
  })
}

// ── Negotiation: quick wins & big bringers (V11 section 6, buildNegoSummary) ──

export interface NegotiationRow {
  /** Loop 10 View-Spec-Adoption: der Metrik-Schlüssel bleibt an der Zeile —
   * der Builder (negotiation-run.ts) löst darüber die SourceRef-Belege aus
   * dem Differenzkatalog auf, statt sie am Label zu raten. */
  key: SummaryMetricKey
  label: string
  alt: number | null
  neu: number | null
  deltaAbsolute: number
  deltaPercent: number | null
}

export interface Negotiation {
  /** Cost DECREASES (NEU günstiger) — arguments for our side. Top 3. */
  wins: NegotiationRow[]
  /** Cost INCREASES — the supplier's biggest levers. Top 3. */
  bringers: NegotiationRow[]
}

/** V11: |Δ| above this counts as a real movement for negotiation lists. */
const NEGO_MIN_DELTA = 0.0005

/**
 * Top-3 cost decreases (quick wins) and increases (big bringers) over the
 * V11 POSTEN base metrics — pure sorting, no recompute.
 */
export function buildNegotiation(rows: SummaryDiffRowData[]): Negotiation {
  const m = byKey(rows)
  const candidates: NegotiationRow[] = []
  for (const key of NEGOTIATION_METRIC_KEYS) {
    const row = m.get(key)
    const d = row?.delta_absolute
    if (d === null || d === undefined || Math.abs(d) <= NEGO_MIN_DELTA) continue
    candidates.push({
      key,
      label: METRIC_LABELS_DE[key],
      alt: row!.alt_value,
      neu: row!.neu_value,
      deltaAbsolute: d,
      deltaPercent: row!.delta_percent,
    })
  }
  return {
    wins: candidates
      .filter((r) => r.deltaAbsolute < 0)
      .sort((a, b) => a.deltaAbsolute - b.deltaAbsolute)
      .slice(0, 3),
    bringers: candidates
      .filter((r) => r.deltaAbsolute > 0)
      .sort((a, b) => b.deltaAbsolute - a.deltaAbsolute)
      .slice(0, 3),
  }
}

// ── One-time payments (V11 vm.oneTime — own section, not in metricsTable) ────

export interface OneTimeRow {
  /** Loop 10 View-Spec-Adoption: Metrik-Schlüssel für die
   * SourceRef-Auflösung im Builder (wie NegotiationRow.key). */
  key: SummaryMetricKey
  label: string
  alt: number | null
  neu: number | null
  deltaAbsolute: number | null
  same: boolean
}

// Single source for "which metrics are one-time payments" — drives both the
// Einmalzahlungen section and the metrics-table exclusion below.
const ONE_TIME_METRIC_KEYS: SummaryMetricKey[] = ['oneTimeDevelopment', 'oneTimeTools', 'totalOneTimePayment']

/** One-time payment rows present on at least one side (V11 visibility rule). */
export function buildOneTimeRows(rows: SummaryDiffRowData[]): OneTimeRow[] {
  const m = byKey(rows)
  const out: OneTimeRow[] = []
  for (const key of ONE_TIME_METRIC_KEYS) {
    const row = m.get(key)
    if (!row || (row.alt_value === null && row.neu_value === null)) continue
    out.push({
      key,
      label: METRIC_LABELS_DE[key],
      alt: row.alt_value,
      neu: row.neu_value,
      deltaAbsolute: row.delta_absolute,
      same: sameOf(row.alt_value, row.neu_value),
    })
  }
  return out
}

// ── Metrics comparison table (V11 vm.metricsTable — non-one-time metrics) ────

export interface MetricsTableRow {
  metricKey: SummaryMetricKey
  label: string
  alt: number | null
  neu: number | null
  deltaAbsolute: number | null
  deltaPercent: number | null
  status: string | null
  sourceAlt: string | null
  sourceNeu: string | null
  /**
   * Lag für diese Metrik eine persistierte Diff-Zeile vor? Seit R-22 ist die
   * Zeilenmenge konstant, deshalb reicht `alt === null` nicht mehr aus, um
   * „keine Daten" von „Wert ist null" zu unterscheiden. Die Anzeige entscheidet
   * damit, ob sie die Sektion überhaupt zeigt und was sie zählt.
   */
  present: boolean
  /**
   * Bestätigt das Label in der Datei diese Zeile? `null` heißt nicht prüfbar
   * oder (bei Altvergleichen) nicht erhoben — die Anzeige darf daraus keine
   * Warnung machen, nur aus einem echten `false`.
   */
  labelVerified: boolean | null
}

const ONE_TIME_KEYS: ReadonlySet<string> = new Set(ONE_TIME_METRIC_KEYS)

/**
 * All extracted metrics in canonical order with provenance — the audit table
 * under the bridge. One-time payments are excluded (own section per V11).
 *
 * Die Zeilenmenge ist über alle Läufe konstant (V2-Spezifikation R-22, Befund
 * F-19): drei reale Läufe lieferten 14, 15 und 13 Zeilen, weil eine Metrik ohne
 * persistierte Diff-Zeile weggelassen wurde. Damit konnte sich kein Konsument
 * auf die Struktur verlassen — im HICE-Lauf fehlte ausgerechnet die
 * Transportzeile, deren Memo-Posten um 54,6 % gestiegen war. Eine Metrik ohne
 * Daten erscheint jetzt mit `null`-Werten; „nicht vorhanden" ist eine Aussage,
 * „gar nicht erst aufgeführt" ist keine.
 */
export function buildMetricsTable(rows: SummaryDiffRowData[]): MetricsTableRow[] {
  const m = byKey(rows)
  const out: MetricsTableRow[] = []
  for (const key of SUMMARY_METRIC_KEYS) {
    if (ONE_TIME_KEYS.has(key)) continue
    const row = m.get(key)
    out.push({
      metricKey: key,
      // Befund F-02: Was in der Datei steht, schlägt die Konfiguration. Das
      // Registry-Label ist nur noch der Rückfall für Vergleiche, die vor der
      // Label-Erhebung gespeichert wurden — und für Zeilen, die in diesem Lauf
      // gar keine Daten haben (seit R-22 sind auch die aufgeführt).
      label: row?.label_file_neu ?? row?.label_file_alt ?? METRIC_LABELS_DE[key],
      alt: row?.alt_value ?? null,
      neu: row?.neu_value ?? null,
      deltaAbsolute: row?.delta_absolute ?? null,
      deltaPercent: row?.delta_percent ?? null,
      status: row?.status ?? null,
      sourceAlt: row?.source_alt ?? null,
      sourceNeu: row?.source_neu ?? null,
      present: row !== undefined,
      // Die aktuelle Datei ist die Aussage; die Vergabe-Seite zählt nur, wenn
      // für die aktuelle nichts erhoben wurde.
      labelVerified: row?.label_verified_neu ?? row?.label_verified_alt ?? null,
    })
  }
  return out
}

/**
 * The six QAF form positions (V11 formLines, pairwise variant) built from the
 * persisted summary diffs. Scrap = material + manufacturing scrap per side.
 */
export function buildFormLines(rows: SummaryDiffRowData[]): FormLine[] {
  const m = byKey(rows)

  const { alt: scrapAlt, neu: scrapNeu } = scrapPair(m)
  const scrapDelta =
    scrapAlt !== null && scrapNeu !== null && scrapAlt !== 0 ? (scrapNeu - scrapAlt) / Math.abs(scrapAlt) : null

  return [
    lineFromRow('1. Material costs', m.get('materialCosts'), false),
    lineFromRow('2. Manufacturing cost', m.get('manufacturingCosts'), false),
    lineFromRow('TOTAL PRODUCTION COSTS (1.+2.)', m.get('totalProductionCosts'), true),
    {
      label: '4. Scrap costs',
      alt: scrapAlt,
      neu: scrapNeu,
      deltaPercent: scrapDelta,
      total: false,
      same: sameOf(scrapAlt, scrapNeu),
    },
    lineFromRow('5. Other Surcharges (SG&A + Profit)', m.get('otherSurcharges'), false),
    lineFromRow('QUOTATION PRICE', m.get('quotationPrice'), true),
  ]
}

/**
 * Loop 10: Summe der Einmalzahlungs-Deltas — aus der Detailkomponente
 * hierher gezogen (Roadmap-Exit „keine fachliche Berechnung im Client").
 *
 * Die Fassung in der Komponente lautete
 * `rows.reduce((sum, r) => sum + (r.deltaAbsolute ?? 0), 0)`. Das `?? 0` war
 * das Problem: eine Position OHNE bestimmbares Delta (nicht berechenbar,
 * blockiert, nur eine Seite belegt) ging als Null in die Summe ein. Das
 * Ergebnis las sich anschließend wie eine vollständige Gesamtsumme, obwohl
 * Positionen fehlten — genau die stille Null, die der Master-Prompt für jede
 * quantitative Aussage verbietet.
 *
 * Deshalb zählt diese Funktion die unbestimmten Positionen mit und nennt sie,
 * statt sie zu verrechnen. `total` ist `null`, wenn KEINE Position ein
 * bestimmbares Delta hat: dann gibt es keine Summe, und `0` wäre eine
 * erfundene Aussage.
 */
export interface OneTimeDeltaSum {
  /** Summe der bestimmbaren Deltas — `null`, wenn es keine gibt. */
  total: number | null
  /** Wie viele Positionen ein bestimmbares Delta beigetragen haben. */
  bestimmt: number
  /** Wie viele Positionen ohne bestimmbares Delta übergangen wurden. */
  unbestimmt: number
}

export function sumOneTimeDeltas(rows: readonly OneTimeRow[]): OneTimeDeltaSum {
  let total = 0
  let bestimmt = 0
  let unbestimmt = 0
  for (const row of rows) {
    if (row.deltaAbsolute === null || !Number.isFinite(row.deltaAbsolute)) {
      unbestimmt++
      continue
    }
    total += row.deltaAbsolute
    bestimmt++
  }
  return { total: bestimmt > 0 ? total : null, bestimmt, unbestimmt }
}
