// Datenqualitäts-Detektoren (Kap. 7.7 und 8.7, R-12).

import { describe, it, expect } from 'vitest'
import {
  SEVERITY_MATRIX,
  severityOf,
  assignFindingIds,
  summarize,
  groupByTotalLine,
  detectZeroCostPositions,
  detectStaleTotalLines,
  detectNamedRowsWithoutParameters,
  detectDuplicateNames,
  detectAwardPlaceholders,
  detectRowShifts,
  detectAnchorLabelMismatch,
  type RawFinding,
  type DetectorId,
} from '../data-quality'

const raw = (over: Partial<RawFinding>): RawFinding => ({
  detector: 'D02',
  scope: 'current',
  context: 'cost_path',
  descriptionDe: 'Befund',
  recommendationDe: 'Empfehlung',
  affectedCells: [{ fileRole: 'current', sheet: 'MATERIAL', cell: 'K10' }],
  relatedDifferenceIds: [],
  ...over,
})

describe('Schwere kommt aus der Matrix', () => {
  it('bewertet dieselbe Beobachtung im Rechenpfad höher als in den Stammdaten', () => {
    expect(severityOf('D02', 'cost_path')).toBe('high')
    expect(severityOf('D02', 'metadata')).toBe('low')
  })

  it('deckt jeden Detektor in jedem Kontext ab', () => {
    // Eine Lücke in der Matrix wäre ein Befund ohne Schwere — und damit ein
    // Befund, der in keiner Rangfolge auftaucht.
    const detectors = Object.keys(SEVERITY_MATRIX) as DetectorId[]
    expect(detectors).toHaveLength(17)
    for (const d of detectors) {
      for (const ctx of ['cost_path', 'template', 'metadata'] as const) {
        expect(SEVERITY_MATRIX[d][ctx]).toBeTruthy()
      }
    }
  })

  it('führt den Dateinamen-Widerspruch als härtesten Befund', () => {
    // Stimmt die Sachnummer im Dateinamen nicht zum Blattinhalt, ist der ganze
    // Vergleich hinfällig — das ist kein abgestufter Hinweis.
    expect(severityOf('D15', 'metadata')).toBe('critical')
  })
})

describe('assignFindingIds', () => {
  it('nummeriert nach Schwere und kennzeichnet den betroffenen Stand', () => {
    const out = assignFindingIds([
      raw({ detector: 'D10', context: 'metadata', scope: 'both' }),
      raw({ detector: 'D02', context: 'cost_path', scope: 'current' }),
    ])
    expect(out.map((f) => f.findingId)).toEqual(['DQ-B-001', 'DQ-AB-002'])
    expect(out[0].severity).toBe('high')
    expect(out[1].severity).toBe('info')
  })

  it('vergibt bei gleicher Eingabe dieselben Kennungen, unabhängig von der Reihenfolge', () => {
    // Ohne diese Eigenschaft zeigen zwei Läufe derselben Dateien auf
    // verschiedene Kennungen, und jeder Verweis aus einer Folie wird ungültig.
    const a = raw({ detector: 'D02', affectedCells: [{ fileRole: 'current', sheet: 'MATERIAL', cell: 'K10' }] })
    const b = raw({ detector: 'D02', affectedCells: [{ fileRole: 'current', sheet: 'MATERIAL', cell: 'K40' }] })
    const c = raw({ detector: 'D11', context: 'cost_path' })

    const lauf1 = assignFindingIds([a, b, c]).map((f) => `${f.findingId}:${f.affectedCells[0]?.cell}:${f.detector}`)
    const lauf2 = assignFindingIds([c, b, a]).map((f) => `${f.findingId}:${f.affectedCells[0]?.cell}:${f.detector}`)
    expect(lauf2).toEqual(lauf1)
  })

  it('lässt eine ausdrücklich gesetzte Schwere stehen', () => {
    const out = assignFindingIds([raw({ detector: 'D02', severity: 'low' })])
    expect(out[0].severity).toBe('low')
  })
})

