// Zellgenaue Datenqualitäts-Detektoren (Spezifikation Kap. 8.7, R-12).
//
// Die Detektoren daneben in `data-quality.ts` arbeiten auf geparsten Werten:
// Position, Name, Betrag. Diese hier brauchen mehr — sie fragen, wie ein Wert
// zustande kommt. Ob dort eine Formel steht oder eine von Hand eingetragene
// Zahl, ob der Bezug in eine Datei zeigt, die dem Vergleich nicht beiliegt, ob
// eine Spalte im aktuellen Stand neu ausgeblendet wurde.
//
// Der Unterschied ist nicht formal. Ein Preis, der im Vergabestand aus
// Einzelpreis mal Anzahl gerechnet wurde und jetzt als feste Zahl dasteht,
// sieht im Blatt gleich aus und ist doch nicht mehr nachvollziehbar.
//
// Pure Funktionen, keine I/O — die Zellzustände kommen als Momentaufnahme
// herein, damit die Regeln ohne Arbeitsmappe prüfbar bleiben.

import {
  classifyCell,
  isErrorValue,
  isHardcodedComputation,
  looksLikeTextDate,
  referencesExternalWorkbook,
  type CellSnapshot,
} from './cell-state'
import type { FindingScope, RawFinding } from './data-quality'

const cellRefsOf = (scope: FindingScope, cells: CellSnapshot[]) =>
  cells.map((c) => ({ fileRole: scope, sheet: c.sheet, cell: c.cell }))

const listOf = (cells: CellSnapshot[], max = 8): string => {
  const shown = cells.slice(0, max).map((c) => `${c.sheet}!${c.cell}`)
  return cells.length > max ? `${shown.join(', ')} und ${cells.length - max} weitere` : shown.join(', ')
}

/**
 * D01 — Datum als Text.
 *
 * Ein Textdatum sortiert falsch, rechnet nicht und wird beim Import zur
 * Fehlerquelle. In einem Angebotsblatt trägt es ausserdem den Angebotsstand —
 * also genau die Angabe, an der die Vergleichbarkeit hängt.
 */
export function detectTextDates(cells: CellSnapshot[], scope: FindingScope): RawFinding[] {
  const hits = cells.filter(looksLikeTextDate)
  if (hits.length === 0) return []
  return [
    {
      detector: 'D01',
      scope,
      context: 'metadata',
      descriptionDe: `${hits.length} Datumsangaben sind als Text abgelegt statt als Datum: ${listOf(hits)}.`,
      recommendationDe: 'Als Datum formatieren, damit Sortierung und Fristenrechnung tragen.',
      affectedCells: cellRefsOf(scope, hits),
      relatedDifferenceIds: [],
    },
  ]
}

/**
 * D03 — Fehlerzellen im Rechenpfad.
 *
 * `#N/A` und Verwandte stehen dort, wo eine Rechnung nicht aufgeht. Solange sie
 * in einer Plausibilitätsspalte stehen, ist es ein Vorlagenproblem; im
 * Rechenpfad ist es ein fehlender Betrag.
 */
export function detectErrorCells(
  cells: CellSnapshot[],
  scope: FindingScope,
  context: 'cost_path' | 'template' = 'template',
): RawFinding[] {
  const hits = cells.filter((c) => isErrorValue(c.value))
  if (hits.length === 0) return []
  return [
    {
      detector: 'D03',
      scope,
      context,
      descriptionDe: `${hits.length} Zellen tragen einen Fehlerwert: ${listOf(hits)}.`,
      recommendationDe:
        context === 'cost_path'
          ? 'Die zugrunde liegende Rechnung reparieren — der Betrag fehlt, er ist nicht null.'
          : 'Vorlagenprüfung nachziehen; die Fehlerwerte gehen in keine Summe ein, verdecken aber die Prüfung.',
      affectedCells: cellRefsOf(scope, hits),
      relatedDifferenceIds: [],
    },
  ]
}

