// Datenqualitäts-Detektoren (Spezifikation Kap. 7.7 und 8.7, R-12).
//
// Ein Vergleich, der nur Zahlen gegenüberstellt, sagt nichts darüber, ob die
// Zahlen tragen. Die Detektoren beantworten genau das: Wo ist eine Angabe
// verschwunden, wo widerspricht eine Summe ihren Bestandteilen, wo trägt eine
// Position einen Namen aber keinen Preis. Erst zusammen mit diesen Befunden
// wird aus einem Zahlenvergleich eine Verhandlungsgrundlage.
//
// Zwei Regeln gelten für jeden Detektor:
//
//   1. Kein Befund ohne Fundstelle. Ein Hinweis, den niemand nachschlagen kann,
//      ist in einer Verhandlung wertlos.
//   2. Die Schwere kommt aus einer Matrix, nicht aus dem Einzelfall. Sonst
//      wandert dieselbe Beobachtung je nach Kontext einmal nach oben und einmal
//      nach unten, und die Rangfolge wird beliebig.
//
// Pure Funktionen, keine I/O.

import { normalizeMaterialName } from './material-name'

/** Katalog nach Kap. 8.7. Die Kennungen sind Teil der Ausgabe und bleiben stabil. */
export type DetectorId =
  | 'D01' // Datum als Text
  | 'D02' // benannte Position ohne Kosten
  | 'D03' // Fehlerzellen in Rechenpfaden
  | 'D04' // externe Verknüpfungen
  | 'D05' // neu versteckte Spalten
  | 'D06' // Summenzeile ≠ Summe der Detailzeilen
  | 'D07' // Infozeile ≠ Komponentensumme
  | 'D08' // Basis gelöscht, Stückwert hart
  | 'D09' // Zeilenverschiebung
  | 'D10' // mehrfach vergebene Namen
  | 'D11' // benannte Zeile ohne Parameter
  | 'D12' // Ankerlabel weicht ab
  | 'D13' // hart codierter Satz
  | 'D14' // Vergabe-Platzhalter ohne Preis
  | 'D15' // Dateiname widerspricht Blattinhalt
  | 'D16' // Stammdaten der App ≠ Dateiwerte
  | 'D17' // Verlagerung Material ↔ Fertigung

export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info'

/**
 * Wo die Beobachtung sitzt.
 *
 * Der Kontext entscheidet über die Schwere, nicht der Einzelfall: dieselbe
 * gelöschte Angabe wiegt im Rechenpfad schwerer als in einem Stammdatenfeld.
 */
export type FindingContext = 'cost_path' | 'template' | 'metadata'

/** Welcher Stand betroffen ist. */
export type FindingScope = 'award' | 'current' | 'both'

export interface CellRef {
  fileRole: FindingScope
  sheet: string
  cell: string
}

export interface DqFinding {
  findingId: string
  detector: DetectorId
  scope: FindingScope
  context: FindingContext
  severity: Severity
  descriptionDe: string
  recommendationDe: string
  affectedCells: CellRef[]
  relatedDifferenceIds: string[]
}

/**
 * Detektor × Kontext → Schwere.
 *
 * Bewusst als Tabelle und nicht als Kette von Bedingungen im Code: so lässt
 * sich die Rangfolge lesen, ohne sie aus Verzweigungen zu rekonstruieren, und
 * eine Änderung ist eine Zeile statt einer Suche.
 */