describe('summarize', () => {
  it('zählt die Schweren getrennt und nennt die gelaufenen Detektoren', () => {
    const findings = assignFindingIds([
      raw({ detector: 'D02', context: 'cost_path' }),
      raw({ detector: 'D10', context: 'metadata' }),
    ])
    const s = summarize(findings, ['D10', 'D02'])
    expect(s.count).toBe(2)
    expect(s.severityCounts).toEqual({ critical: 0, high: 1, medium: 0, low: 0, info: 1 })
    expect(s.detectorsRun).toEqual(['D02', 'D10'])
  })
})

describe('D02 — benannte Position ohne Kosten', () => {
  it('meldet benannte Positionen mit Preis null', () => {
    const f = detectZeroCostPositions(
      [
        { row: 10, name: 'Dichtung', cost: 0 },
        { row: 11, name: 'Halter', cost: 4.5 },
        { row: 12, name: 'Kappe', cost: null },
      ],
      'current',
    )
    expect(f).toHaveLength(1)
    expect(f[0].descriptionDe).toContain('2 benannte Positionen')
    expect(f[0].affectedCells.map((c) => c.cell)).toEqual(['K10', 'K12'])
  })

  it('übergeht namenlose Zeilen', () => {
    // Eine leere Zeile ohne Preis ist keine Position ohne Preis.
    expect(detectZeroCostPositions([{ row: 10, name: '   ', cost: 0 }], 'current')).toEqual([])
  })

  it('meldet nichts, wenn alle Positionen bepreist sind', () => {
    expect(detectZeroCostPositions([{ row: 10, name: 'Dichtung', cost: 1 }], 'current')).toEqual([])
  })

  it('nutzt den erhobenen Zellbezug, wenn es einen gibt', () => {
    const f = detectZeroCostPositions([{ row: 10, name: 'Dichtung', cost: 0, costCell: 'AP10' }], 'current')
    expect(f[0].affectedCells[0].cell).toBe('AP10')
  })
})

