import { describe, it, expect } from 'vitest'
import {
  buildSummaryKpis,
  buildFormLines,
  buildBuckets,
  buildBridge,
  buildMetricsTable,
  buildNegotiation,
  buildOneTimeRows,
  sumOneTimeDeltas,
  pickCurrency,
  type SummaryDiffRowData,
  type OneTimeRow,
} from '../summary-view'

function row(metric_key: string, alt: number | null, neu: number | null, currency: string | null = 'EUR'): SummaryDiffRowData {
  return {
    metric_key,
    alt_value: alt,
    neu_value: neu,
    delta_absolute: alt !== null && neu !== null ? neu - alt : null,
    delta_percent: alt !== null && neu !== null && alt !== 0 ? (neu - alt) / Math.abs(alt) : null,
    currency,
    status: 'anstieg',
    source_alt: alt !== null ? 'P11' : null,
    source_neu: neu !== null ? 'P11' : null,
  }
}

// Values from the verified G5X pair (V11 PDF cross-check, 03.07.2026).
const rows: SummaryDiffRowData[] = [
  row('quotationPrice', 2.4619, 3.6963),
  row('materialCosts', 1.4983, 0.6917),
  row('totalProductionCosts', 1.8747, 2.7871),
  row('manufacturingCosts', 0.3764, 2.0954),
  row('scrapMaterial', 0.028, 0.037),
  row('scrapManufacturing', 0.0077, 0.0847),
  row('otherSurcharges', 0.3768, 0.5979),
]

describe('buildSummaryKpis', () => {
  const kpis = buildSummaryKpis(rows)

  it('builds the three V11 KPI tiles (QP, Material, TPC)', () => {
    expect(kpis).not.toBeNull()
    expect(kpis!.quotationPrice.alt).toBeCloseTo(2.4619)
    expect(kpis!.quotationPrice.neu).toBeCloseTo(3.6963)
    expect(kpis!.quotationPrice.deltaPercent).toBeCloseTo(0.5014, 3)
    expect(kpis!.materialCosts.deltaPercent).toBeCloseTo(-0.5384, 3)
    expect(kpis!.totalProductionCosts.deltaPercent).toBeCloseTo(0.4867, 3)
    expect(kpis!.currency).toBe('EUR')
  })

  it('returns null when none of the three KPI metrics is present', () => {
    expect(buildSummaryKpis([row('otherSurcharges', 1, 2)])).toBeNull()
    expect(buildSummaryKpis([])).toBeNull()
  })

  it('tolerates a missing tile metric (renders as null values)', () => {
    const k = buildSummaryKpis([row('quotationPrice', 2, 3)])
    expect(k!.materialCosts.alt).toBeNull()
    expect(k!.materialCosts.deltaPercent).toBeNull()
  })
})

describe('pickCurrency', () => {
  it('is deterministic regardless of row order (canonical key order wins)', () => {
    const a = [row('otherSurcharges', 1, 2, 'CNY'), row('materialCosts', 1, 2, 'EUR')]
    const b = [...a].reverse()
    // materialCosts precedes otherSurcharges in SUMMARY_METRIC_KEYS.
    expect(pickCurrency(a)).toBe('EUR')
    expect(pickCurrency(b)).toBe('EUR')
  })

  it('returns null when no row carries a currency', () => {
    expect(pickCurrency([row('materialCosts', 1, 2, null)])).toBeNull()
  })
})

