/**
 * Laden und Prüfen der Erklärungstexte.
 *
 * Die Erklärungen sind eigener redaktioneller Inhalt und liegen deshalb in
 * einer eigenen Datei neben dem amtlichen Katalog:
 *
 *  - Entwicklung: `<repo>/content/erklaerungen.json`
 *  - Gepackt:     `<app>/resources/erklaerungen.json`
 *
 * ## Fehlen ist zulässig
 *
 * Anders als der Fragenkatalog sind die Erklärungen **nicht** Voraussetzung
 * für den Betrieb. Fehlt die Datei oder ist sie unbrauchbar, läuft die
 * Anwendung ohne sie weiter – der Lernbetrieb hängt nicht daran. Ein Fehler
 * wird vermerkt, damit er in der Oberfläche erklärt werden kann, aber er
 * bringt nichts zum Stillstand.
 *
 * ## Was hier NICHT geprüft wird
 *
 * Ob eine Fundstelle im Gesetz tatsächlich existiert. Das ist Aufgabe von
 * `data-pipeline/pruefe_erklaerungen.py` vor der Auslieferung: Dort steht der
 * amtliche Gesetzestext zur Verfügung, hier nicht. Zur Laufzeit wird nur
 * geprüft, dass die Daten die vereinbarte Form haben.
 */

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

import { app } from 'electron';

import {
  GESETZE,
  type Erklaerung,
  type Erklaerungen,
  type Fundstelle,
  type Gesetzeskuerzel,
} from '../shared/erklaerungen';
import { katalogLaden, sucheAufwaerts } from './katalog';

const DATEI = 'erklaerungen.json';

/**
 * Normbezeichnung: „§ 12", „§ 12a" oder eine Anlage.
 *
 * Anlagen werden nicht einheitlich bezeichnet: WaffG und 1. SprengV zählen
 * arabisch („Anlage 1"), BeschussV und SprengG römisch („Anlage II"), die
 * AWaffV hat genau eine und nennt sie nur „Anlage".
 */
const NORM_MUSTER = /^(§ \d+[a-z]?|Anlage( [0-9IVX]+)?)$/u;

/** Gliederungsangaben: Ziffern, optional mit einem Buchstaben. */
const GLIEDERUNG_MUSTER = /^[0-9]{1,3}[a-z]?$/u;

/** Buchstabenangabe: ein oder zwei Kleinbuchstaben. */
const BUCHSTABE_MUSTER = /^[a-z]{1,2}$/u;

