// Differenzkatalog und Rückverfolgung (Kap. 7.6, R-13).

import { describe, it, expect } from 'vitest'
import {
  assignDifferenceIds,
  buildCatalog,
  buildTraceabilityIndex,
  validateTraceability,
  DIFFERENCE_AREAS,
  type RawDifference,
} from '../all-differences'
import { differencesFromTooling } from '../differences-from-tooling'
import type { ToolMapping } from '../tooling-analysis'

const diff = (over: Partial<RawDifference> = {}): RawDifference => ({
  area: 'SBM',
  kind: 'changed',
  formulaFindingKind: null,
  descriptionDe: 'Betrag verändert',
  valueAward: 100,
  valueCurrent: 120,
  delta: 20,
  deltaNullReason: null,
  unit: 'EUR',
  cells: [{ fileRole: 'current', sheet: 'SBM-DEVICES-FWZ', cell: 'AP20', formula: null, valueState: 'constant' }],
  relatedDq: [],
  anchorRow: 20,
  ...over,
})

describe('assignDifferenceIds', () => {
  it('nummeriert je Bereich getrennt und in Bereichsordnung', () => {
    const out = assignDifferenceIds([
      diff({ area: 'SBM', anchorRow: 20 }),
      diff({ area: 'SUM', anchorRow: 5 }),
      diff({ area: 'SBM', anchorRow: 10 }),
    ])
    expect(out.map((d) => d.differenceId)).toEqual(['D-SUM-001', 'D-SBM-001', 'D-SBM-002'])
    expect(out[1].anchorRow).toBe(10)
  })

  it('sortiert innerhalb eines Bereichs nach der Zeile', () => {
    // Die Zeile ändert sich zwischen zwei Läufen nicht, die
    // Verarbeitungsreihenfolge sehr wohl.
    const out = assignDifferenceIds([diff({ anchorRow: 88 }), diff({ anchorRow: 15 }), diff({ anchorRow: 40 })])
    expect(out.map((d) => d.anchorRow)).toEqual([15, 40, 88])
  })

  it('vergibt bei umgestellter Eingabe dieselben Kennungen', () => {
    const a = diff({ anchorRow: 15, descriptionDe: 'A' })
    const b = diff({ anchorRow: 40, descriptionDe: 'B' })
    const c = diff({ area: 'MAT', anchorRow: 7, descriptionDe: 'C' })
    const key = (x: { differenceId: string; descriptionDe: string }) => `${x.differenceId}:${x.descriptionDe}`
    expect(assignDifferenceIds([c, b, a]).map(key)).toEqual(assignDifferenceIds([a, b, c]).map(key))
  })

  it('hängt Differenzen ohne Zeilenbezug hinten an', () => {
    const out = assignDifferenceIds([diff({ anchorRow: null, descriptionDe: 'ohne' }), diff({ anchorRow: 99 })])
    expect(out[1].descriptionDe).toBe('ohne')
  })

  it('trennt gleiche Zeilen über Art und Beschreibung', () => {
    const out = assignDifferenceIds([
      diff({ anchorRow: 20, kind: 'removed', descriptionDe: 'Z' }),
      diff({ anchorRow: 20, kind: 'added', descriptionDe: 'A' }),
    ])
    expect(out.map((d) => d.kind)).toEqual(['added', 'removed'])
  })

  it('reicht Formelbefund und Delta-Grund unverändert durch', () => {
    const out = assignDifferenceIds([
      diff({ anchorRow: 10, formulaFindingKind: 'formel_zu_konstante' }),
      diff({ anchorRow: 20, valueAward: null, valueCurrent: null, delta: null, deltaNullReason: 'blockiert' }),
    ])
    expect(out[0].formulaFindingKind).toBe('formel_zu_konstante')
    expect(out[1].deltaNullReason).toBe('blockiert')
  })

  it('trägt Wertband und Formelbefund gleichzeitig — die Aussagen sind orthogonal', () => {
    // formel_geaendert_wert_geaendert: der Summary-Vergleich behält das
    // Wertband und setzt den Formelbefund dazu — keines verdrängt das andere.
    const [r] = assignDifferenceIds([diff({ kind: 'changed', formulaFindingKind: 'formel_geaendert_wert_geaendert' })])
    expect(r.kind).toBe('changed')
    expect(r.formulaFindingKind).toBe('formel_geaendert_wert_geaendert')
  })

  it('trennt kompletten Gleichstand der vier Schlüssel über die Fundstelle', () => {
    // Material-/Fertigungszeilen tragen mehrere Felder je Zeile: Bereich,
    // Ankerzeile und Art sind dort regelmäßig gleich. Hinge die Eindeutigkeit
    // an der Beschreibung, würde eine Umformulierung zwei Kennungen
    // vertauschen — die Zelle ist der fachliche Anker.
    const zelleIn = (cell: string) => [
      { fileRole: 'current' as const, sheet: 'MATERIAL', cell, formula: null, valueState: 'constant' as const },
    ]
    const a = diff({ anchorRow: 10, descriptionDe: 'Feld verändert', cells: zelleIn('H10') })
    const b = diff({ anchorRow: 10, descriptionDe: 'Feld verändert', cells: zelleIn('K10') })

    const schlüssel = (x: { differenceId: string; cells: { cell: string }[] }) =>
      `${x.differenceId}:${x.cells[0].cell}`
    // Permutationsprobe auf echtem Gleichstand: dieselbe Kennung für denselben
    // Satz, unabhängig von der Eingabereihenfolge. Der ältere Permutationstest
    // oben deckt das nicht — seine Sätze unterscheiden sich in der Ankerzeile.
    expect(assignDifferenceIds([a, b]).map(schlüssel)).toEqual(assignDifferenceIds([b, a]).map(schlüssel))
  })

  it('lässt Sätze ohne Fundstelle beim fünften Schlüssel unangetastet', () => {
    // Weg 4: bewusst leere cells (SUM-Pfad). Der fünfte Schlüssel greift dort
    // nicht — und darf vor allem nicht werfen.
    const out = assignDifferenceIds([
      diff({ anchorRow: 5, cells: [] }),
      diff({ anchorRow: 3, cells: [] }),
    ])
    expect(out.map((d) => d.anchorRow)).toEqual([3, 5])
  })

  it('sortiert nicht nach den neuen Feldern', () => {
    // Der fünfte Sortierschlüssel ist bewusst ein eigener Schritt — die neuen
    // Angaben dürfen die Kennungsvergabe nicht verschieben.
    const out = assignDifferenceIds([
      diff({ anchorRow: 40, formulaFindingKind: 'formel_geaendert_wert_gleich' }),
      diff({ anchorRow: 15 }),
    ])
    expect(out.map((d) => d.anchorRow)).toEqual([15, 40])
  })
})