describe('buildFormLines', () => {
  const lines = buildFormLines(rows)

  it('produces the six QAF form lines in V11 order', () => {
    expect(lines.map((l) => l.label)).toEqual([
      '1. Material costs',
      '2. Manufacturing cost',
      'TOTAL PRODUCTION COSTS (1.+2.)',
      '4. Scrap costs',
      '5. Other Surcharges (SG&A + Profit)',
      'QUOTATION PRICE',
    ])
    expect(lines.filter((l) => l.total).map((l) => l.label)).toEqual([
      'TOTAL PRODUCTION COSTS (1.+2.)',
      'QUOTATION PRICE',
    ])
  })

  it('passes the persisted delta_percent through verbatim for single-metric lines', () => {
    // Persisted value deliberately differs from a local recompute — the view
    // must show the engine's number, not its own.
    const custom: SummaryDiffRowData = { ...row('materialCosts', 10, 16), delta_percent: 0.599999 }
    const l = buildFormLines([custom]).find((x) => x.label === '1. Material costs')!
    expect(l.deltaPercent).toBe(0.599999)
  })

  it('works without the three KPI metrics (form lines are independent)', () => {
    const l = buildFormLines([row('manufacturingCosts', 1, 2), row('otherSurcharges', 3, 4)])
    expect(l.find((x) => x.label === '2. Manufacturing cost')!.alt).toBe(1)
    expect(l.find((x) => x.label === '5. Other Surcharges (SG&A + Profit)')!.neu).toBe(4)
    expect(l.find((x) => x.label === 'QUOTATION PRICE')!.alt).toBeNull()
  })

  it('sums scrap material + manufacturing per side', () => {
    const scrap = lines.find((l) => l.label === '4. Scrap costs')!
    expect(scrap.alt).toBeCloseTo(0.0357, 4)
    expect(scrap.neu).toBeCloseTo(0.1217, 4)
    expect(scrap.deltaPercent).toBeCloseTo((0.1217 - 0.0357) / 0.0357, 2)
  })

  it('keeps scrap null when both components are missing', () => {
    const noScrap = buildFormLines([row('materialCosts', 1, 2)])
    const scrap = noScrap.find((l) => l.label === '4. Scrap costs')!
    expect(scrap.alt).toBeNull()
    expect(scrap.neu).toBeNull()
    expect(scrap.deltaPercent).toBeNull()
  })

  it('handles a one-sided scrap component as a partial sum', () => {
    const l = buildFormLines([row('scrapMaterial', 0.02, null), row('scrapManufacturing', 0.01, 0.03)])
    const scrap = l.find((x) => x.label === '4. Scrap costs')!
    expect(scrap.alt).toBeCloseTo(0.03)
    expect(scrap.neu).toBeCloseTo(0.03)
  })

  it('flags unchanged lines', () => {
    const l = buildFormLines([row('quotationPrice', 2, 2)])
    const qp = l.find((x) => x.label === 'QUOTATION PRICE')!
    expect(qp.same).toBe(true)
    expect(qp.deltaPercent).toBe(0)
  })
})

describe('buildBridge', () => {
  const bridge = buildBridge(rows)!

  it('anchors the waterfall at ALT and NEU quotation price', () => {
    expect(bridge.start).toBeCloseTo(2.4619)
    expect(bridge.end).toBeCloseTo(3.6963)
    expect(bridge.delta).toBeCloseTo(1.2344, 4)
  })

  it('creates one step per non-zero metric delta with bucket mapping', () => {
    const mat = bridge.steps.find((s) => s.label === 'Materialkosten')!
    expect(mat.delta).toBeCloseTo(-0.8066, 3)
    expect(mat.bucket).toBe('Material')
    const mfg = bridge.steps.find((s) => s.label === 'Fertigungskosten')!
    expect(mfg.bucket).toBe('Manufacturing')
    // quotationPrice itself is the anchor, never a step
    expect(bridge.steps.some((s) => s.label === 'Angebotspreis')).toBe(false)
  })

  // Vertragswechsel mit R-06 (Befund F-01): Ein Rest, den die additiven Zeilen
  // nicht erklären, wurde früher als Schritt „Übrige" eingebucht — die Brücke
  // sah damit immer geschlossen aus und verdeckte genau die Fehler, die der
  // Rest kompensierte. Er wird jetzt ausgewiesen, nicht verteilt.
  it('reports an unexplained remainder instead of booking it as a step', () => {
    expect(bridge.steps.some((s) => s.label === 'Übrige')).toBe(false)

    const sumSteps = bridge.steps.reduce((s, x) => s + x.delta, 0)
    expect(bridge.residual).toBeCloseTo(bridge.delta - sumSteps, 6)
    expect(bridge.residualWithinTolerance).toBe(Math.abs(bridge.residual) <= 0.0005)
  })

  it('skips near-zero deltas and returns null without both QP sides', () => {
    const b = buildBridge([row('quotationPrice', 2, 2.5), row('materialCosts', 1, 1)])!
    expect(b.steps.some((s) => s.label === 'Materialkosten')).toBe(false)
    expect(buildBridge([row('materialCosts', 1, 2)])).toBeNull()
    expect(buildBridge([row('quotationPrice', null, 2)])).toBeNull()
  })

  it('builds the NEU composition from the bucket values plus a residual', () => {
    const comp = bridge.endComposition
    const sum = comp.reduce((s, c) => s + c.value, 0)
    expect(sum).toBeCloseTo(bridge.end, 3)
    expect(comp.find((c) => c.key === 'Material')!.value).toBeCloseTo(0.6917)
  })
})