describe('D07 — Infozeile gegen ihre Komponenten', () => {
  const rows = [
    { row: 15, name: 'TOTAL Baugruppe 1', cost: 630, isTotalLine: true },
    { row: 16, name: 'Gehäuse', cost: 400 },
    { row: 17, name: 'Deckel', cost: 279 },
    { row: 20, name: 'TOTAL Baugruppe 2', cost: 100, isTotalLine: true },
    { row: 21, name: 'Halter', cost: 100 },
  ]

  it('ordnet die Komponenten der Infozeile über die Blattreihenfolge zu', () => {
    const g = groupByTotalLine(rows)
    expect(g).toHaveLength(2)
    expect(g[0]).toMatchObject({ totalRow: 15, componentSum: 679 })
    expect(g[0].componentRows).toEqual([16, 17])
    expect(g[1].componentRows).toEqual([21])
  })

  it('meldet die veraltete Infozeile mit beiden Beträgen', () => {
    const f = detectStaleTotalLines(groupByTotalLine(rows), 'current')
    expect(f).toHaveLength(1)
    expect(f[0].descriptionDe).toContain('630')
    expect(f[0].descriptionDe).toContain('679')
    expect(f[0].affectedCells[0].cell).toBe('AP15')
  })

  it('meldet nichts, wenn die Infozeile stimmt', () => {
    expect(detectStaleTotalLines(groupByTotalLine(rows.slice(3)), 'current')).toEqual([])
  })

  it('meldet nichts für eine Infozeile ohne Komponenten', () => {
    // Eine Gruppenüberschrift ohne Zeilen darunter ist keine falsche Summe.
    const g = groupByTotalLine([{ row: 15, name: 'TOTAL', cost: 630, isTotalLine: true }])
    expect(g[0].componentSum).toBeNull()
    expect(detectStaleTotalLines(g, 'current')).toEqual([])
  })

  it('meldet nichts, wenn keine Komponente einen Betrag trägt', () => {
    // Komponenten ohne Kostenangabe ergeben nicht die Summe null, sondern gar
    // keine Summe. Als 0 gelesen entstünde ein Widerspruch zur Infozeile, den
    // es nicht gibt.
    const g = groupByTotalLine([
      { row: 15, name: 'TOTAL', cost: 630, isTotalLine: true },
      { row: 16, name: 'Gehäuse', cost: null },
      { row: 17, name: 'Deckel', cost: null },
    ])
    expect(g[0].componentSum).toBeNull()
    expect(detectStaleTotalLines(g, 'current')).toEqual([])
  })

  it('summiert die Komponenten, die einen Betrag tragen', () => {
    const g = groupByTotalLine([
      { row: 15, name: 'TOTAL', cost: 400, isTotalLine: true },
      { row: 16, name: 'Gehäuse', cost: 400 },
      { row: 17, name: 'Deckel', cost: null },
    ])
    expect(g[0].componentSum).toBe(400)
    expect(detectStaleTotalLines(g, 'current')).toEqual([])
  })

  it('lässt Zeilen vor der ersten Infozeile unberücksichtigt', () => {
    const g = groupByTotalLine([{ row: 5, name: 'Vorlauf', cost: 99 }, ...rows])
    expect(g[0].componentSum).toBe(679)
  })

  it('beendet die Gruppe an einer Lücke in der Zeilenfolge', () => {
    // Eine Leerzeile trennt im Blatt die Baugruppen; der Zeilenlauf überspringt
    // sie, weshalb sie sich nur an der Zeilennummer zeigt. Ohne diese Grenze
    // zieht die letzte Gruppe alles bis zum Blattende an sich — im Anlassfall
    // einen angehängten Block, der zu keiner Baugruppe gehört.
    const g = groupByTotalLine([
      { row: 15, name: 'TOTAL Baugruppe', cost: 300, isTotalLine: true },
      { row: 16, name: 'Gehäuse', cost: 100 },
      { row: 17, name: 'Deckel', cost: 200 },
      // Zeile 18 ist leer und fehlt deshalb ganz.
      { row: 19, name: 'Angehängtes Werkzeug', cost: 900_000 },
    ])
    expect(g[0].componentRows).toEqual([16, 17])
    expect(g[0].componentSum).toBe(300)
    expect(detectStaleTotalLines(g, 'current')).toEqual([])
  })

  it('nimmt eine Komponente nur direkt unter der Infozeile auf', () => {
    // Steht zwischen Infozeile und erster Komponente eine Leerzeile, ist die
    // Zugehörigkeit nicht belegt — dann lieber keine Aussage.
    const g = groupByTotalLine([
      { row: 15, name: 'TOTAL Baugruppe', cost: 300, isTotalLine: true },
      { row: 17, name: 'Gehäuse', cost: 100 },
    ])
    expect(g[0].componentRows).toEqual([])
    expect(detectStaleTotalLines(g, 'current')).toEqual([])
  })
})

describe('D11 — benannte Zeile ohne Parameter', () => {
  it('meldet jede Zeile einzeln, mit Name und Fundstelle', () => {
    const f = detectNamedRowsWithoutParameters(
      [
        { row: 115, name: 'Fügevorgang', kind: 'named_without_parameters' },
        { row: 20, name: 'Montage', kind: 'station' },
      ],
      'current',
    )
    expect(f).toHaveLength(1)
    expect(f[0].descriptionDe).toContain('Fügevorgang')
    expect(f[0].affectedCells[0].cell).toBe('B115')
  })
})