function ungueltig(nachricht: string): never {
  throw new Error(`Erklärungen 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 pflichttext(wert: unknown, pfad: string): string {
  if (typeof wert !== 'string' || wert.trim().length === 0) {
    ungueltig(`${pfad} fehlt oder ist leer.`);
  }
  return wert;
}

function kuerteText(wert: unknown, pfad: string): string | undefined {
  if (wert === undefined) {
    return undefined;
  }
  return pflichttext(wert, pfad);
}

/** Optionale Gliederungsangabe, gegen ein Muster geprüft. */
function gliederung(wert: unknown, pfad: string, muster: RegExp): string | undefined {
  if (wert === undefined) {
    return undefined;
  }
  if (typeof wert !== 'string' || !muster.test(wert)) {
    ungueltig(`${pfad} hat eine unerwartete Form.`);
  }
  return wert;
}

function fundstelleLesen(roh: unknown, pfad: string): Fundstelle {
  const o = objekt(roh, pfad);

  const gesetz = o['gesetz'];
  if (typeof gesetz !== 'string' || !(GESETZE as readonly string[]).includes(gesetz)) {
    ungueltig(`${pfad}.gesetz ist kein bekanntes Gesetz.`);
  }

  const norm = pflichttext(o['norm'], `${pfad}.norm`);
  if (!NORM_MUSTER.test(norm)) {
    ungueltig(`${pfad}.norm muss „§ 12“ oder „Anlage 1“ lauten.`);
  }

  return {
    gesetz: gesetz as Gesetzeskuerzel,
    norm,
    ...optional('absatz', gliederung(o['absatz'], `${pfad}.absatz`, GLIEDERUNG_MUSTER)),
    ...optional('nummer', gliederung(o['nummer'], `${pfad}.nummer`, GLIEDERUNG_MUSTER)),
    ...optional('buchstabe', gliederung(o['buchstabe'], `${pfad}.buchstabe`, BUCHSTABE_MUSTER)),
    ...optional('satz', gliederung(o['satz'], `${pfad}.satz`, GLIEDERUNG_MUSTER)),
    ...optional('stelle', kuerteText(o['stelle'], `${pfad}.stelle`)),
  };
}

/**
 * Baut ein Feld nur ein, wenn es einen Wert hat.
 *
 * `exactOptionalPropertyTypes` unterscheidet „Feld fehlt“ von
 * „Feld ist undefined“; ein pauschales Zuweisen wäre ein Typfehler.
 */
function optional<K extends string>(
  schluessel: K,
  wert: string | undefined,
): Record<K, string> | Record<string, never> {
  return wert === undefined ? {} : ({ [schluessel]: wert } as Record<K, string>);
}

function erklaerungLesen(roh: unknown, pfad: string): Erklaerung {
  const o = objekt(roh, pfad);

  const fundstellenRoh = o['fundstellen'];
  if (!Array.isArray(fundstellenRoh)) {
    ungueltig(`${pfad}.fundstellen fehlt oder ist keine Liste.`);
  }

  const fundstellen = (fundstellenRoh as readonly unknown[]).map((eintrag, i) =>
    fundstelleLesen(eintrag, `${pfad}.fundstellen[${String(i)}]`),
  );

  const grund = kuerteText(o['ohneFundstelleGrund'], `${pfad}.ohneFundstelleGrund`);
  if (fundstellen.length === 0 && grund === undefined) {
    ungueltig(`${pfad}: ohne Fundstelle muss ein Grund angegeben sein.`);
  }

  const merksatz = kuerteText(o['merksatz'], `${pfad}.merksatz`);

  /* Dass die genannten Fragen existieren und die Verweise beidseitig sind,
     prüft `pruefe_erklaerungen.py` vor der Auslieferung – dort steht der
     Katalog zur Verfügung, hier nicht. Der Lader prüft die Form. */
  const verwandtRoh = o['verwandt'];
  let verwandt: string[] | undefined;
  if (verwandtRoh !== undefined) {
    if (!Array.isArray(verwandtRoh)) {
      ungueltig(`${pfad}.verwandt ist keine Liste.`);
    }
    verwandt = (verwandtRoh as readonly unknown[]).map((eintrag, i) =>
      pflichttext(eintrag, `${pfad}.verwandt[${String(i)}]`),
    );
    if (verwandt.length === 0) {
      /* Eine leere Liste wäre eine Angabe, die nichts angibt – und die
         Oberfläche zeigte eine Überschrift ohne Inhalt darunter. */
      ungueltig(`${pfad}.verwandt ist leer; dann gehört das Feld weg.`);
    }
  }

  /* Die Kernpunkte sind eine Einschaetzung dieser Software, keine Aussage
     ueber den amtlichen Katalog. Der Lader prueft nur die Form; ob ein Punkt
     traegt, entscheidet die redaktionelle Durchsicht. */
  const kernpunkteRoh = o['kernpunkte'];
  let kernpunkte: string[] | undefined;
  if (kernpunkteRoh !== undefined) {
    if (!Array.isArray(kernpunkteRoh)) {
      ungueltig(`${pfad}.kernpunkte ist keine Liste.`);
    }
    kernpunkte = (kernpunkteRoh as readonly unknown[]).map((eintrag, i) =>
      pflichttext(eintrag, `${pfad}.kernpunkte[${String(i)}]`),
    );
    if (kernpunkte.length < 2) {
      /* Eine Pruefliste mit einem Punkt waere kein Werkzeug, sondern Beiwerk –
         und eine leere waere eine Ueberschrift ohne Inhalt. */
      ungueltig(`${pfad}.kernpunkte braucht mindestens zwei Punkte.`);
    }
  }

  return {
    kurz: pflichttext(o['kurz'], `${pfad}.kurz`),
    text: pflichttext(o['text'], `${pfad}.text`),
    fundstellen,
    ...(grund === undefined ? {} : { ohneFundstelleGrund: grund }),
    ...(merksatz === undefined ? {} : { merksatz }),
    ...(verwandt === undefined ? {} : { verwandt }),
    ...(kernpunkte === undefined ? {} : { kernpunkte }),
  };
}

function erklaerungenValidieren(roh: unknown): Erklaerungen {
  const o = objekt(roh, 'Die Datei');
  const meta = objekt(o['meta'], 'meta');
  const zuFrageRoh = objekt(o['zuFrage'], 'zuFrage');

  const version = meta['version'];
  if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
    ungueltig('meta.version fehlt oder ist keine ganze Zahl ab 1.');
  }

  const gesetzesstandRoh = objekt(meta['gesetzesstand'], 'meta.gesetzesstand');
  const gesetzesstand: Record<string, string> = {};
  for (const [kuerzel, stand] of Object.entries(gesetzesstandRoh)) {
    gesetzesstand[kuerzel] = pflichttext(stand, `meta.gesetzesstand.${kuerzel}`);
  }

  /* Erklärungen zu Fragen, die es nicht gibt, werden verworfen statt
     abgewiesen: Ein solcher Rest ist harmlos, und ein Programm, das deswegen
     gar keine Erklärungen zeigt, wäre die schlechtere Antwort darauf. */
  const bekannt = new Set(katalogLaden().fragen.map((frage) => frage.id));
  const zuFrage: Record<string, Erklaerung> = {};
  let verworfen = 0;

  for (const [frageId, eintrag] of Object.entries(zuFrageRoh)) {
    if (!bekannt.has(frageId)) {
      verworfen += 1;
      continue;
    }
    zuFrage[frageId] = erklaerungLesen(eintrag, `zuFrage.${frageId}`);
  }

  if (verworfen > 0) {
    console.warn(
      `Erklärungen: ${String(verworfen)} Eintrag/Einträge ohne passende Frage übergangen.`,
    );
  }

  return {
    meta: {
      version,
      stand: pflichttext(meta['stand'], 'meta.stand'),
      gesetzesstand,
      hinweis: pflichttext(meta['hinweis'], 'meta.hinweis'),
    },
    zuFrage,
  };
}

/** Pfad der Erklärungsdatei – gepackt wie ungepackt. */
export function erklaerungenPfad(): string {
  if (app.isPackaged) {
    return join(process.resourcesPath, DATEI);
  }

  const relativ = join('content', DATEI);
  for (const start of [app.getAppPath(), __dirname, process.cwd()]) {
    const treffer = sucheAufwaerts(start, relativ);
    if (treffer) {
      return treffer;
    }
  }
  return join(app.getAppPath(), '..', relativ);
}

/** Leerer Bestand – die Anwendung läuft auch ohne Erklärungen. */
const LEER: Erklaerungen = Object.freeze({
  meta: Object.freeze({
    version: 0,
    stand: '',
    gesetzesstand: Object.freeze({}),
    hinweis: '',
  }),
  zuFrage: Object.freeze({}),
});

let speicher: Erklaerungen | null = null;

export function erklaerungenLaden(): Erklaerungen {
  if (speicher !== null) {
    return speicher;
  }

  const pfad = erklaerungenPfad();
  if (!existsSync(pfad)) {
    speicher = LEER;
    return speicher;
  }

  try {
    speicher = erklaerungenValidieren(JSON.parse(readFileSync(pfad, 'utf8')));
  } catch (fehler: unknown) {
    /* Bewusst kein erneuter Versuch bei jedem Aufruf: Eine kaputte Datei
       wird zwischen zwei Aufrufen nicht heil, und die Meldung soll einmal
       erscheinen, nicht bei jeder Frage. */
    console.error('Erklärungen konnten nicht gelesen werden:', fehler);
    speicher = LEER;
  }

  return speicher;
}
