/**
 * Laden, Prüfen und Bereitstellen des amtlichen Fragenkatalogs.
 *
 * Der Katalog wird beim ersten Zugriff einmalig von der Platte gelesen,
 * streng validiert und danach im Speicher gehalten. 575 Fragen sind rund
 * 1,5 MB JSON – ein zweites Parsen wäre reine Verschwendung.
 *
 * Ablageort der Daten:
 *  - Entwicklung: `<repo>/content/katalog/`
 *  - Gepackt:     `<app>/resources/katalog/`
 *
 * Der gepackte Pfad entsteht durch den `extraResources`-Eintrag in
 * `electron-builder.yml`. Beide Fälle werden über `app.isPackaged`
 * unterschieden.
 *
 * Sicherheitsgrundsatz für Bilder: der Renderer nennt ausschließlich
 * Bild-IDs. Ein Pfad aus dem Renderer wird niemals verwendet – die Datei
 * ergibt sich immer aus dem geprüften Katalogeintrag.
 */

import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';

import { app } from 'electron';

import { entschaerft } from './eingaben';
import type {
  Antwortoption,
  Frage,
  Fragetyp,
  Kapitel,
  Katalog,
  KatalogBild,
  KatalogMeta,
  RichText,
  TextSegment,
} from '../shared/katalog';

/** Name des Verzeichnisses mit den Katalogdaten – gepackt wie ungepackt. */
const KATALOG_ORDNER = 'katalog';

/** Unterverzeichnis der Prüfzeichen innerhalb des Katalogverzeichnisses. */
const ASSET_ORDNER = 'assets';

const KATALOG_DATEI = 'katalog.json';

const FRAGETYPEN: readonly Fragetyp[] = ['mc', 'freitext', 'lueckentext'];

/** Amtliche Antwortlabels. Der Katalog nutzt derzeit „a“ bis „g“. */
const LABEL_MUSTER = /^[a-h]$/u;

/**
 * Erlaubte Dateinamen für Prüfzeichen: reiner Basisname, keine Trenner,
 * kein `..`. Damit kann aus einem Katalogeintrag kein Pfad ausbrechen,
 * selbst wenn die JSON-Datei manipuliert wurde.
 */
const DATEINAME_MUSTER = /^[A-Za-z0-9._-]+\.png$/u;

/** ISO-Datum `JJJJ-MM-TT`. */
const ISO_DATUM_MUSTER = /^\d{4}-\d{2}-\d{2}$/u;

// ─── Validierung ────────────────────────────────────────────────────────────

/** Bricht die Validierung mit einer sprechenden deutschen Meldung ab. */
function ungueltig(nachricht: string): never {
  throw new Error(`Fragenkatalog ungültig: ${nachricht}`);
}

function objekt(wert: unknown, pfad: string): Record<string, unknown> {
  if (typeof wert !== 'object' || wert === null || Array.isArray(wert)) {
    ungueltig(`${pfad} ist kein Objekt.`);
  }
  return wert as Record<string, unknown>;
}

function liste(wert: unknown, pfad: string): readonly unknown[] {
  if (!Array.isArray(wert)) {
    ungueltig(`${pfad} ist keine Liste.`);
  }
  return wert as readonly unknown[];
}

/** Zeichenkette mit Inhalt – leere Angaben gelten als fehlendes Pflichtfeld. */
function pflichtText(wert: unknown, pfad: string): string {
  if (typeof wert !== 'string' || wert.trim().length === 0) {
    ungueltig(`${pfad} fehlt oder ist keine nicht-leere Zeichenkette.`);
  }
  return wert;
}

function textOderNull(wert: unknown, pfad: string): string | null {
  if (wert === null || wert === undefined) {
    return null;
  }
  if (typeof wert !== 'string') {
    ungueltig(`${pfad} ist weder Zeichenkette noch null.`);
  }
  return wert;
}

function ganzzahl(wert: unknown, pfad: string, mindestens: number): number {
  if (typeof wert !== 'number' || !Number.isInteger(wert) || wert < mindestens) {
    ungueltig(`${pfad} ist keine ganze Zahl ab ${String(mindestens)}.`);
  }
  return wert;
}