describe('D10 — mehrfach vergebene Namen', () => {
  it('meldet Bezeichnungen, die mehrfach vorkommen', () => {
    const f = detectDuplicateNames(
      [
        { row: 10, name: 'Halter', cost: 1 },
        { row: 11, name: 'Halter', cost: 1 },
        { row: 12, name: 'Deckel', cost: 1 },
      ],
      'current',
      'MATERIAL',
    )
    expect(f).toHaveLength(1)
    expect(f[0].severity).toBeUndefined() // Schwere setzt erst assignFindingIds
    expect(f[0].affectedCells.map((c) => c.cell)).toEqual(['B10', 'B11'])
  })

  it('meldet nichts bei durchweg eindeutigen Namen', () => {
    expect(
      detectDuplicateNames([{ row: 10, name: 'Halter', cost: 1 }], 'current', 'MATERIAL'),
    ).toEqual([])
  })

  it('zählt namenlose Zeilen nicht als Dubletten', () => {
    expect(
      detectDuplicateNames(
        [
          { row: 10, name: '', cost: 1 },
          { row: 11, name: '  ', cost: 1 },
        ],
        'current',
        'MATERIAL',
      ),
    ).toEqual([])
  })
})

describe('D14 — Vergabe-Platzhalter ohne Preis', () => {
  it('meldet die im Vergabestand unbepreiste, jetzt bepreiste Position', () => {
    const f = detectAwardPlaceholders([{ row: 50, name: 'Halter', costAward: 0, costCurrent: 250_000 }])
    expect(f).toHaveLength(1)
    expect(f[0].scope).toBe('award')
    expect(f[0].recommendationDe).toContain('nachgereichte Bepreisung')
  })

  it('meldet nichts, wenn die Position im Vergabestand schon einen Preis hatte', () => {
    expect(
      detectAwardPlaceholders([{ row: 50, name: 'Halter', costAward: 200_000, costCurrent: 250_000 }]),
    ).toEqual([])
  })

  it('meldet nichts, wenn die Position auch jetzt keinen Preis trägt', () => {
    // Dann ist es kein nachgereichter Preis, sondern eine offene Position — die
    // meldet D02.
    expect(detectAwardPlaceholders([{ row: 50, name: 'Halter', costAward: 0, costCurrent: 0 }])).toEqual([])
  })
})

describe('D09 — Zeilenverschiebung', () => {
  it('nennt alte und neue Position', () => {
    const f = detectRowShifts([{ awardRow: 50, currentRow: 101, name: 'Halter' }])
    expect(f[0].descriptionDe).toContain('50 → 101')
    expect(f[0].scope).toBe('both')
  })

  it('meldet nichts ohne Verschiebung', () => {
    expect(detectRowShifts([])).toEqual([])
  })
})

describe('D12 — Ankerlabel weicht ab', () => {
  const anchor = {
    rowKey: 'material_total',
    labelAward: 'Materialkosten',
    labelCurrent: 'Materialkosten gesamt',
    verifiedAward: true,
    verifiedCurrent: false,
    cellAward: 'B23',
    cellCurrent: 'B23',
  }

  it('meldet den Stand, dessen Beschriftung abweicht', () => {
    const f = detectAnchorLabelMismatch([anchor])
    expect(f).toHaveLength(1)
    expect(f[0].scope).toBe('current')
    expect(f[0].affectedCells).toEqual([{ fileRole: 'current', sheet: 'Zusammenfassung', cell: 'B23' }])
  })

  it('meldet beide Stände, wenn beide abweichen', () => {
    expect(detectAnchorLabelMismatch([{ ...anchor, verifiedAward: false }])[0].scope).toBe('both')
  })

  it('behandelt „nicht geprüft" nicht als Abweichung', () => {
    // null heisst, dass keine Beschriftung erhoben wurde. Wo nichts geprüft
    // wurde, ist auch nichts widerlegt.
    expect(detectAnchorLabelMismatch([{ ...anchor, verifiedAward: null, verifiedCurrent: null }])).toEqual([])
  })

  it('meldet nichts, wenn beide Beschriftungen bestätigt sind', () => {
    expect(detectAnchorLabelMismatch([{ ...anchor, verifiedCurrent: true }])).toEqual([])
  })
})