export const SEVERITY_MATRIX: Readonly<Record<DetectorId, Readonly<Record<FindingContext, Severity>>>> = {
  D01: { cost_path: 'medium', template: 'low', metadata: 'medium' },
  D02: { cost_path: 'high', template: 'low', metadata: 'low' },
  D03: { cost_path: 'high', template: 'medium', metadata: 'low' },
  D04: { cost_path: 'medium', template: 'low', metadata: 'low' },
  D05: { cost_path: 'high', template: 'medium', metadata: 'low' },
  D06: { cost_path: 'high', template: 'high', metadata: 'medium' },
  D07: { cost_path: 'medium', template: 'medium', metadata: 'low' },
  D08: { cost_path: 'high', template: 'medium', metadata: 'low' },
  D09: { cost_path: 'info', template: 'info', metadata: 'info' },
  D10: { cost_path: 'info', template: 'info', metadata: 'info' },
  D11: { cost_path: 'medium', template: 'low', metadata: 'low' },
  D12: { cost_path: 'high', template: 'medium', metadata: 'medium' },
  D13: { cost_path: 'info', template: 'info', metadata: 'info' },
  D14: { cost_path: 'medium', template: 'low', metadata: 'low' },
  D15: { cost_path: 'critical', template: 'critical', metadata: 'critical' },
  D16: { cost_path: 'medium', template: 'low', metadata: 'medium' },
  D17: { cost_path: 'medium', template: 'low', metadata: 'low' },
}

export function severityOf(detector: DetectorId, context: FindingContext): Severity {
  return SEVERITY_MATRIX[detector][context]
}

const SEVERITY_RANK: Record<Severity, number> = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }
const SCOPE_TAG: Record<FindingScope, string> = { award: 'A', current: 'B', both: 'AB' }

/** Ein Befund, bevor er seine laufende Kennung bekommt. */
export type RawFinding = Omit<DqFinding, 'findingId' | 'severity'> & { severity?: Severity }

/**
 * Kennungen vergeben.
 *
 * Die Reihenfolge ist deterministisch — Schwere, dann Detektor, dann erste
 * Fundstelle. Zwei Läufe über dieselben Dateien vergeben damit dieselben
 * Kennungen, und die Verweise aus Sichten und Folien bleiben gültig.
 */
export function assignFindingIds(findings: RawFinding[]): DqFinding[] {
  const withSeverity = findings.map((f) => ({ ...f, severity: f.severity ?? severityOf(f.detector, f.context) }))
  const cellKey = (f: RawFinding) => {
    const c = f.affectedCells[0]
    return c === undefined ? '' : `${c.sheet}!${c.cell}`
  }
  const sorted = [...withSeverity].sort(
    (a, b) =>
      SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] ||
      a.detector.localeCompare(b.detector) ||
      cellKey(a).localeCompare(cellKey(b)) ||
      a.descriptionDe.localeCompare(b.descriptionDe),
  )
  return sorted.map((f, i) => ({
    ...f,
    findingId: `DQ-${SCOPE_TAG[f.scope]}-${String(i + 1).padStart(3, '0')}`,
  }))
}

export interface DataQualityResult {
  findings: DqFinding[]
  count: number
  severityCounts: Record<Severity, number>
  /** Welche Detektoren gelaufen sind — ein nicht gelaufener Detektor ist kein bestandener. */
  detectorsRun: DetectorId[]
}

export function summarize(findings: DqFinding[], detectorsRun: DetectorId[]): DataQualityResult {
  const severityCounts: Record<Severity, number> = { critical: 0, high: 0, medium: 0, low: 0, info: 0 }
  for (const f of findings) severityCounts[f.severity] += 1
  return { findings, count: findings.length, severityCounts, detectorsRun: [...detectorsRun].sort() }
}

// ── Einzelne Detektoren ─────────────────────────────────────────────────────

export interface NamedCostRow {
  row: number
  name: string
  cost: number | null
  /** Zellbezug der Kostenzelle, für den Nachweis. */
  costCell?: string
}

const SHEET = {
  material: 'MATERIAL',
  manufacturing: 'MANUFACTURING COSTS',
  sbm: 'SBM-DEVICES-FWZ',
  summary: 'Zusammenfassung',
} as const

/** Fundstelle der Kostenzelle; ohne erhobenen Bezug die Spaltenannahme des Blatts. */
const cellOf = (row: NamedCostRow, fallbackColumn: string): string => row.costCell ?? `${fallbackColumn}${row.row}`

/**
 * D02 — benannte Position ohne Kosten.
 *
 * Eine Position, die einen Namen trägt, aber keinen Preis, ist entweder aus dem
 * Umfang gefallen oder im Preis versteckt. Beides ist eine Frage; als
 * Einsparung zu lesen wäre falsch.
 */
