/**
 * Sicherung des Lernstands: schreiben, prüfen, beziffern.
 *
 * Alles hier ist reine Datei- und Datenbankarbeit ohne Electron – damit es
 * gegen echte SQLite-Dateien prüfbar bleibt. Die Dialoge und der Ablauf des
 * Einspielens stehen in `sicherung-dialoge.ts`.
 *
 * ## Warum `VACUUM INTO` und nicht `db.backup()`
 *
 * Beides erzeugt eine stimmige Kopie einer laufenden Datenbank. Nachgemessen
 * an better-sqlite3 13.0.3 und SQLite 3.53.4 dieses Projekts unterscheiden
 * sie sich an drei Stellen, und alle drei sprechen für `VACUUM INTO`:
 *
 * - `db.backup()` liefert eine Datei im **WAL-Modus**. Wer sie später auch
 *   nur ansieht, erzeugt daneben `-wal` und `-shm` – und die bleiben nach dem
 *   Schließen liegen. Eine Sicherung, von der man zwei Dateien vergisst, ist
 *   genau der Fehler, den sie verhüten soll. `VACUUM INTO` schreibt eine
 *   einzelne Datei im `delete`-Modus; sie bleibt eine einzelne Datei.
 * - `VACUUM INTO` bricht ab, wenn das Ziel schon existiert. Ein
 *   versehentliches Überschreiben ist damit technisch ausgeschlossen und
 *   nicht bloß durch Sorgfalt.
 * - `VACUUM INTO` ist **synchron**. Der Hauptprozess ist einfädig; solange
 *   nichts abgewartet wird, kann sich kein anderer Kanal dazwischenschieben
 *   und die Datei in genau dem Augenblick öffnen, in dem sie ersetzt wird.
 *   `db.backup()` arbeitet über eine `setImmediate`-Schleife und risse dieses
 *   Fenster wieder auf.
 *
 * ## Warum eine blosse Dateikopie nicht genügt
 *
 * Der Lernstand läuft im WAL-Modus. Nachgemessen: Bei offener Verbindung und
 * 5 000 geschriebenen Zeilen war `lernstand.db` **4 096 Byte** gross und das
 * Schreibprotokoll `lernstand.db-wal` **1,1 MB**. Eine Kopie nur der
 * Hauptdatei liess sich anstandslos öffnen, bestand `integrity_check` mit
 * „ok“ – und enthielt **keine einzige Tabelle**. Nicht ein Teil fehlte,
 * sondern alles. Wer so sichert, merkt es an dem Tag, an dem er die Sicherung
 * braucht.
 */

import { closeSync, openSync, readSync, renameSync, statSync, unlinkSync } from 'node:fs';

import type BetterSqlite3 from 'better-sqlite3';

import { entschaerft } from './eingaben';
import { SCHEMA_VERSION } from './schema';
import type { Kennzahlen } from '../shared/sicherung';

/**
 * Kennung im Dateikopf, an der eine Sicherung dieser Anwendung zu erkennen
 * ist – „WSKL“ als 32-Bit-Zahl.
 *
 * Ausschliesslich als Auskunft, **nie** als Ablehnungsgrund: Eine von Hand
 * kopierte `lernstand.db` trägt hier 0 und muss trotzdem einspielbar bleiben.
 */
export const ANWENDUNGSKENNUNG = 0x57534b4c;

/** Die ersten Bytes jeder SQLite-Datei. */
const SQLITE_KOPF = 'SQLite format 3\0';

/** Kleiner als das kann keine Datenbank sein: eine SQLite-Seite. */
const MINDESTGROESSE = 512;

/**
 * Grösser als das ist kein Lernstand, sondern ein Versehen.
 *
 * Exportiert, weil `sicherung-dialoge.ts` die Grenze schon an der **Quelle**
 * anlegt: Sonst wanderte ein versehentlich gewählter Film erst vollständig
 * ins Programmverzeichnis und würde danach abgelehnt.
 */
export const HOECHSTGROESSE = 512 * 1024 * 1024;

/**
 * Tabellen, die es seit Schemafassung 1 gibt und die jeder Lernstand hat.
 *
 * `pruefung_lauf` und `pruefung_offen` fehlen hier mit Absicht – sie kamen
 * erst mit Fassung 2 und 5. Eine echte alte Sicherung soll nicht daran
 * scheitern, dass sie alt ist.
 */
const PFLICHTTABELLEN = ['schema_version', 'profil', 'frage_stand', 'antwort_log'] as const;