describe('buildCatalog', () => {
  it('zählt je Bereich', () => {
    const c = buildCatalog([diff({ area: 'SBM' }), diff({ area: 'SBM', anchorRow: 21 }), diff({ area: 'MAT' })])
    expect(c.count).toBe(3)
    expect(c.countsByArea.SBM).toBe(2)
    expect(c.countsByArea.MAT).toBe(1)
    expect(Object.keys(c.countsByArea).sort()).toEqual([...DIFFERENCE_AREAS].sort())
  })

  it('führt auch leere Bereiche mit null', () => {
    // Ein fehlender Bereich sähe aus wie ein nicht ausgewerteter.
    expect(buildCatalog([]).countsByArea.MFG).toBe(0)
  })
})

describe('buildTraceabilityIndex', () => {
  const records = assignDifferenceIds([
    diff({ area: 'SBM', anchorRow: 88, relatedDq: ['DQ-B-003'] }),
    diff({ area: 'SBM', anchorRow: 91, relatedDq: ['DQ-B-002'] }),
  ])
  const ids = records.map((r) => r.differenceId)

  it('löst die Zellen aus den Differenzen auf', () => {
    // Eine zweite, von Hand gepflegte Zellliste driftet weg — dann belegt der
    // Index etwas anderes, als die Differenz behauptet.
    const idx = buildTraceabilityIndex(records, { zweitwerkzeuge: ids }, {})
    expect(idx.statements.zweitwerkzeuge.cells).toHaveLength(2)
    expect(idx.statements.zweitwerkzeuge.dq).toEqual(['DQ-B-002', 'DQ-B-003'])
  })

  it('führt Sichten und Aussagen getrennt', () => {
    const idx = buildTraceabilityIndex(records, { kernaussage: [ids[0]] }, { slide_07: ids })
    expect(idx.statements.kernaussage.differenceIds).toEqual([ids[0]])
    expect(idx.views.slide_07.differenceIds).toEqual([...ids].sort())
  })

  it('übergeht unbekannte Kennungen beim Auflösen, ohne zu werfen', () => {
    const idx = buildTraceabilityIndex(records, { kaputt: ['D-SBM-999'] }, {})
    expect(idx.statements.kaputt.cells).toEqual([])
    expect(idx.statements.kaputt.differenceIds).toEqual(['D-SBM-999'])
  })
})

