// Regression der Materialebene gegen eine anonymisierte Ableitung eines realen
// Vergleichs (Spezifikation Kap. 11, Assertions 6–9).
//
// Diese Suite ist das Gegenstück zu material-mapping-golden-case.real-files:
// Jene misst gegen die echten Daten und überspringt sich überall, wo sie nicht
// liegen — sie ist die Abnahme, aber kein Gate. Diese hier läuft überall, auch
// in der CI, und schützt damit tatsächlich vor Regressionen.
//
// Die Fixture ist keine erfundene Stückliste: Namen sind pseudonymisiert und
// Beträge mit einem festen Faktor skaliert, aber Struktur und Verhältnisse
// stammen aus dem realen Fall. Sie löst nachweislich dieselben Regeln aus —
// dieselben sieben Zuordnungsarten in denselben Häufigkeiten.

import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { buildMaterialMappings, classifyMaterialEffects, type MappablePosition } from '../material-mapping'

interface Fixture {
  scale: number
  counts: { positionsAward: number; positionsCurrent: number }
  sheetDelta: number
  effectBuckets: Record<string, { delta: number; count: number }>
  positionsAward: MappablePosition[]
  positionsCurrent: MappablePosition[]
}

const FIXTURE: Fixture = JSON.parse(
  readFileSync(path.join(__dirname, 'fixtures/material-anonymised.json'), 'utf8'),
)

const mappingResult = () => buildMaterialMappings(FIXTURE.positionsAward, FIXTURE.positionsCurrent)
const effects = () => classifyMaterialEffects(mappingResult().mappings, FIXTURE.sheetDelta)

describe('Materialebene — Regression gegen den anonymisierten Realfall', () => {
  it('Assertion 6 — die Positionszahlen beider Stände', () => {
    const r = mappingResult()
    expect(r.counts.positionsAward).toBe(FIXTURE.counts.positionsAward)
    expect(r.counts.positionsCurrent).toBe(FIXTURE.counts.positionsCurrent)
  })

  it('Assertion 7 — die Zuordnungsarten in ihren Häufigkeiten', () => {
    // Diese Verteilung stammt aus dem realen Referenzlauf und wird von der
    // Fixture nachweislich reproduziert. Ändert sich hier etwas, hat sich eine
    // Zuordnungsregel verschoben.
    expect(mappingResult().counts.byMatchType).toEqual({
      exact: 17,
      duplicate_context: 21,
      merged: 2,
      split: 1,
      renamed: 2,
      added: 12,
      removed: 1,
    })
  })

  it('löst jede Zuordnungsregel so oft aus wie der Originalfall', () => {
    // Die schärfste Zusicherung dieser Suite: Nicht nur die Zuordnungsarten
    // stimmen, sondern die REGELN, über die sie zustande kommen. Ohne diese
    // Prüfung könnte eine Fixture dieselbe Verteilung über andere Wege
    // erzeugen — und eine abgeschaltete Regel bliebe unbemerkt.
    //
    // Diese Zahlen sind gegen den echten Referenzfall abgeglichen; die Fixture
    // brauchte vier Anläufe, bis sie sie reproduzierte (nachzulesen im
    // Erzeuger, der operator-lokal neben den echten Daten liegt).
    const byRule = mappingResult().mappings.reduce<Record<string, number>>((acc, m) => {
      acc[m.matchEvidence.ruleId] = (acc[m.matchEvidence.ruleId] ?? 0) + 1
      return acc
    }, {})

    expect(byRule).toEqual({
      name_exact_unique: 17,
      duplicate_order_preserved: 21,
      name_contains_parts: 1,
      enumerating_name: 1,
      group_collapses_to_single: 1,
      anchor_window_and_stem: 2,
      unmatched_current: 12,
      unmatched_award: 1,
    })
  })

  it('ordnet jede Position genau einmal zu — keine doppelt, keine vergessen', () => {
    const r = mappingResult()
    const awardRows = r.mappings.flatMap((m) => m.awardRows)
    const currentRows = r.mappings.flatMap((m) => m.currentRows)

    expect(new Set(awardRows).size).toBe(awardRows.length)
    expect(new Set(currentRows).size).toBe(currentRows.length)
    expect(awardRows.length).toBe(FIXTURE.counts.positionsAward)
    expect(currentRows.length).toBe(FIXTURE.counts.positionsCurrent)
  })

  it('Assertion 8 — jede Wirkungsart trifft ihren Betrag', () => {
    // Die Beträge sind der harte Teil: sie folgen aus jeder einzelnen Zuordnung.
    // Seit scope_removed (PO-Entscheidung 07.08.2026) ohne Ausnahmen: jede
    // Wirkungsart des Referenzlaufs existiert auch hier.
    const r = effects()
    for (const [bucket, expected] of Object.entries(FIXTURE.effectBuckets)) {
      const actual = r.buckets[bucket as keyof typeof r.buckets]
      expect(actual, `Wirkungsart ${bucket} fehlt`).toBeDefined()
      expect(actual.delta, `Wirkungsart ${bucket}`).toBeCloseTo(expected.delta, 3)
    }
  })

  it('trennt den ersatzlosen Wegfall vom bepreisten — auch in der Zählung', () => {
    // Assertion 8 vergleicht Beträge, und der ersatzlose Wegfall trägt genau
    // keinen: eine Fehlklassifikation nach price_removed bliebe dort
    // unsichtbar. Deshalb hier die Zählungen beider Klassen gegen die Referenz.
    const r = effects()
    expect(r.buckets.scope_removed.count).toBe(FIXTURE.effectBuckets.scope_removed.count)
    expect(r.buckets.price_removed.count).toBe(FIXTURE.effectBuckets.price_removed.count)
  })

  it('Assertion 9 — die Positionsebene erklärt das Blattdelta', () => {
    const r = effects()
    expect(r.reconciliation.residual).toBeCloseTo(0, 4)
    expect(r.reconciliation.passed).toBe(true)
  })

  it('hält die bekannten Abweichungen zur Referenz fest', () => {
    // Zwei Zählungen weichen bewusst ab, beide mit Delta 0,000 und damit ohne
    // Wirkung auf einen Betrag:
    //   · Nullkosten-Zugänge sind hier `unchanged` (Spec Kap. 8.5), im
    //     Referenzlauf `scope_added` — die Spezifikation gewinnt.
    // Die dritte Abweichung (ersatzloser Wegfall in `price_removed`) ist seit
    // der scope_removed-Regel geschlossen — geprüft im Zählungs-Test oben.
    const r = effects()
    expect(r.buckets.unchanged.count - FIXTURE.effectBuckets.unchanged.count).toBe(3)
    expect(FIXTURE.effectBuckets.scope_added.count - r.buckets.scope_added.count).toBe(3)
  })

  it('enthält keine Klartextnamen aus dem Originalfall', () => {
    // Sicherheitsnetz im Repo selbst: Der Erzeuger prüft das bereits, aber die
    // Fixture wird von Hand angefasst werden, und dann fällt es hier auf.
    const serialized = JSON.stringify(FIXTURE).toLowerCase()
    for (const term of ['hice', 'hanon', 'ecompressor', 'chiller', 'wcc', 'ihx', 'bracket']) {
      expect(serialized, `Klartextbegriff „${term}" in der Fixture`).not.toContain(term)
    }
  })
})