export type Pruefbefund =
  | {
      readonly art: 'brauchbar';
      readonly kennzahlen: Kennzahlen;
      /**
       * Die Profilnummern der Datei, in derselben Reihenfolge wie
       * `kennzahlen.jeProfil`. Sie verlassen den Hauptprozess nie – der
       * Renderer wählt über den Index.
       */
      readonly profilIds: readonly number[];
    }
  | { readonly art: 'abgelehnt'; readonly grund: string };

/** Konstruktor von better-sqlite3, so weit hier gebraucht. */
export type DatenbankKonstruktor = new (
  pfad: string,
  optionen?: BetterSqlite3.Options,
) => BetterSqlite3.Database;

/**
 * Schreibt eine Sicherung der laufenden Datenbank.
 *
 * Erst nach `.teil`, dann umbenennen: Ein Abbruch mittendrin hinterlässt
 * damit nie eine halbe Datei unter dem richtigen Namen. Das Umbenennen im
 * selben Verzeichnis ist der einzige Schritt, den das Betriebssystem
 * unteilbar ausführt.
 *
 * @returns Grösse der geschriebenen Datei in Byte.
 */
export function sicherungSchreiben(
  db: BetterSqlite3.Database,
  ziel: string,
  Datenbank: DatenbankKonstruktor,
): number {
  const teil = `${ziel}.teil`;
  aufraeumen(teil);

  /* Gebundener Parameter statt eingesetztem Pfad – nachgemessen, dass SQLite
     das bei VACUUM INTO annimmt. Ein Pfad mit Anführungszeichen im Namen
     hätte sonst die Anweisung zerlegt. */
  db.prepare('VACUUM INTO ?').run(teil);

  /* Die Kennung wird auf einer SCHREIBENDEN Verbindung gesetzt; auf einer
     nur lesenden wirft jedes schreibende Pragma. Gegengeprüft wird danach in
     einer zweiten, ausdrücklich lesenden Verbindung – sonst prüfte dieselbe
     Verbindung ihr eigenes Werk. */
  const schreibend = new Datenbank(teil);
  try {
    schreibend.pragma(`application_id = ${String(ANWENDUNGSKENNUNG)}`);
    schreibend.pragma(`user_version = ${String(SCHEMA_VERSION)}`);
  } finally {
    schreibend.close();
  }

  const befund = dateiPruefen(teil, Datenbank);
  if (befund.art === 'abgelehnt') {
    aufraeumen(teil);
    throw new Error(
      `Die Sicherung wurde geschrieben, hielt der Gegenprobe aber nicht stand: ${befund.grund}`,
    );
  }

  const bytes = statSync(teil).size;
  renameSync(teil, ziel);
  return bytes;
}

/**
 * Die Prüfkette, von billig nach teuer.
 *
 * Jede Stufe hat ihren Grund, und die wichtigste ist die fünfte: Eine Datei
 * von null Byte besteht `integrity_check` mit „ok“ und hat null Tabellen.
 * Sie durchliefe anschliessend die vollständige Migrationskette und stünde
 * als tadelloser, **leerer** Lernstand da. Das Einspielen meldete Erfolg, und
 * die Arbeit von Wochen wäre fort.
 *
 * Jeder Ablehnungsgrund endet auf denselben Satz: „Es wurde nichts
 * verändert.“ Wer eine Fehlermeldung liest, will zuerst das wissen.
 */