describe('validateTraceability', () => {
  const records = assignDifferenceIds([diff({ anchorRow: 88 })])
  const ids = records.map((r) => r.differenceId)

  it('meldet nichts für einen geschlossenen Index', () => {
    const referenced = { statements: { a: ids }, views: { slide_01: ids } }
    const idx = buildTraceabilityIndex(records, referenced.statements, referenced.views)
    expect(validateTraceability(records, idx, referenced)).toEqual([])
  })

  it('meldet den Verweis auf eine nicht geführte Differenz', () => {
    // Ein Index, der ins Leere zeigt, ist schlimmer als keiner: Er sieht
    // vollständig aus.
    const referenced = { statements: { a: ['D-SBM-999'] }, views: {} }
    const idx = buildTraceabilityIndex(records, referenced.statements, referenced.views)
    const p = validateTraceability(records, idx, referenced)
    expect(p.map((x) => x.kind)).toContain('unknown_difference_id')
  })

  it('meldet die Differenz ohne Fundstelle', () => {
    const ohneZelle = assignDifferenceIds([diff({ cells: [] })])
    const p = validateTraceability(ohneZelle, { statements: {}, views: {} }, { statements: {}, views: {} })
    expect(p).toHaveLength(1)
    expect(p[0].kind).toBe('difference_without_cells')
  })

  it('meldet die Aussage ohne Beleg', () => {
    const referenced = { statements: { behauptung: [] }, views: {} }
    const idx = buildTraceabilityIndex(records, referenced.statements, referenced.views)
    const p = validateTraceability(records, idx, referenced)
    expect(p.map((x) => x.kind)).toContain('statement_without_differences')
  })

  it('meldet den Delta-Grund, der einem vorhandenen Delta widerspricht', () => {
    // Ein Grund für ein fehlendes Delta neben einem vorhandenen Delta ist eine
    // widersprüchliche Aussage — sie darf nicht still im Katalog stehen.
    const rec = assignDifferenceIds([diff({ delta: 20, deltaNullReason: 'blockiert' })])
    const p = validateTraceability(rec, { statements: {}, views: {} }, { statements: {}, views: {} })
    expect(p.map((x) => x.kind)).toContain('delta_reason_contradiction')
  })

  it('akzeptiert den Delta-Grund am fehlenden Delta', () => {
    const rec = assignDifferenceIds([
      diff({ valueAward: null, valueCurrent: null, delta: null, deltaNullReason: 'nicht_berechenbar' }),
    ])
    const p = validateTraceability(rec, { statements: {}, views: {} }, { statements: {}, views: {} })
    expect(p).toEqual([])
  })
})

describe('differencesFromTooling', () => {
  const mapping = (over: Partial<ToolMapping>): ToolMapping => ({
    mappingId: 'TOOL-001',
    awardRow: 10,
    currentRow: 10,
    part: 'Halter',
    status: 'unchanged',
    classification: 'unchanged',
    specRemovedInCurrent: false,
    rowMoved: false,
    evidenceDe: 'unverändert',
    oneTimeAward: 100,
    oneTimeCurrent: 100,
    delta: 0,
    perPieceAward: null,
    perPieceCurrent: null,
    perPieceDelta: null,
    ...over,
  })

  it('lässt unveränderte Werkzeuge aus dem Katalog heraus', () => {
    // Ein Katalog, in dem jede Zeile steht, ist keine Differenzliste mehr.
    expect(differencesFromTooling([mapping({})])).toEqual([])
  })

  it('nimmt die verschobene Zeile auf, obwohl der Betrag gleich bleibt', () => {
    const d = differencesFromTooling([mapping({ rowMoved: true, awardRow: 10, currentRow: 55 })])
    expect(d).toHaveLength(1)
    expect(d[0]).toMatchObject({ kind: 'moved', delta: 0, anchorRow: 55 })
  })

  it('trägt für jeden Stand eine Fundstelle', () => {
    const d = differencesFromTooling([mapping({ status: 'changed', oneTimeCurrent: 150, delta: 50 })])
    expect(d[0].cells.map((c) => c.fileRole)).toEqual(['award', 'current'])
  })

  it('führt einen Zugang nur mit der Fundstelle im aktuellen Stand', () => {
    const d = differencesFromTooling([
      mapping({ status: 'new', awardRow: null, oneTimeAward: null, currentRow: 91, delta: 166_300 }),
    ])
    expect(d[0].cells).toHaveLength(1)
    expect(d[0].cells[0]).toMatchObject({ fileRole: 'current', valueState: 'constant' })
    expect(d[0].kind).toBe('added')
  })

  it('führt einen Wegfall mit der Fundstelle im Vergabestand', () => {
    const d = differencesFromTooling([
      mapping({ status: 'removed', currentRow: null, oneTimeCurrent: null, delta: -100 }),
    ])
    expect(d[0].cells).toHaveLength(1)
    expect(d[0].cells[0]).toMatchObject({ fileRole: 'award', valueState: 'constant' })
    expect(d[0].kind).toBe('removed')
  })

  it('kennzeichnet eine Zelle ohne Betrag als leer', () => {
    // Eine benannte Position ohne Preis ist etwas anderes als eine Position mit
    // dem Preis null — der Zellzustand hält das auseinander.
    const d = differencesFromTooling([
      mapping({ status: 'new', awardRow: null, oneTimeAward: null, currentRow: 89, oneTimeCurrent: null, delta: 0 }),
    ])
    expect(d[0].cells[0].valueState).toBe('empty')
  })

  it('verknüpft die Datenqualitäts-Befunde derselben Zeile', () => {
    const d = differencesFromTooling(
      [mapping({ status: 'changed', currentRow: 88, delta: 5 })],
      new Map([[88, ['DQ-B-003']]]),
    )
    expect(d[0].relatedDq).toEqual(['DQ-B-003'])
  })
})
