// Kanonische Serialisierung und Determinismus-Nachweis (Spezifikation Kap. 10,
// R-17/R-19; Befunde F-11/F-15).
//
// Zwei Mängel des V1-Ausgabeformats:
//
// F-11: Gleitkomma-Rohwerte ohne Rundungsregel. Eine Einmalzahlung stand als
//   5579440.0003921185 im Dokument — ein Artefakt der Fliesskommarechnung, kein
//   Betrag. Wer so etwas liest, misstraut zu Recht auch den übrigen Zahlen.
//
// F-15: Kein Determinismus-Nachweis. Zwei Läufe über dieselben Dateien liessen
//   sich nicht als identisch belegen, weil es keinen Inhalts-Hash gab. Ohne ihn
//   ist „gleiche Eingabe, gleiches Ergebnis" eine Behauptung.
//
// Pure Funktionen; der Hash nutzt die Web-Crypto-API und ist deshalb async.

/**
 * Grenze, unterhalb derer ein Abstand zur nächsten ganzen Zahl als
 * Rechenartefakt gilt (Spec Kap. 10). Bewusst klein: 0,005 EUR sind bei einer
 * Einmalzahlung im Millionenbereich Rauschen, bei einem Stückpreis wären sie es
 * nicht — deshalb greift die Regel nur zusammen mit INTEGER_MAGNITUDE.
 */
const INTEGER_EPSILON = 0.005

/**
 * Ab dieser Grössenordnung sind Beträge in diesem Dokument Absolutwerte
 * (Einmalzahlungen, Werkzeugkosten) und nie Stückpreise oder Anteile. Nur dort
 * ist das Runden auf ganze Einheiten verlustfrei.
 */
const INTEGER_MAGNITUDE = 1000

/**
 * Nachkommastellen für alles Übrige. 6 deckt sowohl Stückpreise (4 laut
 * Konventionen) als auch Prozent-Anteile (6) ab, ohne eine der beiden
 * Bedeutungen zu beschneiden — die Sektionen tragen ihre Einheit heute noch
 * nicht mit, deshalb wird hier bewusst die grosszügigere Regel angewandt
 * statt geraten.
 */
const DECIMALS = 6

/**
 * Eine Zahl in ihre kanonische Form bringen.
 *
 * Nicht-endliche Werte werden zu `null`: `NaN` und `Infinity` sind in JSON
 * ohnehin nicht darstellbar, und ein stillschweigend zu 0 gemachter Fehlwert
 * wäre schlimmer als ein sichtbares „nicht vorhanden".
 */
export function canonicalNumber(n: number): number | null {
  if (!Number.isFinite(n)) return null
  const rounded = Math.round(n)
  if (Math.abs(n) >= INTEGER_MAGNITUDE && Math.abs(n - rounded) < INTEGER_EPSILON) return rounded
  return Number(n.toFixed(DECIMALS))
}

/**
 * Rekursiv alle Zahlen eines Ergebnisdokuments kanonisieren.
 *
 * Die Reihenfolge der Schlüssel bleibt erhalten (Spec Kap. 10: feste
 * Schema-Reihenfolge, nicht alphabetisch — das Dokument soll für Menschen
 * lesbar bleiben). Für den Hash genügt das, weil dieselbe Erzeugung dieselbe
 * Reihenfolge liefert.
 */
export function canonicalizeNumbers<T>(value: T): T {
  if (typeof value === 'number') return canonicalNumber(value) as unknown as T
  if (Array.isArray(value)) return value.map((v) => canonicalizeNumbers(v)) as unknown as T
  if (value !== null && typeof value === 'object') {
    const out: Record<string, unknown> = {}
    for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
      out[k] = canonicalizeNumbers(v)
    }
    return out as unknown as T
  }
  return value
}

/**
 * Felder, die sich zwischen zwei Läufen über dieselben Eingaben zwangsläufig
 * unterscheiden und deshalb nicht in den Hash eingehen dürfen (Spec Kap. 10).
 * Stünden sie darin, wäre der Hash immer verschieden und als Nachweis wertlos.
 */
export const HASH_EXCLUDED_PATHS = ['meta.comparisonId', 'meta.generatedAt', 'validation.determinism'] as const

function stripPath(obj: Record<string, unknown>, path: string): void {
  const [head, ...rest] = path.split('.')
  if (rest.length === 0) {
    delete obj[head]
    return
  }
  const child = obj[head]
  if (child !== null && typeof child === 'object' && !Array.isArray(child)) {
    stripPath(child as Record<string, unknown>, rest.join('.'))
  }
}

/** Das Dokument in der Form, über die gehasht wird — ohne volatile Felder. */
export function hashableDocument<T extends object>(doc: T): unknown {
  const copy = JSON.parse(JSON.stringify(canonicalizeNumbers(doc))) as Record<string, unknown>
  for (const path of HASH_EXCLUDED_PATHS) stripPath(copy, path)
  return copy
}

function toHex(buffer: ArrayBuffer): string {
  return Array.from(new Uint8Array(buffer))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('')
}

/**
 * SHA-256 über das kanonische Dokument (R-19). Abnahmekriterium der
 * Spezifikation: zwei unabhängige Läufe über dieselben Dateien liefern
 * denselben Hash.
 */
export async function contentHash(doc: object): Promise<string> {
  const json = JSON.stringify(hashableDocument(doc))
  const bytes = new TextEncoder().encode(json)
  const digest = await crypto.subtle.digest('SHA-256', bytes)
  return `sha256:${toHex(digest)}`
}