export function detectZeroCostPositions(
  positions: NamedCostRow[],
  scope: FindingScope,
  sheet: string = SHEET.material,
): RawFinding[] {
  const hits = positions.filter((p) => p.name.trim() !== '' && (p.cost ?? 0) === 0)
  if (hits.length === 0) return []
  return [
    {
      detector: 'D02',
      scope,
      context: 'cost_path',
      descriptionDe: `${hits.length} benannte Positionen ohne Kosten: ${hits
        .map((h) => `Zeile ${h.row}`)
        .join(', ')}.`,
      recommendationDe:
        'Je Position klären, ob sie aus dem Umfang gefallen ist oder ihr Preis in einer anderen Position steckt. Ohne Klärung ist keine der beiden Lesarten belegt.',
      affectedCells: hits.map((h) => ({ fileRole: scope, sheet, cell: cellOf(h, 'K') })),
      relatedDifferenceIds: [],
    },
  ]
}

export interface TotalLineGroup {
  totalRow: number
  totalName: string
  totalValue: number | null
  componentRows: number[]
  /**
   * Summe der Komponenten — `null`, solange keine einzige einen Betrag trägt.
   *
   * Der Unterschied ist tragend: Komponenten ohne Kostenangabe ergeben nicht
   * die Summe null, sondern gar keine Summe. Sie als 0 zu lesen erzeugte einen
   * Widerspruch zur Infozeile, den es nicht gibt.
   */
  componentSum: number | null
}

/**
 * Infozeilen ihren Bestandteilen zuordnen.
 *
 * Die Formblätter führen Zwischensummen als eigene Zeile über der Gruppe, die
 * sie zusammenfasst. Zwei Dinge begrenzen eine Gruppe: die nächste Summenzeile
 * — und eine Lücke in der Zeilenfolge.
 *
 * Die Lücke ist tragend. Eine Leerzeile trennt im Blatt die Baugruppen
 * voneinander, und der Zeilenlauf überspringt sie, weshalb sie sich nur noch an
 * der nicht mehr fortlaufenden 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. Der Detektor meldete dann
 * einen Widerspruch in Millionenhöhe, den es nicht gibt, und ein Bericht mit
 * einem solchen Befund wird zu Recht nicht mehr gelesen.
 */
export function groupByTotalLine(
  rows: Array<NamedCostRow & { isTotalLine?: boolean }>,
): TotalLineGroup[] {
  const groups: TotalLineGroup[] = []
  let current: TotalLineGroup | null = null
  let lastRow: number | null = null

  for (const r of [...rows].sort((a, b) => a.row - b.row)) {
    if (r.isTotalLine === true) {
      current = {
        totalRow: r.row,
        totalName: r.name,
        totalValue: r.cost,
        componentRows: [],
        componentSum: null,
      }
      groups.push(current)
      lastRow = r.row
      continue
    }
    if (current !== null && lastRow !== null && r.row !== lastRow + 1) current = null
    lastRow = r.row
    if (current === null) continue
    current.componentRows.push(r.row)
    if (r.cost !== null) current.componentSum = (current.componentSum ?? 0) + r.cost
  }
  return groups
}

const TOTAL_TOLERANCE = 0.005

/**
 * D07 — Infozeile widerspricht ihrer Komponentensumme.
 *
 * Die Zwischensumme wird beim Ergänzen einer Position gern vergessen. Sie geht
 * in keine Rechnung ein, steht aber im Blatt — und wer sie liest, liest einen
 * veralteten Stand.
 */
