// Reine Helfer der V2-Anzeigekomponenten.
//
// Die Komponenten selbst sind darstellend; was Entscheidungen trifft, ist hier
// herausgezogen und wird hier geprüft — dasselbe Muster, das
// qaf-provenance.tsx und qaf-projection.tsx schon verwenden.

import { describe, it, expect } from 'vitest'
import {
  cellReference,
  fileLabel,
  isWeakState,
  valueStateLabel,
} from '../qaf-v2-evidence-panel'
import { comparabilityView, comparisonTypeLabel, shortHash } from '../qaf-v2-header'
import { decimalsFor, formatDelta, formatRatio, formatValue, statusClass } from '../qaf-v2-kpi-tile'
import { axisTicks, barGeometry, valueRange, barClass, isHairline, runningLevel } from '../qaf-v2-waterfall'
import type { WaterfallBar } from '@/lib/qaf-differences/internal/chart-specs'

describe('Zellnachweis — Beschriftungen', () => {
  it('setzt Blattnamen mit Sonderzeichen in Hochkommata', () => {
    // Ohne Hochkommata versteht Excel den eingefügten Bezug nicht.
    expect(cellReference({ sheet: 'MATERIAL', cell: 'K10' })).toBe('MATERIAL!K10')
    expect(cellReference({ sheet: 'SBM-Vorrichtungen-FWZ', cell: 'AP88' })).toBe("'SBM-Vorrichtungen-FWZ'!AP88")
    expect(cellReference({ sheet: "Blatt'A", cell: 'A1' })).toBe("'Blatt''A'!A1")
  })

  it('nennt die Datei bei ihrem Namen, nicht bei ihrer Rolle', () => {
    expect(fileLabel('award')).toBe('Vergabestand')
    expect(fileLabel('current')).toBe('Aktueller Stand')
  })

  it('übersetzt den Wertzustand in etwas Lesbares', () => {
    // „constant" heisst für den Leser nichts; „fester Wert" schon.
    expect(valueStateLabel('constant')).toBe('fester Wert')
    expect(valueStateLabel('formula_and_cached')).toBe('gerechnet')
    expect(valueStateLabel('external_link')).toBe('aus fremder Datei')
  })

  it('hebt die Zustände hervor, die den Wert schwächen', () => {
    expect(isWeakState('error')).toBe(true)
    expect(isWeakState('external_link')).toBe(true)
    expect(isWeakState('constant')).toBe(false)
    expect(isWeakState('formula_and_cached')).toBe(false)
  })
})

describe('Kopfbereich', () => {
  it('nennt die Vergleichsart im Klartext', () => {
    expect(comparisonTypeLabel('temporal_change')).toBe('Zeitlicher Änderungsvergleich')
    expect(comparisonTypeLabel('undetermined')).toContain('nicht bestimmt')
  })

  it('gibt „nicht geprüft" nicht die Farbe von „in Ordnung"', () => {
    // Wo niemand hingesehen hat, ist nichts in Ordnung — es ist nur nichts
    // bekannt.
    expect(comparabilityView('comparable').tone).toBe('good')
    expect(comparabilityView('undetermined').tone).toBe('neutral')
    expect(comparabilityView('not_comparable').tone).toBe('critical')
  })

  it('kürzt den Determinismus-Hash und lässt das Präfix weg', () => {
    expect(shortHash('sha256:0123456789abcdef0123')).toBe('0123456789ab…')
    expect(shortHash('abc')).toBe('abc')
    expect(shortHash(null)).toBeNull()
  })
})