function textliste(wert: unknown, pfad: string): string[] {
  return liste(wert, pfad).map((eintrag, i) => pflichtText(eintrag, `${pfad}[${String(i)}]`));
}

function segment(wert: unknown, pfad: string): TextSegment {
  const roh = objekt(wert, pfad);
  const t = roh['t'];
  if (typeof t !== 'string') {
    ungueltig(`${pfad}.t ist keine Zeichenkette.`);
  }
  return roh['h'] === true ? { t, h: true } : { t };
}

function richText(wert: unknown, pfad: string): RichText {
  const roh = objekt(wert, pfad);
  const text = roh['text'];
  if (typeof text !== 'string') {
    ungueltig(`${pfad}.text ist keine Zeichenkette.`);
  }
  const segmente = liste(roh['segmente'], `${pfad}.segmente`).map((eintrag, i) =>
    segment(eintrag, `${pfad}.segmente[${String(i)}]`),
  );
  return { text, segmente };
}

function meta(wert: unknown): KatalogMeta {
  const roh = objekt(wert, 'meta');
  const stand = pflichtText(roh['stand'], 'meta.stand');
  if (!ISO_DATUM_MUSTER.test(stand)) {
    ungueltig(`meta.stand ist kein ISO-Datum (JJJJ-MM-TT): „${stand}“.`);
  }
  return {
    titel: pflichtText(roh['titel'], 'meta.titel'),
    herausgeber: pflichtText(roh['herausgeber'], 'meta.herausgeber'),
    stand,
    quellenangabe: pflichtText(roh['quellenangabe'], 'meta.quellenangabe'),
    quelle_url: pflichtText(roh['quelle_url'], 'meta.quelle_url'),
    quelldatei_sha256: pflichtText(roh['quelldatei_sha256'], 'meta.quelldatei_sha256'),
    fragen_gesamt: ganzzahl(roh['fragen_gesamt'], 'meta.fragen_gesamt', 1),
  };
}

function kapitel(wert: unknown, pfad: string): Kapitel {
  const roh = objekt(wert, pfad);
  const abschnitte = liste(roh['abschnitte'], `${pfad}.abschnitte`).map((eintrag, i) => {
    const ab = objekt(eintrag, `${pfad}.abschnitte[${String(i)}]`);
    return {
      id: pflichtText(ab['id'], `${pfad}.abschnitte[${String(i)}].id`),
      titel: pflichtText(ab['titel'], `${pfad}.abschnitte[${String(i)}].titel`),
    };
  });
  return {
    id: pflichtText(roh['id'], `${pfad}.id`),
    titel: pflichtText(roh['titel'], `${pfad}.titel`),
    abschnitte,
  };
}

function bild(wert: unknown, pfad: string): KatalogBild {
  const roh = objekt(wert, pfad);
  const datei = pflichtText(roh['datei'], `${pfad}.datei`);
  if (!DATEINAME_MUSTER.test(datei)) {
    ungueltig(`${pfad}.datei ist kein einfacher PNG-Dateiname: „${datei}“.`);
  }
  return {
    id: pflichtText(roh['id'], `${pfad}.id`),
    datei,
    breite: ganzzahl(roh['breite'], `${pfad}.breite`, 1),
    hoehe: ganzzahl(roh['hoehe'], `${pfad}.hoehe`, 1),
    alt: textOderNull(roh['alt'], `${pfad}.alt`),
    beschreibung: textOderNull(roh['beschreibung'], `${pfad}.beschreibung`),
  };
}

function option(wert: unknown, pfad: string): Antwortoption {
  const roh = objekt(wert, pfad);
  const label = pflichtText(roh['label'], `${pfad}.label`);
  if (!LABEL_MUSTER.test(label)) {
    ungueltig(`${pfad}.label ist kein amtliches Label („a“ bis „h“): „${label}“.`);
  }
  if (typeof roh['korrekt'] !== 'boolean') {
    ungueltig(`${pfad}.korrekt ist kein Wahrheitswert.`);
  }
  return {
    label,
    inhalt: richText(roh['inhalt'], `${pfad}.inhalt`),
    korrekt: roh['korrekt'],
    bilder: textliste(roh['bilder'], `${pfad}.bilder`),
  };
}