export function detectStaleTotalLines(
  groups: TotalLineGroup[],
  scope: FindingScope,
  sheet: string = SHEET.sbm,
): RawFinding[] {
  return groups
    .filter(
      (g) =>
        g.totalValue !== null && g.componentSum !== null && Math.abs(g.totalValue - g.componentSum) > TOTAL_TOLERANCE,
    )
    .map((g) => ({
      detector: 'D07' as const,
      scope,
      context: 'cost_path' as const,
      descriptionDe: `Infozeile „${g.totalName}" (Zeile ${g.totalRow}) weist ${g.totalValue} aus, die Summe ihrer ${g.componentRows.length} Komponenten ergibt ${g.componentSum}.`,
      recommendationDe: 'Die Infozeile gegen ihre Komponenten nachziehen oder als veraltet kennzeichnen.',
      affectedCells: [{ fileRole: scope, sheet, cell: `AP${g.totalRow}` }],
      relatedDifferenceIds: [],
    }))
}

export interface NamedParameterRow {
  row: number
  name: string
  kind: string
}

/**
 * D11 — benannte Zeile ohne Parameter.
 *
 * Eine Prozesszeile mit Namen, aber ohne Zeit, Personal und Kosten. Sie als
 * Station zu zählen überzeichnet die Fertigungstiefe, sie stillschweigend
 * fallen zu lassen verliert einen Befund.
 */
export function detectNamedRowsWithoutParameters(
  rows: NamedParameterRow[],
  scope: FindingScope,
  sheet: string = SHEET.manufacturing,
): RawFinding[] {
  const hits = rows.filter((r) => r.kind === 'named_without_parameters')
  return hits.map((h) => ({
    detector: 'D11' as const,
    scope,
    context: 'cost_path' as const,
    descriptionDe: `„${h.name}" (Zeile ${h.row}) ist als Prozessschritt benannt, trägt aber weder Zykluszeit noch Personal noch Kosten.`,
    recommendationDe:
      'Klären, ob die Zeile ein eigener Arbeitsschritt ist — dann fehlen die Parameter — oder eine Beschreibung, die zu einer anderen Station gehört.',
    affectedCells: [{ fileRole: scope, sheet, cell: `B${h.row}` }],
    relatedDifferenceIds: [],
  }))
}

/**
 * D10 — mehrfach vergebene Namen.
 *
 * Kein Fehler, aber ein Risiko: Gleichteile lassen sich nur über die
 * Reihenfolge zuordnen, und wer eine Zeile einfügt, verschiebt die Zuordnung.
 */
export function detectDuplicateNames(
  rows: NamedCostRow[],
  scope: FindingScope,
  sheet: string,
): RawFinding[] {
  const byName = new Map<string, number[]>()
  for (const r of rows) {
    const k = normalizeMaterialName(r.name).normalized
    if (k === '') continue
    byName.set(k, [...(byName.get(k) ?? []), r.row])
  }
  const dupes = [...byName.entries()].filter(([, rowsOfName]) => rowsOfName.length > 1)
  if (dupes.length === 0) return []
  return [
    {
      detector: 'D10',
      scope,
      context: 'metadata',
      descriptionDe: `${dupes.length} Bezeichnungen kommen mehrfach vor; die Zuordnung zwischen den Ständen hängt dort an der Reihenfolge.`,
      recommendationDe: 'Bei Abweichungen in diesen Zeilen die Zuordnung von Hand nachvollziehen.',
      affectedCells: dupes.flatMap(([, rowsOfName]) =>
        rowsOfName.map((row) => ({ fileRole: scope, sheet, cell: `B${row}` })),
      ),
      relatedDifferenceIds: [],
    },
  ]
}

export interface AwardPlaceholderInput {
  row: number
  name: string
  costAward: number | null
  costCurrent: number | null
}

/**
 * D14 — Vergabe-Platzhalter ohne Preis.
 *
 * Im Vergabestand benannt, aber ohne Preis; im aktuellen Stand bepreist. Der
 * Zuwachs ist dann kein neuer Umfang, sondern eine nachgereichte Bepreisung —
 * ein Unterschied, der in der Verhandlung zählt.
 */