describe('KPI-Kachel — Formatierung', () => {
  it('nimmt die Nachkommastellen aus der Einheit', () => {
    expect(decimalsFor('EUR')).toBe(2)
    expect(decimalsFor('EUR_per_piece')).toBe(4)
    expect(decimalsFor('pieces')).toBe(0)
  })

  it('rechnet Anteile erst hier auf Prozent', () => {
    // Im Kern steht 0,31; auf dem Schirm muss 31,0 % stehen.
    expect(formatValue(0.31, 'pct_decimal')).toBe('31,0 %')
  })

  it('schreibt Beträge in deutscher Form mit Einheit', () => {
    expect(formatValue(861500, 'EUR')).toBe('861.500,00 EUR')
    expect(formatValue(2.135, 'EUR_per_piece')).toBe('2,1350 EUR/Stück')
    expect(formatValue(null, 'EUR')).toBe('—')
  })

  it('kürzt grosse Beträge, statt Ziffern zählen zu lassen', () => {
    expect(formatValue(77_406_000, 'EUR')).toBe('77,41 Mio. EUR')
    expect(formatValue(3_973_200, 'EUR')).toBe('3,97 Mio. EUR')
    expect(formatValue(2_400_000_000, 'EUR')).toBe('2,40 Mrd. EUR')
    // Die Grenze liegt bei einer Million, knapp darunter bleibt es ausgeschrieben.
    expect(formatValue(999_999, 'EUR')).toBe('999.999,00 EUR')
  })

  it('kürzt nur Einheiten, bei denen Millionen vorkommen', () => {
    // Ein Stückpreis in Mio. wäre eine Falschaussage über die Grössenordnung;
    // Sekunden und Stückzahlen erst recht.
    expect(formatValue(1_200_000, 'EUR_per_piece')).toBe('1.200.000,0000 EUR/Stück')
    expect(formatValue(1_200_000, 'pieces')).toBe('1.200.000')
  })

  it('kürzt auch das Delta, nicht nur den Wert', () => {
    expect(formatDelta(3_973_200, 'EUR')).toBe('+3,97 Mio. EUR')
    expect(formatDelta(-2_500_000, 'EUR')).toBe('−2,50 Mio. EUR')
  })

  it('setzt das Vorzeichen immer, auch bei einer Senkung', () => {
    // Farbe allein trägt die Aussage nicht — nicht für jemanden, der Rot und
    // Grün nicht unterscheidet, und nicht in einem Ausdruck.
    expect(formatDelta(49000, 'EUR')).toBe('+49.000,00 EUR')
    expect(formatDelta(-0.76, 'EUR_per_piece')).toBe('−0,7600 EUR/Stück')
    expect(formatDelta(0, 'EUR')).toBe('+0,00 EUR')
  })

  it('schreibt den Anteil mit Vorzeichen als Prozent', () => {
    expect(formatRatio(0.1)).toBe('+10,0 %')
    expect(formatRatio(-0.025)).toBe('−2,5 %')
    expect(formatRatio(null)).toBe('—')
  })

  it('gibt jedem Status eine eigene Klasse', () => {
    const klassen = (['critical', 'warning', 'good', 'neutral'] as const).map(statusClass)
    expect(new Set(klassen).size).toBe(4)
  })
})