export function dateiPruefen(pfad: string, Datenbank: DatenbankKonstruktor): Pruefbefund {
  const schluss = ' Es wurde nichts verändert.';

  // ── Stufe 1: überhaupt eine Datei dieser Größenordnung? ──────────────
  let groesse: number;
  try {
    const stand = statSync(pfad);
    if (!stand.isFile()) {
      return { art: 'abgelehnt', grund: `Das ist keine Datei.${schluss}` };
    }
    groesse = stand.size;
  } catch {
    return { art: 'abgelehnt', grund: `Diese Datei lässt sich nicht lesen.${schluss}` };
  }

  if (groesse < MINDESTGROESSE) {
    return {
      art: 'abgelehnt',
      grund: `Diese Datei ist leer oder viel zu klein. Sie enthält keinen Lernstand.${schluss}`,
    };
  }
  if (groesse > HOECHSTGROESSE) {
    return {
      art: 'abgelehnt',
      grund:
        `Diese Datei ist ${megabyte(groesse)} groß und kann kein Lernstand sein – ` +
        `ein Lernstand ist wenige Megabyte groß.${schluss}`,
    };
  }

  // ── Stufe 2: überhaupt SQLite? ───────────────────────────────────────
  /* Nötig, weil eine Textdatei sich readonly ÖFFNEN lässt – nachgemessen;
     erst die erste Abfrage wirft dann SQLITE_NOTADB. Ohne diese Stufe bekäme
     ein umbenanntes Foto eine englische Datenbankmeldung. */
  if (!hatSqliteKopf(pfad)) {
    return {
      art: 'abgelehnt',
      grund:
        'Diese Datei ist keine Datenbank, sondern etwas anderes – vielleicht ein Bild oder ' +
        `ein Dokument. Sicherungen dieser Anwendung enden auf .wsklernstand.${schluss}`,
    };
  }

  // ── Stufe 3 bis 8: auf einer nur lesenden Verbindung ─────────────────
  let db: BetterSqlite3.Database;
  try {
    db = new Datenbank(pfad, { readonly: true, fileMustExist: true });
  } catch {
    return {
      art: 'abgelehnt',
      grund: `Diese Datei lässt sich nicht lesen. Bitte prüfen Sie die Zugriffsrechte.${schluss}`,
    };
  }

  try {
    // Stufe 4: heil? `integrity_check` WIRFT bei Beschädigung, statt einen
    // Wert zu liefern – nachgemessen. Beide Wege müssen behandelt werden.
    let heil = false;
    try {
      const zeilen = db.pragma('integrity_check') as { integrity_check: string }[];
      heil = zeilen.length === 1 && zeilen[0]?.integrity_check === 'ok';
    } catch {
      heil = false;
    }
    if (!heil) {
      return {
        art: 'abgelehnt',
        grund:
          'Diese Datei ist unvollständig oder beschädigt. Möglicherweise ist das Herunterladen ' +
          `oder das Kopieren abgebrochen.${schluss}`,
      };
    }

    // Stufe 5: ein Lernstand DIESER Anwendung?
    const tabellen = new Set(
      db
        .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'")
        .all()
        .map((zeile) => zeile.name),
    );
    const fehlend = PFLICHTTABELLEN.filter((name) => !tabellen.has(name));
    if (fehlend.length > 0) {
      return {
        art: 'abgelehnt',
        grund:
          'Diese Datei ist zwar eine SQLite-Datenbank, aber kein Lernstand dieser Anwendung – ' +
          `es fehlen die Tabellen ${fehlend.join(' und ')}.${schluss}`,
      };
    }

    const profile = db
      .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil')
      .get();
    if ((profile?.anzahl ?? 0) < 1) {
      return {
        art: 'abgelehnt',
        grund:
          'Diese Datei enthält kein einziges Lernprofil und kann deshalb kein Lernstand ' +
          `dieser Anwendung sein.${schluss}`,
      };
    }

    // Stufe 6: Schemafassung.
    const fassung =
      db
        .prepare<[], { version: number | null }>(
          'SELECT MAX(version) AS version FROM schema_version',
        )
        .get()?.version ?? 0;
    if (fassung < 1) {
      return {
        art: 'abgelehnt',
        grund:
          'Diese Datei nennt keine Schemafassung und ist damit kein vollständiger ' +
          `Lernstand.${schluss}`,
      };
    }
    if (fassung > SCHEMA_VERSION) {
      return {
        art: 'abgelehnt',
        grund:
          `Diese Sicherung stammt aus einer neueren Fassung des Programms (Schema ${String(fassung)}, ` +
          `dieses Programm kennt ${String(SCHEMA_VERSION)}). Bitte zuerst das Programm ` +
          `aktualisieren.${schluss}`,
      };
    }

    // Stufe 7: hängt es zusammen?
    /* `integrity_check` prüft die Baumstruktur, nicht die Beziehungen. Die
       laufende Datenbank arbeitet mit `foreign_keys = ON`; eine Datei mit
       verwaisten Zeilen fiele später an beliebiger Stelle auf. */
    const verwaist = db.pragma('foreign_key_check') as unknown[];
    if (verwaist.length > 0) {
      return {
        art: 'abgelehnt',
        grund:
          'Diese Datei ist beschädigt: Sie enthält Einträge, die auf ein Profil verweisen, ' +
          `das es darin nicht gibt.${schluss}`,
      };
    }

    // Stufe 8: Zahlen für die Rückfrage – kein Ablehnungsgrund mehr.
    return {
      art: 'brauchbar',
      kennzahlen: kennzahlenLesen(db, fassung, tabellen),
      profilIds: db
        .prepare<[], { id: number }>('SELECT id FROM profil ORDER BY id')
        .all()
        .map((z) => z.id),
    };
  } finally {
    // Stufe 9: sonst bleibt die Datei unter Windows gesperrt und die
    // Arbeitskopie liesse sich weder löschen noch umbenennen.
    db.close();
  }
}