export function detectAwardPlaceholders(rows: AwardPlaceholderInput[], sheet: string = SHEET.sbm): RawFinding[] {
  const hits = rows.filter(
    (r) => r.name.trim() !== '' && (r.costAward ?? 0) === 0 && (r.costCurrent ?? 0) > 0,
  )
  if (hits.length === 0) return []
  return [
    {
      detector: 'D14',
      scope: 'award',
      context: 'cost_path',
      descriptionDe: `${hits.length} Positionen waren im Vergabestand benannt, aber ohne Preis, und sind jetzt bepreist: ${hits
        .map((h) => `Zeile ${h.row}`)
        .join(', ')}.`,
      recommendationDe:
        'Als nachgereichte Bepreisung führen, nicht als neuen Umfang — der Umfang stand bereits im Vergabestand.',
      affectedCells: hits.map((h) => ({ fileRole: 'award' as const, sheet, cell: `AP${h.row}` })),
      relatedDifferenceIds: [],
    },
  ]
}

export interface RowShiftInput {
  awardRow: number
  currentRow: number
  name: string
}

/**
 * D09 — Zeilenverschiebung bei gleichem Inhalt.
 *
 * Wirtschaftlich neutral, aber auszuweisen: ohne den Hinweis liest sich eine
 * verschobene Zeile beim Vergleich zweier Ausdrucke wie ein Wegfall plus ein
 * Zugang.
 */
export function detectRowShifts(shifts: RowShiftInput[], sheet: string = SHEET.sbm): RawFinding[] {
  if (shifts.length === 0) return []
  return [
    {
      detector: 'D09',
      scope: 'both',
      context: 'template',
      descriptionDe: `${shifts.length} inhaltlich unveränderte Positionen stehen jetzt an anderer Stelle: ${shifts
        .map((s) => `Zeile ${s.awardRow} → ${s.currentRow}`)
        .join(', ')}.`,
      recommendationDe: 'Beim zeilenweisen Vergleich zweier Ausdrucke berücksichtigen.',
      affectedCells: shifts.map((s) => ({ fileRole: 'current' as const, sheet, cell: `B${s.currentRow}` })),
      relatedDifferenceIds: [],
    },
  ]
}

export interface AnchorLabelInput {
  rowKey: string
  labelAward: string | null
  labelCurrent: string | null
  verifiedAward: boolean | null
  verifiedCurrent: boolean | null
  cellAward: string | null
  cellCurrent: string | null
}

/**
 * D12 — Ankerlabel weicht von der Registry ab.
 *
 * Die Kennzeichnung einer Zeile stammt aus der Registry, der Wert aus der
 * Datei. Stimmen die Beschriftungen nicht überein, zeigt die Anwendung sonst
 * eine Bezeichnung an, die in der Datei gar nicht steht — der Befund, der
 * diesen Detektor veranlasst hat.
 *
 * `null` heisst „nicht geprüft" und ist kein Befund: wo keine Beschriftung
 * erhoben wurde, ist auch keine widerlegt.
 */
export function detectAnchorLabelMismatch(anchors: AnchorLabelInput[], sheet: string = SHEET.summary): RawFinding[] {
  return anchors
    .filter((a) => a.verifiedAward === false || a.verifiedCurrent === false)
    .map((a) => {
      const cells: CellRef[] = []
      if (a.verifiedAward === false && a.cellAward !== null) {
        cells.push({ fileRole: 'award', sheet, cell: a.cellAward })
      }
      if (a.verifiedCurrent === false && a.cellCurrent !== null) {
        cells.push({ fileRole: 'current', sheet, cell: a.cellCurrent })
      }
      const scope: FindingScope =
        a.verifiedAward === false && a.verifiedCurrent === false
          ? 'both'
          : a.verifiedAward === false
            ? 'award'
            : 'current'
      return {
        detector: 'D12' as const,
        scope,
        context: 'cost_path' as const,
        descriptionDe: `Die Beschriftung der Ankerzeile „${a.rowKey}" weicht von der Registry ab (Vergabe: „${a.labelAward ?? '—'}", aktuell: „${a.labelCurrent ?? '—'}").`,
        recommendationDe:
          'Registry-Eintrag gegen die Datei prüfen. Bis dahin gilt der Wert der Datei, nicht die Bezeichnung der Registry.',
        affectedCells: cells,
        relatedDifferenceIds: [],
      }
    })
}