describe('buildMetricsTable', () => {
  const table = buildMetricsTable(rows)

  // Seit R-22 (Befund F-19) ist die Zeilenmenge konstant: eine Metrik ohne
  // persistierte Diff-Zeile erscheint mit null-Werten statt zu fehlen. Die
  // Liste steht bewusst wörtlich hier und wird nicht aus der Registry
  // abgeleitet — eine Änderung an der Registry soll hier auffallen.
  it('lists the full canonical row set in order with German labels', () => {
    expect(table[0].label).toBe('Materialkosten')
    expect(table.map((r) => r.metricKey)).toEqual([
      'materialCosts',
      'manufacturingCosts',
      'totalProductionCosts',
      'packagingTransportIncluded',
      'customsIncluded',
      'devicesAndTools',
      'scrapMaterial',
      'scrapManufacturing',
      'totalCosts',
      'otherSurcharges',
      'quotationBasePrice',
      'rawMaterialPriceShareMaterial',
      'rawMaterialPriceShareEnergy',
      'customsSupplierToBMW',
      'transportSupplierToBMW',
      'quotationPrice',
      'costBreakdownAw1',
    ])
  })

  it('kennzeichnet, welche Zeilen wirklich Daten tragen', () => {
    expect(table.find((r) => r.metricKey === 'materialCosts')!.present).toBe(true)
    expect(table.find((r) => r.metricKey === 'transportSupplierToBMW')!.present).toBe(false)
  })

  it('carries values, deltas, sources and status', () => {
    const mat = table[0]
    expect(mat.alt).toBeCloseTo(1.4983)
    expect(mat.neu).toBeCloseTo(0.6917)
    expect(mat.sourceAlt).toBe('P11')
    expect(mat.status).toBe('anstieg')
  })

  it('excludes one-time payment rows (own section per V11)', () => {
    const withOneTime = buildMetricsTable([row('materialCosts', 1, 2), row('totalOneTimePayment', 5, 5)])
    expect(withOneTime.some((r) => r.metricKey === 'totalOneTimePayment')).toBe(false)
  })
})

describe('buildNegotiation', () => {
  const nego = buildNegotiation(rows)

  it('puts cost decreases into wins, sorted by biggest saving first', () => {
    expect(nego.wins.map((w) => w.label)).toEqual(['Materialkosten'])
    expect(nego.wins[0].deltaAbsolute).toBeCloseTo(-0.8066, 3)
  })

  it('puts cost increases into bringers, top-3 by delta descending', () => {
    expect(nego.bringers).toHaveLength(3)
    expect(nego.bringers[0].label).toBe('Fertigungskosten')
    expect(nego.bringers[0].deltaAbsolute).toBeCloseTo(1.719, 3)
    // sorted descending
    const deltas = nego.bringers.map((b) => b.deltaAbsolute)
    expect([...deltas].sort((a, b) => b - a)).toEqual(deltas)
  })

  it('ignores aggregates (QP/TPC) and near-zero deltas per V11 POSTEN rule', () => {
    expect([...nego.wins, ...nego.bringers].some((r) => r.label === 'Angebotspreis')).toBe(false)
    const tiny = buildNegotiation([row('materialCosts', 1, 1.0001)])
    expect(tiny.wins).toEqual([])
    expect(tiny.bringers).toEqual([])
  })
})

describe('buildOneTimeRows', () => {
  it('lists one-time payment rows present on at least one side', () => {
    const oneTime = buildOneTimeRows([
      row('oneTimeTools', 184600, 184600),
      row('totalOneTimePayment', 184600, 184600),
      row('materialCosts', 1, 2),
    ])
    expect(oneTime.map((r) => r.label)).toEqual(['Einmal: Sonderbetriebsmittel', 'Gesamteinmalzahlung'])
    expect(oneTime[0].same).toBe(true)
    expect(oneTime[0].deltaAbsolute).toBe(0)
  })

  it('returns empty when no one-time metrics were extracted', () => {
    expect(buildOneTimeRows(rows)).toEqual([])
  })
})