/**
 * D04 — Verweise auf fremde Arbeitsmappen.
 *
 * Der Wert stammt dann aus einer Datei, die dem Vergleich nicht beiliegt. Was
 * im Blatt steht, ist ein zwischengespeichertes Ergebnis — prüfbar ist es
 * nicht.
 */
export function detectExternalLinks(cells: CellSnapshot[], scope: FindingScope): RawFinding[] {
  const hits = cells.filter((c) => referencesExternalWorkbook(c.formula))
  if (hits.length === 0) return []
  return [
    {
      detector: 'D04',
      scope,
      context: 'cost_path',
      descriptionDe: `${hits.length} Zellen beziehen ihren Wert aus einer nicht beiliegenden Arbeitsmappe: ${listOf(hits)}.`,
      recommendationDe:
        'Die verknüpften Dateien anfordern oder die Werte als geprüfte Konstanten übernehmen. Bis dahin sind diese Beträge nicht nachvollziehbar.',
      affectedCells: cellRefsOf(scope, hits),
      relatedDifferenceIds: [],
    },
  ]
}

/**
 * D05 — im aktuellen Stand neu ausgeblendete Spalten.
 *
 * Eine ausgeblendete Spalte ist kein Fehler. Eine Spalte, die im Vergabestand
 * sichtbar war und jetzt nicht mehr, ist ein Transparenzverlust — besonders
 * wenn dort die Auslegung der Werkzeuge stand.
 */
export function detectNewlyHiddenColumns(
  hiddenAward: readonly string[],
  hiddenCurrent: readonly string[],
  sheet: string,
): RawFinding[] {
  const wasVisible = new Set(hiddenAward)
  const neu = hiddenCurrent.filter((c) => !wasVisible.has(c))
  if (neu.length === 0) return []
  return [
    {
      detector: 'D05',
      scope: 'current',
      context: 'cost_path',
      descriptionDe: `${neu.length} Spalten sind im aktuellen Stand ausgeblendet, im Vergabestand waren sie sichtbar: ${neu.join(', ')}.`,
      recommendationDe: 'Einblenden lassen und den Inhalt gegen den Vergabestand vergleichen.',
      affectedCells: neu.map((c) => ({ fileRole: 'current' as const, sheet, cell: `${c}1` })),
      relatedDifferenceIds: [],
    },
  ]
}

export interface SumFormulaCheck {
  /** Zelle, die die Summe ausweist. */
  sumCell: CellSnapshot
  /** Zellen, über die summiert werden soll. */
  detailCells: CellSnapshot[]
}

/**
 * D06 — Summenzeile stimmt nicht mit ihren Detailzeilen überein.
 *
 * Die verbreitetste Ursache: die Hilfsspalten tragen nur in einem Teil der
 * Zeilen Formeln, weil beim Einfügen neuer Zeilen nicht heruntergezogen wurde.
 * Die Summe sieht dann vollständig aus und ist es nicht.
 */
export function detectBrokenTemplateSums(
  checks: SumFormulaCheck[],
  scope: FindingScope,
  tolerance = 0.005,
): RawFinding[] {
  return checks
    .map((c): RawFinding | null => {
      const ausgewiesen = typeof c.sumCell.value === 'number' ? c.sumCell.value : null
      const werte = c.detailCells.map((d) => (typeof d.value === 'number' ? d.value : null))
      const vorhanden = werte.filter((v): v is number => v !== null)
      if (ausgewiesen === null || vorhanden.length === 0) return null
      const summe = vorhanden.reduce((s, v) => s + v, 0)
      if (Math.abs(ausgewiesen - summe) <= tolerance) return null

      const ohneFormel = c.detailCells.filter((d) => classifyCell(d) === 'constant').length
      return {
        detector: 'D06' as const,
        scope,
        context: 'cost_path' as const,
        descriptionDe:
          `${c.sumCell.sheet}!${c.sumCell.cell} weist ${ausgewiesen} aus, die ${vorhanden.length} Detailzellen ergeben ${summe}.` +
          (ohneFormel > 0 ? ` ${ohneFormel} der Detailzellen tragen keine Formel.` : ''),
        recommendationDe:
          'Die Summenformel über den vollständigen Bereich ziehen und prüfen, ob in den Detailzellen Formeln fehlen.',
        affectedCells: cellRefsOf(scope, [c.sumCell, ...c.detailCells]),
        relatedDifferenceIds: [],
      }
    })
    .filter((f): f is RawFinding => f !== null)
}