describe('Wasserfall — Geometrie', () => {
  const bar = (over: Partial<WaterfallBar>): WaterfallBar => ({
    key: 'x',
    labelDe: 'X',
    kind: 'increase',
    value: 10,
    base: 100,
    differenceIds: [],
    ...over,
  })

  it('setzt Anfangs- und Endbalken auf den Boden', () => {
    const geo = barGeometry(bar({ kind: 'start', value: 100, base: 0 }), 0, 200)
    expect(geo.bottomRatio).toBe(0)
    expect(geo.heightRatio).toBeCloseTo(0.5, 6)
  })

  it('hängt einen Anstieg an seine untere Kante', () => {
    const geo = barGeometry(bar({ kind: 'increase', value: 10, base: 100 }), 0, 200)
    expect(geo.bottomRatio).toBeCloseTo(0.5, 6)
    expect(geo.heightRatio).toBeCloseTo(0.05, 6)
  })

  it('behandelt eine Senkung wie einen Anstieg von unten', () => {
    // Die Unterkante liegt schon im Balken selbst; die Höhe ist der Betrag.
    const geo = barGeometry(bar({ kind: 'decrease', value: -10, base: 90 }), 0, 200)
    expect(geo.bottomRatio).toBeCloseTo(0.45, 6)
    expect(geo.heightRatio).toBeCloseTo(0.05, 6)
  })

  it('liefert nichts bei entarteter Spanne', () => {
    expect(barGeometry(bar({}), 5, 5)).toEqual({ bottomRatio: 0, heightRatio: 0 })
  })

  it('beginnt die Skala immer bei null', () => {
    // Die beschnittene Skala ist raus. Sie hielt kleine Schritte sichtbar, log
    // dafür aber über die Grössenordnung: 87,42 und 92,15 liegen 5 %
    // auseinander und standen als 4,5 % und 92,5 % hohe Balken nebeneinander.
    // Zwei Nachbesserungen haben das gemildert und nicht behoben.
    const bars = [bar({ kind: 'start', value: 1000, base: 0 }), bar({ kind: 'increase', value: 10, base: 1000 })]

    expect(valueRange(bars).min).toBe(0)
  })

  it('stellt zwei nahe beieinanderliegende Stände auch nah beieinander dar', () => {
    // Die eigentliche Zusicherung hinter der Null-Skala: Werte, die 5 %
    // auseinanderliegen, dürfen sich in der Höhe nicht um ein Vielfaches
    // unterscheiden.
    const bars = [
      bar({ key: 'start', kind: 'start', value: 87.42, base: 0 }),
      bar({ key: 'schritt', kind: 'increase', value: 4.73, base: 87.42 }),
      bar({ key: 'end', kind: 'end', value: 92.15, base: 0 }),
    ]
    const { min, max } = valueRange(bars)
    const anfang = barGeometry(bars[0], min, max).heightRatio
    const ende = barGeometry(bars[2], min, max).heightRatio

    expect(anfang / ende).toBeCloseTo(87.42 / 92.15, 2)
  })

  it('setzt die Schritte lückenlos aufeinander', () => {
    // Die Treppe muss schliessen: Wo ein Balken endet, beginnt der nächste.
    // Klafft dort eine Lücke, behauptet das Bild einen Sprung, den die Zahlen
    // nicht hergeben.
    const bars = [
      bar({ key: 'start', kind: 'start', value: 87.42, base: 0 }),
      bar({ key: 'a', kind: 'increase', value: 2.84, base: 87.42 }),
      bar({ key: 'b', kind: 'increase', value: 1.37, base: 90.26 }),
      bar({ key: 'c', kind: 'decrease', value: -0.16, base: 91.47 }),
      bar({ key: 'end', kind: 'end', value: 91.47, base: 0 }),
    ]
    const { min, max } = valueRange(bars)
    const geo = bars.map((b) => barGeometry(b, min, max))

    // Oberkante des Anfangsbalkens = Unterkante des ersten Schritts.
    expect(geo[0].bottomRatio + geo[0].heightRatio).toBeCloseTo(geo[1].bottomRatio, 6)
    // Und so weiter durch die Schritte.
    expect(geo[1].bottomRatio + geo[1].heightRatio).toBeCloseTo(geo[2].bottomRatio, 6)
    expect(geo[2].bottomRatio + geo[2].heightRatio).toBeCloseTo(geo[3].bottomRatio + geo[3].heightRatio, 6)
  })

  it('lässt oben etwas Luft, damit der höchste Balken nicht klebt', () => {
    const bars = [bar({ kind: 'start', value: 100, base: 0 })]
    expect(valueRange(bars).max).toBeGreaterThan(100)
  })

  it('kommt mit einer leeren Brücke zurecht', () => {
    expect(valueRange([])).toEqual({ min: 0, max: 0 })
  })

  it('teilt die Achse gleichmässig von null bis zum Maximum', () => {
    const ticks = axisTicks(3.8, 5)
    expect(ticks).toHaveLength(5)
    // Gleitkomma: 3,8 · 3/4 landet bei 2,8499…, deshalb nicht auf Gleichheit.
    for (const [i, soll] of [0, 0.95, 1.9, 2.85, 3.8].entries()) {
      expect(ticks[i]).toBeCloseTo(soll, 6)
    }
    expect(axisTicks(0)).toEqual([0])
  })

  it('macht einen zu dünnen Balken zur Linie mit Wert', () => {
    // Ein Schritt, der da ist, muss sichtbar sein — aber er darf nicht grösser
    // aussehen, als er ist. Ein Balken von einem halben Pixel ist beides nicht.
    expect(isHairline({ bottomRatio: 0.9, heightRatio: 0.001 })).toBe(true)
    expect(isHairline({ bottomRatio: 0.5, heightRatio: 0.2 })).toBe(false)
    // Höhe null ist kein Haar, sondern nichts.
    expect(isHairline({ bottomRatio: 0, heightRatio: 0 })).toBe(false)
  })

  it('unterscheidet Anstieg und Senkung auch ohne Farbe', () => {
    // Die Senkung trägt zusätzlich einen Rand — Farbe allein genügt nicht.
    expect(barClass('decrease')).toContain('border')
    expect(barClass('increase')).not.toContain('border')
  })

  it('setzt den Konnektor auf den aufgelaufenen Stand', () => {
    // Bei einer Senkung ist `base` schon der Stand danach, bei einem Anstieg
    // der davor. Wer das verwechselt, zieht die Linie an der Senkung oben statt
    // unten an — sie träfe den Balken dort, wo er nicht endet.
    expect(runningLevel(bar({ kind: 'start', value: 87.42, base: 0 }))).toBe(87.42)
    expect(runningLevel(bar({ kind: 'end', value: 92.15, base: 0 }))).toBe(92.15)
    expect(runningLevel(bar({ kind: 'increase', value: 2.84, base: 87.42 }))).toBeCloseTo(90.26, 6)
    expect(runningLevel(bar({ kind: 'decrease', value: -0.16, base: 92.15 }))).toBeCloseTo(92.15, 6)
  })
})