/**
 * Die Zahlen, die in der Rückfrage stehen.
 *
 * Profilnamen sind Fremdeingabe und laufen deshalb durch `entschaerft()`:
 * Zeichen zur Schreibrichtung könnten in einer Rückfrage sonst das Gegenteil
 * dessen anzeigen, was dort steht.
 */
/** Spaltennamen einer Tabelle – ältere Sicherungen haben nicht alle. */
function spalten(db: BetterSqlite3.Database, tabelle: string): ReadonlySet<string> {
  const zeilen = db.prepare<[], { name: string }>(`PRAGMA table_info(${tabelle})`).all();
  return new Set(zeilen.map((z) => z.name));
}

export function kennzahlenLesen(
  db: BetterSqlite3.Database,
  schemafassung: number,
  tabellen: ReadonlySet<string>,
): Kennzahlen {
  const namen = db
    .prepare<[], { name: string }>('SELECT name FROM profil ORDER BY id')
    .all()
    .map((zeile) => entschaerft(zeile.name));

  /*
    Zeilen im Antwortprotokoll – und ausdrücklich nur die, die jemand wirklich
    beantwortet hat.

    `antwort_log` enthält seit Schemafassung 8 auch Zeilen mit
    `nur_historie = 1`: Fragen eines abgelaufenen Prüfungsbogens, die nie
    aufgeschlagen wurden. Sie gehören in die Historie – sie standen im Bogen –,
    aber nicht in eine Zahl, die „beantwortete Fragen“ heisst und über die
    jemand eine nicht rücknehmbare Entscheidung trifft. Derselbe Befund wie
    docs/stand.md 7.3, nur an einer zweiten Stelle.

    Ältere Sicherungen haben die Spalte nicht; dort zählt alles, und das ist
    richtig so – rückwirkend liesse sich nicht ermitteln, welche Zeile nie
    gestellt wurde.
  */
  const hatNurHistorie = spalten(db, 'antwort_log').has('nur_historie');
  const antworten =
    db
      .prepare<[], { anzahl: number }>(
        hatNurHistorie
          ? 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE nur_historie = 0'
          : 'SELECT COUNT(*) AS anzahl FROM antwort_log',
      )
      .get()?.anzahl ?? 0;
  const letzte =
    db
      .prepare<[], { zeitpunkt: string | null }>(
        'SELECT MAX(zeitpunkt) AS zeitpunkt FROM antwort_log',
      )
      .get()?.zeitpunkt ?? null;
  const gemerkt =
    db
      .prepare<[], { anzahl: number }>(
        'SELECT COUNT(*) AS anzahl FROM frage_stand WHERE gemerkt = 1',
      )
      .get()?.anzahl ?? 0;

  /* Nur ab Schemafassung 2 beziehungsweise 5 – ältere Sicherungen haben die
     Tabellen nicht, und ihr Fehlen ist kein Fehler. */
  const pruefungslaeufe = tabellen.has('pruefung_lauf')
    ? (db.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM pruefung_lauf').get()
        ?.anzahl ?? 0)
    : 0;
  const offenerBogen = tabellen.has('pruefung_offen')
    ? (db.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM pruefung_offen').get()
        ?.anzahl ?? 0) > 0
    : false;

  /* Je Profil, damit sich vergleichen lässt statt nur zu summieren. Die
     Namen sind der einzige Anker: Die Nummern werden auf jedem Gerät
     unabhängig vergeben und sagen über die Zugehörigkeit nichts. */
  const jeProfil = db
    .prepare<[], { id: number; name: string }>('SELECT id, name FROM profil ORDER BY id')
    .all()
    .map((profil) => {
      const zaehle = (sql: string): number =>
        db.prepare<[number], { anzahl: number }>(sql).get(profil.id)?.anzahl ?? 0;
      return {
        name: entschaerft(profil.name),
        antworten: zaehle(
          hatNurHistorie
            ? 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE profil_id = ? AND nur_historie = 0'
            : 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE profil_id = ?',
        ),
        gemerkt: zaehle(
          'SELECT COUNT(*) AS anzahl FROM frage_stand WHERE profil_id = ? AND gemerkt = 1',
        ),
        pruefungslaeufe: tabellen.has('pruefung_lauf')
          ? zaehle('SELECT COUNT(*) AS anzahl FROM pruefung_lauf WHERE profil_id = ?')
          : 0,
        letzteAntwort:
          db
            .prepare<[number], { zeitpunkt: string | null }>(
              'SELECT MAX(zeitpunkt) AS zeitpunkt FROM antwort_log WHERE profil_id = ?',
            )
            .get(profil.id)?.zeitpunkt ?? null,
      };
    });

  return {
    profilnamen: namen,
    jeProfil,
    antworten,
    letzteAntwort: letzte,
    gemerkt,
    pruefungslaeufe,
    offenerBogen,
    schemafassung,
  };
}