function frage(wert: unknown, pfad: string): Frage {
  const roh = objekt(wert, pfad);
  const typ = pflichtText(roh['typ'], `${pfad}.typ`);
  if (!(FRAGETYPEN as readonly string[]).includes(typ)) {
    ungueltig(`${pfad}.typ ist unbekannt: „${typ}“.`);
  }

  const optionenRoh = roh['optionen'];
  const optionen =
    optionenRoh === undefined || optionenRoh === null
      ? undefined
      : liste(optionenRoh, `${pfad}.optionen`).map((eintrag, i) =>
          option(eintrag, `${pfad}.optionen[${String(i)}]`),
        );

  if (typ === 'mc') {
    if (optionen === undefined || optionen.length === 0) {
      ungueltig(`${pfad} ist Multiple Choice, hat aber keine Antwortoptionen.`);
    }
    if (!optionen.some((o) => o.korrekt)) {
      ungueltig(`${pfad} ist Multiple Choice, hat aber keine richtige Antwortoption.`);
    }
    const labels = new Set(optionen.map((o) => o.label));
    if (labels.size !== optionen.length) {
      ungueltig(`${pfad} enthält doppelte Antwortlabels.`);
    }
  }

  const musterantwortRoh = roh['musterantwort'];
  const musterantwort =
    musterantwortRoh === undefined || musterantwortRoh === null
      ? undefined
      : richText(musterantwortRoh, `${pfad}.musterantwort`);

  if (typ !== 'mc' && musterantwort === undefined) {
    ungueltig(`${pfad} ist eine offene Frage, hat aber keine Musterantwort.`);
  }

  const warnungenRoh = roh['warnungen'];
  const warnungen =
    warnungenRoh === undefined || warnungenRoh === null
      ? undefined
      : textliste(warnungenRoh, `${pfad}.warnungen`);

  const grund = {
    id: pflichtText(roh['id'], `${pfad}.id`),
    amtliche_nummer: pflichtText(roh['amtliche_nummer'], `${pfad}.amtliche_nummer`),
    kapitel: pflichtText(roh['kapitel'], `${pfad}.kapitel`),
    abschnitt: textOderNull(roh['abschnitt'], `${pfad}.abschnitt`),
    typ: typ as Fragetyp,
    seite: ganzzahl(roh['seite'], `${pfad}.seite`, 1),
    frage: richText(roh['frage'], `${pfad}.frage`),
    bilder: textliste(roh['bilder'], `${pfad}.bilder`),
  };

  // `exactOptionalPropertyTypes` verbietet `optionen: undefined` – die
  // optionalen Felder werden deshalb nur bei Vorhandensein gesetzt.
  return {
    ...grund,
    ...(optionen === undefined ? {} : { optionen }),
    ...(musterantwort === undefined ? {} : { musterantwort }),
    ...(warnungen === undefined ? {} : { warnungen }),
  };
}

/**
 * Prüft eine beliebige Eingabe und liefert einen typsicheren Katalog.
 *
 * Wirft bei jedem Verstoß einen `Error` mit deutscher Meldung, die den
 * betroffenen Pfad nennt. Geprüft werden Pflichtfelder, Wertebereiche,
 * Querverweise (Kapitel, Abschnitte, Bilder) und die Kopfzahl
 * `meta.fragen_gesamt`.
 */
