// Zellzustände aus einer Arbeitsmappe holen (Spezifikation Kap. 6.3).
//
// Die Detektoren in `data-quality-cells.ts` arbeiten auf Momentaufnahmen und
// nicht auf der Arbeitsmappe selbst — so bleiben ihre Regeln ohne Datei
// prüfbar. Diese Datei ist die Brücke dorthin und die einzige Stelle, die
// wissen muss, wie ExcelJS Formeln, Ergebnisse und ausgeblendete Spalten
// ablegt.

import type { CellSnapshot } from './cell-state'

/** Was von einer Arbeitsmappe gebraucht wird — bewusst schmal, damit Testdoubles reichen. */
interface CellLike {
  value: unknown
  text?: string
  formula?: string
  result?: unknown
  sharedFormula?: string
  error?: string
  /** Teil eines verbundenen Bereichs. */
  isMerged?: boolean
  /** Führende Zelle des verbundenen Bereichs. */
  master?: { address?: string }
  address?: string
}

interface RowLike {
  eachCell(opts: { includeEmpty: boolean }, cb: (cell: CellLike, colNumber: number) => void): void
}

interface ColumnLike {
  letter?: string
  hidden?: boolean
}

export interface WorksheetLike {
  name: string
  rowCount: number
  getRow(row: number): RowLike
  columns?: Array<ColumnLike | undefined>
}

/** Spaltenbuchstabe zu einer 1-basierten Spaltennummer. */
export function columnLetter(index: number): string {
  let n = index
  let out = ''
  while (n > 0) {
    const rest = (n - 1) % 26
    out = String.fromCharCode(65 + rest) + out
    n = Math.floor((n - 1) / 26)
  }
  return out
}

/**
 * Formel und Ergebnis einer Zelle auseinanderhalten.
 *
 * ExcelJS legt eine Formelzelle als Objekt mit `formula` und `result` ab, eine
 * Zelle mit gemeinsam genutzter Formel als `sharedFormula`, und einen
 * Fehlerwert als `{ error: '#N/A' }`. Für die Detektoren zählt nur zweierlei:
 * ob gerechnet wird und was dabei herauskommt.
 */
/**
 * Anzeigetext einer Zelle, ohne daran zu scheitern.
 *
 * `text` ist bei ExcelJS eine berechnete Eigenschaft und wirft bei verbundenen
 * Zellen, deren führende Zelle leer ist. Ein Absturz beim Einlesen wäre die
 * schlechteste aller Antworten — der Anzeigetext ist für die Detektoren eine
 * Zusatzangabe, kein tragender Wert.
 */
function safeText(cell: CellLike): string | null {
  try {
    return typeof cell.text === 'string' ? cell.text : null
  } catch {
    return null
  }
}

export function toSnapshot(cell: CellLike, sheet: string, ref: string): CellSnapshot {
  const v = cell.value
  const text = safeText(cell)

  // Nur rechnende Zellen und Fehlerzellen liegen als Formelobjekt vor. Datum
  // und formatierter Text sind ebenfalls Objekte, tragen aber einen Wert —
  // sie gehören in die Wertbehandlung, nicht in die Formelbehandlung.
  if (v !== null && typeof v === 'object' && isFormulaShaped(v)) {
    const obj = v as CellLike
    const formula = obj.formula ?? obj.sharedFormula ?? null
    // Ein Fehlerwert steht bei ExcelJS im Ergebnis, nicht neben ihm — und ein
    // Ergebnis, das selbst ein Fehlerobjekt ist, muss auf seinen Text kommen.
    const rawResult = obj.error ?? obj.result ?? null
    const result =
      rawResult !== null && typeof rawResult === 'object' && 'error' in (rawResult as Record<string, unknown>)
        ? (rawResult as { error: string }).error
        : rawResult
    return {
      sheet,
      cell: ref,
      formula: formula === undefined ? null : formula,
      value: normalizeValue(result),
      text,
    }
  }

  return { sheet, cell: ref, formula: null, value: normalizeValue(v), text }
}

const FORMULA_KEYS = ['formula', 'sharedFormula', 'result', 'error'] as const

function isFormulaShaped(v: object): boolean {
  return FORMULA_KEYS.some((k) => k in v)
}

function normalizeValue(v: unknown): CellSnapshot['value'] {
  if (v === null || v === undefined) return null
  if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v
  if (v instanceof Date) return v.getTime()
  // Verschachtelte Ergebnisse (Rich Text, Hyperlinks) auf ihren Text bringen —
  // ein Objekt in einer Wertspalte ist für die Detektoren kein Betrag.
  const asRich = v as { richText?: Array<{ text?: string }>; text?: string }
  if (Array.isArray(asRich.richText)) return asRich.richText.map((p) => p.text ?? '').join('')
  if (typeof asRich.text === 'string') return asRich.text
  return null
}

/**
 * Zellen eines Blattbereichs als Momentaufnahmen lesen.
 *
 * Der Bereich wird bewusst verlangt und nicht erraten: ein ganzes Blatt zu
 * lesen kostet bei diesen Arbeitsmappen spürbar Zeit, und die Detektoren
 * brauchen jeweils nur ihre Spalten.
 */
export function readCells(
  ws: WorksheetLike,
  opts: { fromRow: number; toRow: number; columns?: number[] },
): CellSnapshot[] {
  const out: CellSnapshot[] = []
  const wanted = opts.columns === undefined ? null : new Set(opts.columns)
  const last = Math.min(opts.toRow, ws.rowCount)

  for (let r = opts.fromRow; r <= last; r++) {
    ws.getRow(r).eachCell({ includeEmpty: wanted !== null }, (cell, col) => {
      if (wanted !== null && !wanted.has(col)) return
      if (isCoveredByMerge(cell)) return
      out.push(toSnapshot(cell, ws.name, `${columnLetter(col)}${r}`))
    })
  }
  return out
}

/**
 * Von einem verbundenen Bereich überdeckte Zelle?
 *
 * Ein verbundener Bereich trägt seinen Wert in jeder überspannten Zelle. Ohne
 * diese Prüfung meldet jeder Detektor denselben Befund so oft, wie der Bereich
 * Spalten breit ist — im Anlassfall dreimal dasselbe Angebotsdatum.
 */
function isCoveredByMerge(cell: CellLike): boolean {
  if (cell.isMerged !== true) return false
  const master = cell.master?.address
  return master !== undefined && cell.address !== undefined && master !== cell.address
}

/**
 * Ausgeblendete Spalten eines Blatts.
 *
 * Grundlage für D05: Eine Spalte, die im Vergabestand sichtbar war und jetzt
 * nicht mehr, ist ein Transparenzverlust.
 */
export function hiddenColumns(ws: WorksheetLike): string[] {
  const cols = ws.columns ?? []
  const out: string[] = []
  cols.forEach((c, i) => {
    if (c?.hidden !== true) return
    out.push(c.letter ?? columnLetter(i + 1))
  })
  return out
}