export interface BaseDeletionInput {
  /** Die Zelle, die den Betrag trägt. */
  valueAward: CellSnapshot
  valueCurrent: CellSnapshot
  /** Die Zellen, aus denen sich der Betrag herleitet — Einzelpreis, Menge, Satz. */
  basisAward: CellSnapshot[]
  basisCurrent: CellSnapshot[]
}

const isEmpty = (c: CellSnapshot | undefined) => c === undefined || classifyCell(c) === 'empty'

/**
 * D08 — Basis gelöscht, Wert hart eingetragen.
 *
 * Zwei Wege führen zum selben Verlust, und beide zählen:
 *
 *   1. Die Zelle rechnete im Vergabestand und trägt jetzt eine feste Zahl.
 *   2. Die Zelle trägt eine feste Zahl, und die Angaben, aus denen sie sich
 *      herleitete, sind verschwunden.
 *
 * Der zweite Weg ist der unauffälligere und im Anlassfall der tatsächliche:
 * Einzelpreis und Anzahl sind leer, daneben steht ein Stückwert. Der Betrag mag
 * stimmen — verhandeln lässt sich über ihn nicht mehr, weil es nichts mehr gibt,
 * worüber man verhandeln könnte.
 *
 * Ein unveränderter Betrag macht den Befund nicht kleiner: Gerade dann fällt
 * niemandem auf, dass die Grundlage verschwunden ist.
 */
export function detectDeletedCalculationBase(inputs: BaseDeletionInput[]): RawFinding[] {
  const hits = inputs.filter((p) => {
    if (classifyCell(p.valueCurrent) !== 'constant') return false
    if (classifyCell(p.valueAward) === 'formula_and_cached') return true
    // Basis geleert: mindestens eine Herleitungszelle war belegt und ist es nicht mehr.
    const currentByRef = new Map(p.basisCurrent.map((c) => [c.cell, c]))
    return p.basisAward.some((a) => !isEmpty(a) && isEmpty(currentByRef.get(a.cell)))
  })
  if (hits.length === 0) return []
  return [
    {
      detector: 'D08',
      scope: 'current',
      context: 'cost_path',
      descriptionDe: `${hits.length} Beträge stehen als feste Zahl ohne die Angaben, aus denen sie sich herleiten: ${listOf(
        hits.map((h) => h.valueCurrent),
      )}.`,
      recommendationDe:
        'Die Herleitung nachfordern — Einzelpreis, Menge, Satz. Ohne sie ist der Betrag nicht verhandelbar, nur hinnehmbar.',
      affectedCells: cellRefsOf('current', hits.map((h) => h.valueCurrent)),
      relatedDifferenceIds: [],
    },
  ]
}

/**
 * D13 — hart codierter Satz.
 *
 * Ein Zuschlag oder Faktor, der als Zahl in der Formel steht statt aus einer
 * Zelle zu kommen. Er lässt sich nicht nachschlagen und bei einer Änderung
 * nicht nachziehen — und in einer Prämissendiskussion nicht belegen.
 */
export function detectHardcodedRates(cells: CellSnapshot[], scope: FindingScope): RawFinding[] {
  const hits = cells.filter((c) => isHardcodedComputation(c.formula))
  if (hits.length === 0) return []
  return [
    {
      detector: 'D13',
      scope,
      context: 'template',
      descriptionDe: `${hits.length} Formeln rechnen ausschliesslich mit festen Zahlen ohne Zellbezug: ${listOf(hits)}.`,
      recommendationDe:
        'Die Sätze in das Prämissenblatt überführen und von dort beziehen, damit sie belegbar und änderbar sind.',
      affectedCells: cellRefsOf(scope, hits),
      relatedDifferenceIds: [],
    },
  ]
}