export function katalogValidieren(roh: unknown): Katalog {
  const wurzel = objekt(roh, 'Katalog');

  const kopf = meta(wurzel['meta']);
  const kapitelListe = liste(wurzel['kapitel'], 'kapitel').map((eintrag, i) =>
    kapitel(eintrag, `kapitel[${String(i)}]`),
  );
  if (kapitelListe.length === 0) {
    ungueltig('kapitel ist leer.');
  }

  const bildListe = liste(wurzel['bilder'], 'bilder').map((eintrag, i) =>
    bild(eintrag, `bilder[${String(i)}]`),
  );
  const fragenListe = liste(wurzel['fragen'], 'fragen').map((eintrag, i) =>
    frage(eintrag, `fragen[${String(i)}]`),
  );

  // ── Kopfzahl ──────────────────────────────────────────────────────────
  if (fragenListe.length !== kopf.fragen_gesamt) {
    ungueltig(
      `meta.fragen_gesamt meldet ${String(kopf.fragen_gesamt)} Fragen, ` +
        `enthalten sind aber ${String(fragenListe.length)}.`,
    );
  }

  // ── Querverweise ──────────────────────────────────────────────────────
  const kapitelIds = new Set(kapitelListe.map((k) => k.id));
  const abschnittIds = new Set(kapitelListe.flatMap((k) => k.abschnitte.map((a) => a.id)));
  const bildIds = new Set(bildListe.map((b) => b.id));

  if (bildIds.size !== bildListe.length) {
    ungueltig('bilder enthält doppelte IDs.');
  }

  const gesehen = new Set<string>();
  for (const f of fragenListe) {
    if (gesehen.has(f.id)) {
      ungueltig(`fragen enthält die ID „${f.id}“ mehrfach.`);
    }
    gesehen.add(f.id);

    if (!kapitelIds.has(f.kapitel)) {
      ungueltig(`Frage „${f.id}“ verweist auf das unbekannte Kapitel „${f.kapitel}“.`);
    }
    if (f.abschnitt !== null && !abschnittIds.has(f.abschnitt)) {
      ungueltig(`Frage „${f.id}“ verweist auf den unbekannten Abschnitt „${f.abschnitt}“.`);
    }

    const referenzen = [...f.bilder, ...(f.optionen ?? []).flatMap((o) => o.bilder)];
    for (const referenz of referenzen) {
      if (!bildIds.has(referenz)) {
        ungueltig(`Frage „${f.id}“ verweist auf das unbekannte Bild „${referenz}“.`);
      }
    }
  }

  return { meta: kopf, kapitel: kapitelListe, bilder: bildListe, fragen: fragenListe };
}

// ─── Bildzugriff ────────────────────────────────────────────────────────────

/**
 * Löst eine vom Renderer gelieferte Bild-ID gegen die im Katalog bekannten
 * IDs auf.
 *
 * Das ist die einzige zugelassene Brücke vom Renderer zu einer Datei: es
 * wird ausschließlich exakt verglichen, niemals zusammengesetzt. Eine ID wie
 * `../../etc/passwd` findet schlicht keinen Treffer.
 */
export function bildAufloesen(katalog: Katalog, bildId: unknown): KatalogBild {
  if (typeof bildId !== 'string' || bildId.length === 0) {
    throw new Error('Ungültige Bild-ID: es wurde eine nicht-leere Zeichenkette erwartet.');
  }

  const treffer = katalog.bilder.find((b) => b.id === bildId);
  if (!treffer) {
    throw new Error(`Unbekannte Bild-ID: „${entschaerft(bildId)}“.`);
  }

  // Doppelter Boden: der Dateiname wurde beim Laden geprüft, wird vor dem
  // Zusammensetzen des Pfades aber erneut geprüft.
  if (!DATEINAME_MUSTER.test(treffer.datei)) {
    throw new Error(`Unzulässiger Dateiname im Katalog: „${entschaerft(treffer.datei)}“.`);
  }

  return treffer;
}

// ─── Dateizugriff ───────────────────────────────────────────────────────────

/** Höchstzahl der Verzeichnisebenen, die aufwärts durchsucht werden. */
const SUCHTIEFE = 6;

/**
 * Sucht vom Startverzeichnis aus aufwärts nach einem relativen Pfad.
 *
 * Nötig, weil im ungepackten Betrieb nicht feststeht, aus welchem Verzeichnis
 * die Anwendung gestartet wurde: `app.getAppPath()` zeigt beim Start aus dem
 * Quellbaum auf `app/`, beim Start des gebauten Einstiegspunkts dagegen auf
 * `app/out/main`. Eine feste Anzahl von „..“ wäre in einem der beiden Fälle
 * immer falsch.
 *
 * Exportiert, weil `lizenzen.ts` dieselbe Auflösung für Dateien im
 * Projektwurzelverzeichnis braucht.
 */