/** Dateiname einer Sicherung, mit Datum und Uhrzeit auf die Sekunde genau. */
export function sicherungsDateiname(jetzt: Date, vorsatz = 'Waffensachkunde-Lernstand'): string {
  const z = (wert: number, stellen = 2): string => String(wert).padStart(stellen, '0');
  const stempel =
    `${z(jetzt.getFullYear(), 4)}-${z(jetzt.getMonth() + 1)}-${z(jetzt.getDate())}` +
    `-${z(jetzt.getHours())}${z(jetzt.getMinutes())}${z(jetzt.getSeconds())}`;
  return `${vorsatz}-${stempel}.wsklernstand`;
}

/**
 * Löscht eine Datenbankdatei samt ihrer Nebendateien.
 *
 * `-wal` und `-shm` kamen bis Fassung 0.19.0 nicht mit. Nachgemessen ist das
 * im heutigen Ablauf **harmlos**: Die Arbeitskopie wird nur lesend geöffnet,
 * das zurückbleibende `-wal` ist 0 Byte gross, und der nächste Durchgang
 * überliest es folgenlos – gemessen liest er die richtige Datei mit den
 * richtigen Zahlen.
 *
 * Harmlos, aber nicht ungefährlich. Läge dort je ein **gefülltes** `-wal`,
 * bekäme SQLite den Inhalt der vorigen Datenbank untergeschoben, und
 * `integrity_check` meldete dazu „ok“ – nachgestellt und bestätigt: erwartet
 * wurden 900 Zeilen, gelesen wurden 5000 aus der anderen Datei. Im Durchgang
 * danach war die Datei unbrauchbar („database disk image is malformed“).
 *
 * Ein gefülltes `-wal` entsteht, sobald die Arbeitskopie **schreibend**
 * geöffnet wird – genau das braucht das Übernehmen eines Profils aus einer
 * älteren Sicherung. Diese Zeilen stehen deshalb hier, bevor der erste
 * schreibende Zugriff dazukommt, und nicht danach.
 */
export function aufraeumen(pfad: string): void {
  loeschen(pfad);
  nebendateienAufraeumen(pfad);
}

/**
 * Räumt **nur** `-wal` und `-shm` weg, nicht die Datei selbst.
 *
 * Für den einen Fall, in dem das Ziel stehen bleiben muss, bis sein Ersatz
 * vollständig geschrieben ist: beim Anlegen einer Sicherung über eine
 * vorhandene. Dort erledigt `renameSync` das Ersetzen unteilbar, und ein
 * vorheriges Löschen hätte im Fehlerfall beide Fassungen gekostet – siehe
 * `main/sicherung-dialoge.ts`.
 */
export function nebendateienAufraeumen(pfad: string): void {
  loeschen(`${pfad}-wal`);
  loeschen(`${pfad}-shm`);
}

function loeschen(datei: string): void {
  try {
    unlinkSync(datei);
  } catch {
    /* Nicht da, oder gesperrt. Beides ist hier kein Grund abzubrechen –
       der Aufrufer prüft anschliessend ohnehin, was er braucht. */
  }
}

function hatSqliteKopf(pfad: string): boolean {
  let griff: number;
  try {
    griff = openSync(pfad, 'r');
  } catch {
    return false;
  }
  try {
    const puffer = Buffer.alloc(SQLITE_KOPF.length);
    const gelesen = readSync(griff, puffer, 0, puffer.length, 0);
    return gelesen === puffer.length && puffer.toString('latin1') === SQLITE_KOPF;
  } finally {
    closeSync(griff);
  }
}

/** „1,5 MB“ – dieselbe Schreibweise in jeder Ablehnung. */
export function megabyte(bytes: number): string {
  return `${(bytes / (1024 * 1024)).toLocaleString('de-DE', { maximumFractionDigits: 1 })} MB`;
}