describe('buildBuckets', () => {
  // Vertragswechsel mit R-07 (Befund F-03): FWZ ist ein eigener Block. Vorher
  // fiel er mangels Definition in den Sammelposten „Übrige" — im Anlassfall war
  // „Übrige" exakt der FWZ-Wert. Reihenfolge folgt jetzt dem Preisblock
  // (1. Material · 2. Fertigung · 3. FWZ · 4. Ausschuss · 5. Zuschläge).
  it('builds the summary-mode buckets in price-block order with deltas', () => {
    const buckets = buildBuckets(rows)!
    expect(buckets.map((b) => b.key)).toEqual(['Material', 'Manufacturing', 'FWZ', 'ScrapB', 'SGA'])
    const mat = buckets[0]
    expect(mat.label).toBe('Material')
    expect(mat.alt).toBeCloseTo(1.4983)
    expect(mat.neu).toBeCloseTo(0.6917)
    expect(mat.delta).toBeCloseTo(-0.8066, 3)
    const scrap = buckets.find((b) => b.key === 'ScrapB')!
    expect(scrap.label).toBe('Scrap')
    expect(scrap.alt).toBeCloseTo(0.0357, 4)
    expect(scrap.neu).toBeCloseTo(0.1217, 4)
  })

  it('coerces a missing single bucket to 0 but keeps the chart', () => {
    const buckets = buildBuckets([row('materialCosts', 1, 2)])!
    expect(buckets[0].alt).toBe(1)
    expect(buckets[1].alt).toBe(0) // manufacturing absent → 0 (V11 stands rule)
    expect(buckets[1].delta).toBeNull()
  })

  it('returns null when no bucket metric is present at all', () => {
    expect(buildBuckets([row('quotationPrice', 1, 2)])).toBeNull()
    expect(buildBuckets([])).toBeNull()
  })
})

describe('sumOneTimeDeltas — Loop 10: keine stille Null in der Gesamtsumme', () => {
  const row = (label: string, deltaAbsolute: number | null): OneTimeRow => ({
    // Loop 10 Modul 2: OneTimeRow traegt jetzt den Metrik-Schluessel; fuer
    // die Summen-Tests ist der konkrete Key ohne Belang.
    key: 'oneTimeTools',
    label,
    alt: 1,
    neu: 2,
    deltaAbsolute,
    same: false,
  })

  it('summiert die bestimmbaren Deltas', () => {
    const r = sumOneTimeDeltas([row('A', 100), row('B', -40)])
    expect(r.total).toBe(60)
    expect(r.bestimmt).toBe(2)
    expect(r.unbestimmt).toBe(0)
  })

  // Der eigentliche Grund für diese Funktion: die Vorgängerfassung in der
  // Komponente rechnete `?? 0` und lieferte hier 100 — eine Summe, die wie
  // "alle drei Positionen zusammen" aussah, obwohl zwei gar kein Delta haben.
  it('zählt Positionen ohne Delta NICHT als 0, sondern meldet sie getrennt', () => {
    const r = sumOneTimeDeltas([row('A', 100), row('B', null), row('C', null)])
    expect(r.total).toBe(100)
    expect(r.bestimmt).toBe(1)
    expect(r.unbestimmt).toBe(2)
  })

  it('liefert null statt 0, wenn keine einzige Position ein Delta hat', () => {
    const r = sumOneTimeDeltas([row('A', null), row('B', null)])
    expect(r.total).toBeNull()
    expect(r.bestimmt).toBe(0)
    expect(r.unbestimmt).toBe(2)
  })

  it('liefert null bei leerer Liste — keine erfundene Aussage', () => {
    expect(sumOneTimeDeltas([]).total).toBeNull()
  })

  it('behandelt NaN/Infinity wie fehlend statt die Summe zu vergiften', () => {
    const r = sumOneTimeDeltas([row('A', 50), row('B', NaN), row('C', Infinity)])
    expect(r.total).toBe(50)
    expect(r.unbestimmt).toBe(2)
  })

  it('unterscheidet eine echte Null-Summe von "nichts bestimmbar"', () => {
    const echt = sumOneTimeDeltas([row('A', 40), row('B', -40)])
    expect(echt.total).toBe(0)
    expect(echt.bestimmt).toBe(2)
    const nichts = sumOneTimeDeltas([row('A', null)])
    expect(nichts.total).toBeNull()
  })
})