export function sucheAufwaerts(start: string, relativ: string): string | null {
  let verzeichnis = start;
  for (let ebene = 0; ebene < SUCHTIEFE; ebene += 1) {
    const kandidat = join(verzeichnis, relativ);
    if (existsSync(kandidat)) {
      return kandidat;
    }
    const eltern = dirname(verzeichnis);
    if (eltern === verzeichnis) {
      break; // Wurzel des Dateisystems erreicht
    }
    verzeichnis = eltern;
  }
  return null;
}

/** Verzeichnis mit `katalog.json` und `assets/`. */
export function katalogVerzeichnis(): string {
  if (app.isPackaged) {
    // Kommt aus `extraResources` in electron-builder.yml.
    return join(process.resourcesPath, KATALOG_ORDNER);
  }

  const relativ = join('content', KATALOG_ORDNER);
  for (const start of [app.getAppPath(), __dirname, process.cwd()]) {
    const treffer = sucheAufwaerts(start, join(relativ, KATALOG_DATEI));
    if (treffer) {
      return dirname(treffer);
    }
  }
  // Nichts gefunden: den erwarteten Ort zurückgeben, damit die Fehlermeldung
  // den Pfad nennt, an dem die Datei liegen müsste.
  return join(app.getAppPath(), '..', relativ);
}

function fehlerText(fehler: unknown): string {
  return fehler instanceof Error ? fehler.message : String(fehler);
}

let katalogSpeicher: Katalog | null = null;
const bildSpeicher = new Map<string, string>();

function vonPlatteLaden(): Katalog {
  const verzeichnis = katalogVerzeichnis();
  const pfad = join(verzeichnis, KATALOG_DATEI);

  if (!existsSync(pfad)) {
    throw new Error(
      `Fragenkatalog nicht gefunden: ${pfad}. ` +
        'In der Entwicklung muss content/katalog/katalog.json vorhanden sein, ' +
        'im gepackten Build sorgt der extraResources-Eintrag in electron-builder.yml dafür.',
    );
  }

  let roh: unknown;
  try {
    roh = JSON.parse(readFileSync(pfad, 'utf8'));
  } catch (fehler) {
    // `cause`: die lesbare Meldung für den Nutzer, der ursprüngliche Fehler
    // (Systemfehlercode, Aufrufliste, Position im JSON) für die Fehlersuche.
    throw new Error(`Fragenkatalog nicht lesbar (${pfad}): ${fehlerText(fehler)}`, {
      cause: fehler,
    });
  }

  return katalogValidieren(roh);
}

/** Lädt den Katalog beim ersten Aufruf und liefert danach die Kopie im Speicher. */
export function katalogLaden(): Katalog {
  katalogSpeicher ??= vonPlatteLaden();
  return katalogSpeicher;
}

/**
 * Liefert ein Prüfzeichen als Data-URL.
 *
 * Data-URL statt Dateipfad, weil die Content-Security-Policy dem Renderer
 * jeden Datei- und Netzzugriff verwehrt. Die Ergebnisse werden
 * zwischengespeichert – es sind 17 kleine PNG (gezählt in
 * `content/katalog/assets/`; die Pipeline schreibt nur die Zeichen heraus,
 * auf die auch eine Frage verweist).
 */
export function katalogBild(bildId: unknown): string {
  const eintrag = bildAufloesen(katalogLaden(), bildId);

  const vorhanden = bildSpeicher.get(eintrag.id);
  if (vorhanden !== undefined) {
    return vorhanden;
  }

  const pfad = join(katalogVerzeichnis(), ASSET_ORDNER, eintrag.datei);
  let daten: Buffer;
  try {
    daten = readFileSync(pfad);
  } catch (fehler) {
    // Siehe oben: Meldung für den Nutzer, Ursache für die Fehlersuche.
    throw new Error(`Prüfzeichen „${eintrag.id}“ nicht lesbar: ${fehlerText(fehler)}`, {
      cause: fehler,
    });
  }

  const datenUrl = `data:image/png;base64,${daten.toString('base64')}`;
  bildSpeicher.set(eintrag.id, datenUrl);
  return datenUrl;
}

/** Leert die Zwischenspeicher – für Tests und einen sauberen Neustart. */
export function katalogZuruecksetzen(): void {
  katalogSpeicher = null;
  bildSpeicher.clear();
}
