/**
 * Lernstand in SQLite – Profile, Wiedervorlage, Merkliste, Statistik.
 *
 * Alles liegt lokal in einer einzigen Datei im `userData`-Verzeichnis. Es
 * gibt kein Konto, keine Synchronisierung und keine Telemetrie.
 *
 * Die Klasse {@link Lernstand} bekommt Datenbank und Katalog übergeben und
 * kennt Electron nicht. Dadurch lässt sie sich in Tests gegen eine
 * `:memory:`-Datenbank betreiben; die Anbindung an das Dateisystem und den
 * Anwendungspfad übernimmt {@link lernstandInstanz}.
 *
 * Grundsatz an der IPC-Grenze: jede Nutzlast aus dem Renderer ist
 * unbekannt, bis sie geprüft wurde. Alle öffentlichen Methoden nehmen
 * deshalb `unknown` entgegen und validieren selbst. SQL wird ausschließlich
 * mit vorbereiteten Anweisungen und gebundenen Parametern ausgeführt.
 */

import { existsSync, mkdirSync, renameSync } from 'node:fs';
import { dirname } from 'node:path';

import type BetterSqlite3 from 'better-sqlite3';

import { type FsrsGrad, type Gedaechtnisstand } from '../shared/fsrs';
import type { Katalogwechsel } from '../shared/ipc';
import { bewerteAuswahl, type Frage, type Katalog } from '../shared/katalog';
import {
  BEWERTUNGEN,
  giltAlsRichtig,
  type Behaltensquote,
  type BereichStatistik,
  type Bewertung,
  type FrageStand,
  type Lernuebersicht,
  type Profil,
  type LetzterFreitext,
  type SitzungsFrage,
} from '../shared/lernstand';
import {
  einfuehrungsrate,
  lernplanBerechnen,
  wiedervorlageBerechnen,
  type Lernplan,
  type PlanFrage,
} from '../shared/lernplan';
import {
  abweisen,
  entschaerft,
  ganzeZahl,
  gemischt,
  nutzlast,
  textliste,
  wahrheitswert,
} from './eingaben';
import {
  belegteFragen,
  gesamtstufeMitDeckel,
  MINDESTABSTAND_TAGE,
  reifegradVon,
  stufeFuer,
} from '../shared/reife';
import { HARTNAECKIG_AB_TAGEN, type HartnaeckigeFrage } from '../shared/hartnaeckig';
import { KO_BEREICHE } from '../shared/pruefung';
import { LERNSTAND_SCHEMA, MIGRATIONEN, SCHEMA_VERSION, TAG_MS } from './schema';
import type { DatenbankKonstruktor } from './sicherung';

/** Plausible Obergrenze für eine einzelne Bearbeitungsdauer: 24 Stunden. */
const MAX_DAUER_MS = 24 * 60 * 60 * 1000;

/**
 * Meldung bei einer beschädigten Lernstandsdatei.
 *
 * Als Konstante und nicht als Satz an der Fundstelle, weil zwei Stellen sie
 * brauchen: die Prüfung selbst und {@link istBeschaedigt}, an dem der
 * Hauptprozess diesen einen Fall von allen anderen Öffnungsfehlern
 * unterscheidet – nur bei ihm gibt es etwas anzubieten.
 */
export const LERNSTAND_BESCHAEDIGT =
  'Die Datei mit Ihrem Lernstand ist beschädigt und lässt sich nicht öffnen.';

/** Ob ein Fehler aus dem Öffnen von einer beschädigten Datei stammt. */
export function istBeschaedigt(fehler: unknown): boolean {
  return fehler instanceof Error && fehler.message.startsWith(LERNSTAND_BESCHAEDIGT);
}

/**
 * Wie viele der letzten Antworten in die Tempo-Schätzung eingehen.
 *
 * Genug, um Ausreißer auszumitteln, und wenig genug, dass sich ein
 * tatsächlich schneller gewordenes Tempo auch niederschlägt.
 */
const TEMPO_STICHPROBE = 200;

/** Unter dieser Zahl von Antworten wird nicht geschätzt, sondern angenommen. */
const TEMPO_MINDESTZAHL = 20;

/**
 * Unter so vielen Wiederholungen wird keine Behaltensquote gezeigt.
 *
 * Eine Quote aus drei Antworten schwankt zwischen 0 und 100 Prozent und sagt
 * nichts — sie sähe nur aus wie eine Auskunft. Zwanzig ist die Zahl, ab der
 * ein einzelner Ausrutscher die Quote nicht mehr umwirft.
 */
const BEHALTENSQUOTE_MINDESTZAHL = 20;

const MAX_FREITEXT_LAENGE = 4000;
const MAX_PROFILNAME_LAENGE = 60;

/**
 * Obergrenze für `filter.anzahl`. Liegt bewusst über der Katalogröße, damit
 * sich auch der gesamte Katalog anfordern lässt; alles darüber ist ein
 * Eingabefehler und wird abgewiesen.
 */
const MAX_SITZUNGSGROESSE = 1000;

/** Name des Profils, das beim ersten Start automatisch entsteht. */
const STANDARDPROFIL = 'Standard';

/**
 * Höchstzahl der Profile.
 *
 * Keine technische Grenze, sondern eine gegen Versehen: Die Anwendung ist für
 * eine Handvoll Menschen gedacht, die sich einen Rechner teilen. Wer bei
 * zwanzig ankommt, hat sich vertippt oder etwas anderes vor – in beiden
 * Fällen hilft eine Meldung mehr als eine endlose Liste.
 */
const MAX_PROFILE = 20;

/** Anzahl der Platzhalter je `DELETE`-Stapel – bleibt unter SQLites Parametergrenze. */
const STAPELGROESSE = 400;

const ISO_DATUM_MUSTER = /^\d{4}-\d{2}-\d{2}$/u;

// ─── Zeilentypen ────────────────────────────────────────────────────────────

interface ProfilZeile {
  readonly id: number;
  readonly name: string;
  readonly pruefungstermin: string | null;
  /** Roher JSON-Text; erst {@link Lernstand.zuProfil} macht eine Liste daraus. */
  readonly kapitel_ausschluss: string;
  readonly erstellt_am: string;
}

interface StandZeile {
  readonly frage_id: string;
  readonly versuche: number;
  readonly richtige: number;
  readonly zuletzt_beantwortet: string | null;
  readonly faellig_ab: string | null;
  readonly gemerkt: number;
  readonly letzte_bewertung: string | null;
  readonly intervall_tage: number;
  /** FSRS-Stabilität in Tagen; `null` vor der ersten Antwort. */
  readonly stabilitaet: number | null;
  /** FSRS-Schwierigkeit zwischen 1 und 10; `null` vor der ersten Antwort. */
  readonly schwierigkeit: number | null;
  /** 1, sobald der Abruf belegt ist – siehe `shared/reife.ts`. */
  readonly bestaetigt: number;
}

interface LetzteAntwortZeile {
  readonly frage_id: string;
  readonly richtig: number;
}

/** Eine Zeile der Auszählung hartnäckiger Fragen. */
interface HartnaeckigZeile {
  readonly frage_id: string;
  readonly fehlschlaege: number;
  readonly tage: number;
  readonly zuletzt: string;
}

interface TagesbilanzZeile {
  readonly richtig: number;
  readonly falsch: number;
}

// ─── Prüfhilfen ─────────────────────────────────────────────────────────────
// Die allgemeinen Prüfhilfen stehen in `eingaben.ts`; hier bleibt nur, was
// ausschließlich den Lernstand betrifft.

function istBewertung(wert: unknown): wert is Bewertung {
  return typeof wert === 'string' && (BEWERTUNGEN as readonly string[]).includes(wert);
}

// ─── Wiedervorlage ──────────────────────────────────────────────────────────

/**
 * Übersetzt die Selbsteinschätzung in einen FSRS-Grad.
 *
 * Die vier Stufen der Oberfläche entsprechen eins zu eins denen, mit denen
 * FSRS trainiert wurde. Die Zuordnung steht hier trotzdem ausgeschrieben,
 * damit sie nicht von der zufälligen Reihenfolge in {@link BEWERTUNGEN}
 * abhängt.
 */
const FSRS_GRAD: Readonly<Record<Bewertung, FsrsGrad>> = Object.freeze({
  nochmal: 1,
  schwer: 2,
  gut: 3,
  leicht: 4,
});

/**
 * Bisheriger Gedächtnisstand aus einer Datenbankzeile.
 *
 * `null`, solange die Frage nie beantwortet wurde – oder solange der Lernstand
 * aus einer Version vor der FSRS-Umstellung stammt. Beide Fälle sind
 * gleichbedeutend zu behandeln: Die Frage bekommt bei der nächsten Antwort
 * einen Anfangsstand. Der bisherige Fortschritt in `versuche`, `richtige` und
 * `faellig_ab` bleibt davon unberührt.
 */
function gedaechtnisstandAus(zeile: StandZeile | undefined): Gedaechtnisstand | null {
  if (zeile?.stabilitaet == null || zeile.schwierigkeit == null) {
    return null;
  }
  if (!Number.isFinite(zeile.stabilitaet) || !Number.isFinite(zeile.schwierigkeit)) {
    return null;
  }
  return { stabilitaet: zeile.stabilitaet, schwierigkeit: zeile.schwierigkeit };
}

/**
 * Prüft ein ISO-Datum auf einen Tag, den es wirklich gibt.
 *
 * `Date.parse` genügt dafür nicht: Es lässt den 30. Februar durchgehen und
 * rechnet ihn stillschweigend in den 1. oder 2. März um. Ein Prüfungstermin,
 * der beim Speichern zu einem anderen Tag wird, wäre schlimmer als eine
 * Abweisung – der Lernplan rechnet auf diesen Tag hin.
 *
 * Der Rückvergleich deckt das auf: Nur wenn das erzeugte Datum wieder
 * dieselbe Zeichenkette ergibt, hat es den Tag nicht verschoben.
 */
function istEchtesDatum(iso: string): boolean {
  const zeit = Date.parse(`${iso}T00:00:00Z`);
  if (Number.isNaN(zeit)) {
    return false;
  }
  return new Date(zeit).toISOString().startsWith(iso);
}

/** Volle Tage zwischen zwei Zeitpunkten; nie negativ. */
function tageZwischen(frueher: string | null, spaeter: Date): number {
  if (frueher === null) {
    return 0;
  }
  const start = Date.parse(frueher);
  if (Number.isNaN(start)) {
    return 0;
  }
  return Math.max(0, (spaeter.getTime() - start) / TAG_MS);
}

// ─── Lernstand ──────────────────────────────────────────────────────────────

export interface LernstandOptionen {
  /** Zeitgeber – in Tests überschreibbar, damit Fälligkeiten prüfbar sind. */
  readonly jetzt?: () => Date;
  /** Zufallsquelle für das Mischen – in Tests überschreibbar. */
  readonly zufall?: () => number;
}

/** Geprüfter Sitzungsfilter mit aufgelösten Vorgabewerten. */
interface GepruefterFilter {
  readonly kapitel: readonly string[];
  readonly abschnitte: readonly string[];
  readonly nurGemerkte: boolean;
  readonly nurFehler: boolean;
  readonly nurHartnaeckige: boolean;
  readonly nurNeue: boolean;
  readonly nurOffene: boolean;
  readonly anzahl: number;
  readonly mischen: boolean;
  readonly optionenMischen: boolean;
}

/** Geprüftes Antwortprotokoll – `richtig` ist bereits amtlich nachgerechnet. */
interface GepruefterProtokoll {
  readonly frage: Frage;
  readonly auswahl: readonly string[];
  readonly freitext: string | null;
  readonly richtig: boolean;
  readonly bewertung: Bewertung;
  readonly dauerMs: number;
}

export class Lernstand {
  private readonly db: BetterSqlite3.Database;
  private readonly katalog: Katalog;
  private readonly fragen: ReadonlyMap<string, Frage>;
  private readonly kapitelIds: ReadonlySet<string>;
  private readonly abschnittIds: ReadonlySet<string>;
  private readonly jetzt: () => Date;
  private readonly zufall: () => number;
  /** Befund des Katalogstand-Abgleichs beim Öffnen; `null` bei Gleichstand. */
  private katalogwechselBefund: Katalogwechsel | null = null;

  constructor(
    datenbank: BetterSqlite3.Database,
    katalog: Katalog,
    optionen: LernstandOptionen = {},
  ) {
    this.db = datenbank;
    this.katalog = katalog;
    this.fragen = new Map(katalog.fragen.map((f) => [f.id, f]));
    this.kapitelIds = new Set(katalog.kapitel.map((k) => k.id));
    this.abschnittIds = new Set(katalog.kapitel.flatMap((k) => k.abschnitte.map((a) => a.id)));
    this.jetzt = optionen.jetzt ?? ((): Date => new Date());
    this.zufall = optionen.zufall ?? Math.random;

    this.vorbereiten();
  }

  // ── Aufbau ───────────────────────────────────────────────────────────

  private vorbereiten(): void {
    /* Noch vor allem anderen: Ist die Datei überhaupt heil? Eine beschädigte
       Seite bringt sonst irgendeine spätere Abfrage zu Fall – und zwar mitten
       im Betrieb statt hier, wo sich etwas dagegen tun lässt. */
    this.heilPruefen();

    /* Dann nachsehen, ob wir diese Datei überhaupt anfassen dürfen.
       Erst danach WAL einschalten und das Schema anwenden: Ein Lernstand aus
       einer neueren Programmversion soll unberührt bleiben, damit ihn die
       neuere Fassung später noch öffnen kann. */
    this.versionSperrePruefen();

    // WAL: gleichzeitiges Lesen und Schreiben ohne Sperrkonflikte.
    // Bei `:memory:` bleibt SQLite bei „memory“ – das ist unschädlich.
    this.db.pragma('journal_mode = WAL');
    this.db.pragma('foreign_keys = ON');

    this.db.exec(LERNSTAND_SCHEMA);
    this.migrieren();
    this.katalogstandAbgleichen();
    this.standardprofilSicherstellen();
  }

  /**
   * Weist eine beschädigte Datenbank ab, bevor sie in Betrieb geht.
   *
   * **Warum das hier fehlte und trotzdem wichtig ist.** Für *fremde*
   * Sicherungsdateien läuft seit jeher eine neunstufige Prüfkette
   * (`sicherung.ts`, Stufe 4 mit `integrity_check`). Die eigene, laufende
   * Datei bekam nie eine: Ein Bitfehler, ein defekter Datenträger oder ein
   * abgebrochener Schreibvorgang auf einem USB-Stick blieben unbemerkt, bis
   * irgendeine einzelne Abfrage `SQLITE_CORRUPT` warf – irgendwann mitten in
   * einer Sitzung, mit „Ihr Lernprofil konnte nicht geladen werden“ als
   * einziger Auskunft und ohne jedes Angebot.
   *
   * `quick_check` statt `integrity_check`: Es lässt die aufwendige Prüfung
   * der Indexinhalte weg und findet trotzdem jede beschädigte Seite. Bei
   * einer Datei dieser Größe kostet es Millisekunden – wenig genug, um es bei
   * jedem Start zu tun.
   *
   * Wie beim Einspielweg gilt: `quick_check` **wirft** bei schwerer
   * Beschädigung, statt einen Wert zu liefern. Beide Wege werden behandelt.
   */
  private heilPruefen(): void {
    let heil = false;
    try {
      const zeilen = this.db.pragma('quick_check') as { quick_check: string }[];
      heil = zeilen.length === 1 && zeilen[0]?.quick_check === 'ok';
    } catch {
      heil = false;
    }

    if (!heil) {
      abweisen(LERNSTAND_BESCHAEDIGT);
    }
  }

  /**
   * Weist einen Lernstand ab, der von einer neueren Programmversion stammt –
   * bevor irgendetwas geschrieben wird.
   *
   * Fehlt die Tabelle `schema_version`, ist die Datei neu oder leer; dann gibt
   * es nichts zu schützen und die Prüfung entfällt.
   */
  private versionSperrePruefen(): void {
    const tabelle = this.db
      .prepare<[], { name: string }>(
        "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_version'",
      )
      .get();
    if (tabelle === undefined) {
      return;
    }

    const zeile = this.db
      .prepare<[], { version: number | null }>('SELECT MAX(version) AS version FROM schema_version')
      .get();
    const vorhanden = zeile?.version ?? 0;

    if (vorhanden > SCHEMA_VERSION) {
      abweisen(
        `Der Lernstand wurde mit einer neueren Programmversion angelegt ` +
          `(Schema ${String(vorhanden)}, unterstützt wird ${String(SCHEMA_VERSION)}). ` +
          'Bitte die Anwendung aktualisieren.',
      );
    }
  }

  private migrieren(): void {
    const zeile = this.db
      .prepare<[], { version: number | null }>('SELECT MAX(version) AS version FROM schema_version')
      .get();
    const vorhanden = zeile?.version ?? 0;

    if (vorhanden > SCHEMA_VERSION) {
      abweisen(
        `Der Lernstand wurde mit einer neueren Programmversion angelegt ` +
          `(Schema ${String(vorhanden)}, unterstützt wird ${String(SCHEMA_VERSION)}). ` +
          'Bitte die Anwendung aktualisieren.',
      );
    }

    const vermerken = this.db.prepare<[number, string]>(
      'INSERT OR IGNORE INTO schema_version (version, angewendet_am) VALUES (?, ?)',
    );

    const lauf = this.db.transaction(() => {
      for (let ziel = vorhanden + 1; ziel <= SCHEMA_VERSION; ziel += 1) {
        const schritt = Object.prototype.hasOwnProperty.call(MIGRATIONEN, ziel)
          ? MIGRATIONEN[ziel]
          : undefined;
        if (typeof schritt === 'function') {
          schritt(this.db);
        } else if (schritt !== undefined && schritt.trim().length > 0) {
          this.db.exec(schritt);
        }
        vermerken.run(ziel, this.jetztIso());
      }
    });
    lauf();
  }

  /**
   * Vergleicht den gespeicherten mit dem geladenen Katalogstand.
   *
   * Ein vollständiger Migrationspfad für eine neue BVA-Fassung ist ohne die
   * künftige Fassung nicht baubar – was sich bauen lässt, ist Ehrlichkeit:
   * erkennen, beziffern, melden. Gezählt wird, wie viele Zeilen auf Frage-IDs
   * zeigen, die es im geladenen Katalog nicht gibt. **Gelöscht wird nichts**:
   * Eine spätere Programmfassung kann die Zeilen vielleicht noch zuordnen –
   * gelöscht kann sie es sicher nicht mehr.
   *
   * Der neue Stand wird erst NACH dem Festhalten des Befunds vermerkt. Der
   * Vermerk ist der Punkt, ab dem jeder weitere Start Gleichstand vorfindet
   * und schweigt; stünde er zuerst und bräche das Öffnen dazwischen ab, wäre
   * die Abweichung für immer unbemerkt.
   */
  private katalogstandAbgleichen(): void {
    const geladen = this.katalog.meta.stand;
    const zeile = this.db
      .prepare<[], { stand: string }>('SELECT stand FROM katalog_stand ORDER BY id DESC LIMIT 1')
      .get();

    if (zeile === undefined) {
      /* Erstes Öffnen mit Schemafassung 9 – für frische wie für migrierte
         Datenbanken derselbe Weg. Gegen welchen Stand ältere Zeilen wirklich
         entstanden, wurde nie festgehalten und lässt sich nicht
         rekonstruieren; der geladene Stand ist der ehrlichste verfügbare
         Ausgangswert (siehe Schema-Version 9 in `schema.ts`). Keine Meldung:
         Für den Nutzer hat sich nichts geändert. */
      this.katalogstandVermerken(geladen);
      return;
    }

    if (zeile.stand === geladen) {
      return; // Gleicher Stand: keine Meldung, kein Rauschen.
    }

    this.katalogwechselBefund = {
      vorher: zeile.stand,
      nachher: geladen,
      verwaisteStaende: this.verwaisteZeilen('frage_stand'),
      verwaisteAntworten: this.verwaisteZeilen('antwort_log'),
    };
    this.katalogstandVermerken(geladen);
  }

  private katalogstandVermerken(stand: string): void {
    this.db
      .prepare<[string, string]>('INSERT INTO katalog_stand (stand, vermerkt_am) VALUES (?, ?)')
      .run(stand, this.jetztIso());
  }

  /** Zeilen der Tabelle, deren Frage-ID es im geladenen Katalog nicht gibt. */
  private verwaisteZeilen(tabelle: 'frage_stand' | 'antwort_log'): number {
    /* Der Tabellenname lässt sich nicht als Parameter binden; er stammt aus
       dem Literaltyp des Parameters, nie aus einer Eingabe. Gruppiert je
       Frage-ID statt Zeile für Zeile: wenige hundert Gruppen gegen die
       bekannten IDs zu halten ist billiger, als jede Protokollzeile einzeln
       herüberzureichen. */
    const gruppen = this.db
      .prepare<[], { frage_id: string; anzahl: number }>(
        `SELECT frage_id, COUNT(*) AS anzahl FROM ${tabelle} GROUP BY frage_id`,
      )
      .all();

    let summe = 0;
    for (const gruppe of gruppen) {
      if (!this.fragen.has(gruppe.frage_id)) {
        summe += gruppe.anzahl;
      }
    }
    return summe;
  }

  /**
   * Legt beim ersten Start automatisch ein Profil an, damit die Anwendung
   * ohne Einrichtungsdialog benutzbar ist.
   */
  private standardprofilSicherstellen(): void {
    const zeile = this.db
      .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil')
      .get();
    if ((zeile?.anzahl ?? 0) === 0) {
      this.profilEinfuegen(STANDARDPROFIL);
    }
  }

  private jetztIso(): string {
    return this.jetzt().toISOString();
  }

  /**
   * Die geöffnete Verbindung – für Module, die auf denselben Lernstand
   * schreiben, allen voran die Prüfungssimulation.
   *
   * Bewusst nur lesend nach außen gegeben: der Lernstand bleibt Eigentümer
   * der Verbindung, wendet das Schema an und schließt sie wieder. Eine
   * zweite Verbindung auf dieselbe Datei würde sich unnötig sperren.
   */
  get datenbank(): BetterSqlite3.Database {
    return this.db;
  }

  /**
   * Befund des Katalogstand-Abgleichs beim Öffnen.
   *
   * `null` bei Gleichstand – der Regelfall, und er bleibt bewusst stumm.
   * Ein Befund gilt für die Laufzeit dieser Instanz: Der neue Stand ist
   * bereits vermerkt, der nächste Start findet Gleichstand vor. Die
   * Systemauskunft (`anwendung:info`) reicht ihn an die Oberfläche weiter.
   */
  get katalogwechsel(): Katalogwechsel | null {
    return this.katalogwechselBefund;
  }

  /** Schließt die Datenbank. */
  schliessen(): void {
    if (this.db.open) {
      this.db.close();
    }
  }

  /**
   * Die Fragen, die dieses Profil lernt.
   *
   * ## Warum eine Methode und keine sechs Filter
   *
   * `this.katalog.fragen` stand an sechs Stellen und hieß überall
   * stillschweigend „alle 575“: in der Sitzungsschleife, in der Übersicht, in
   * der Bereichsaufschlüsselung, im Lernplan und zweimal in Zählungen. Sechs
   * Bedingungen einzeln einzubauen hieße, dass die siebte Stelle sie
   * irgendwann vergisst – und der Schaden wäre still: Die Sitzung zöge aus
   * 486 Fragen, während der Startbildschirm weiter „8 von 575 sicher“ sagt
   * und der Fortschrittsbalken für immer 89 Fragen unter dem Maximum hängt.
   *
   * Deshalb ein Wechsel der **Quelle** statt zusätzlicher Bedingungen. Wer
   * künftig über Fragen läuft, die zum Lernen gehören, schreibt
   * `lernfragen(profilId)` und trifft damit von selbst das Richtige.
   *
   * ## Was hier ausdrücklich NICHT gefiltert wird
   *
   * Die **Volltextsuche** und der Fragen-Browser: Nachschlagen ist kein
   * Lernen, und die Suchansicht sagt „alle 575 amtlichen Fragen“ zu. Sie
   * gehen nie über diese Methode, sondern über den Katalog im Renderer.
   *
   * Die **Tagesbilanz** („heute richtig/falsch“): Sie liest `antwort_log`
   * ohne Katalogbezug und sagt, was jemand heute getan hat – nicht, was zu
   * seinem Umfang gehört. Wer heute eine Frage aus Kapitel IV beantwortet und
   * es danach abwählt, sieht sie dort weiterhin. Das ist gewollt: Die
   * Tagesbilanz ist ein Protokoll, keine Bestandszahl.
   *
   * Das **Zurücksetzen**: Es räumt auf, was da ist, nicht was gelernt wird.
   */
  private lernfragen(profilId: number): readonly Frage[] {
    const ausschluss = this.kapitelAusschlussVon(profilId);
    if (ausschluss.size === 0) {
      return this.katalog.fragen;
    }
    return this.katalog.fragen.filter((frage) => !ausschluss.has(frage.kapitel));
  }

  /** Die abgewählten Kapitel eines Profils, als Menge. */
  private kapitelAusschlussVon(profilId: number): ReadonlySet<string> {
    const zeile = this.db
      .prepare<[number], { kapitel_ausschluss: string }>(
        'SELECT kapitel_ausschluss FROM profil WHERE id = ?',
      )
      .get(profilId);
    return new Set(this.kapitelAusschlussLesen(zeile?.kapitel_ausschluss ?? '[]'));
  }

  // ── Profile ──────────────────────────────────────────────────────────

  private profilEinfuegen(name: string): Profil {
    const erstelltAm = this.jetztIso();
    const ergebnis = this.db
      .prepare<[string, string]>(
        'INSERT INTO profil (name, pruefungstermin, erstellt_am) VALUES (?, NULL, ?)',
      )
      .run(name, erstelltAm);

    return {
      id: Number(ergebnis.lastInsertRowid),
      name,
      pruefungstermin: null,
      /* Muss zum DEFAULT der Spalte passen: Das INSERT oben nennt sie nicht,
         der Wert kommt aus dem Schema. Stünde hier etwas anderes, wiche das
         frisch angelegte Profil vom gelesenen ab. */
      kapitelAusschluss: [],
      erstelltAm,
    };
  }

  private zuProfil(zeile: ProfilZeile): Profil {
    return {
      id: zeile.id,
      name: zeile.name,
      pruefungstermin: zeile.pruefungstermin,
      kapitelAusschluss: this.kapitelAusschlussLesen(zeile.kapitel_ausschluss),
      erstelltAm: zeile.erstellt_am,
    };
  }

  /**
   * Liest die gespeicherte Kapitelliste – wohlwollend, nie werfend.
   *
   * Dieselbe Vorsicht wie bei `tageBisTermin`: Was in der Datenbank steht,
   * kann aus einer früheren Fassung stammen. Hier wiegt das schwerer als beim
   * Termin, denn eine neue BVA-Katalogfassung kann Kapitelkennungen
   * verschieben (siehe `docs/stand.md`). Eine unbekannte Kennung fällt still
   * heraus; ein Fehler beim Lesen dürfte die Anwendung nicht am Starten
   * hindern, nur weil jemand einmal ein Kapitel abgewählt hat.
   */
  private kapitelAusschlussLesen(roh: string): readonly string[] {
    let gelesen: unknown;
    try {
      gelesen = JSON.parse(roh);
    } catch {
      return [];
    }
    if (!Array.isArray(gelesen)) {
      return [];
    }
    return gelesen.filter(
      (eintrag): eintrag is string => typeof eintrag === 'string' && this.kapitelIds.has(eintrag),
    );
  }

  /** Alle Profile in der Reihenfolge ihrer Anlage. */
  profile(): Profil[] {
    return this.db
      .prepare<[], ProfilZeile>(
        'SELECT id, name, pruefungstermin, kapitel_ausschluss, erstellt_am FROM profil ORDER BY id',
      )
      .all()
      .map((zeile) => this.zuProfil(zeile));
  }

  private profilnamePruefen(wert: unknown): string {
    if (typeof wert !== 'string') {
      abweisen('Ungültige Anfrage: Der Profilname muss eine Zeichenkette sein.');
    }
    // Steuerzeichen werden zu Leerzeichen – der Name landet in der
    // Oberfläche und in Fehlermeldungen.
    const name = entschaerft(wert, ' ').trim();
    if (name.length === 0) {
      abweisen('Der Profilname darf nicht leer sein.');
    }
    if (name.length > MAX_PROFILNAME_LAENGE) {
      abweisen(`Der Profilname darf höchstens ${String(MAX_PROFILNAME_LAENGE)} Zeichen lang sein.`);
    }
    return name;
  }

  /**
   * Gibt es diesen Namen schon – unabhängig von Groß- und Kleinschreibung?
   *
   * Die UNIQUE-Bedingung auf `profil.name` vergleicht buchstabengenau, lässt
   * also „Olaf“, „olaf“ und „OLAF“ nebeneinander stehen. In einer Liste,
   * aus der jemand sein Profil wiedererkennen soll, ist das keine
   * Unterscheidung, sondern eine Falle. `COLLATE NOCASE` deckt die
   * lateinischen Buchstaben ab; „Ü“ gegen “ü” bleibt offen, weil SQLite
   * ohne ICU nicht mehr kann – besser die Hälfte als nichts.
   */
  private namenskonflikt(name: string, ausserId?: number): boolean {
    const zeile =
      ausserId === undefined
        ? this.db
            .prepare<[string], { id: number }>(
              'SELECT id FROM profil WHERE name = ? COLLATE NOCASE',
            )
            .get(name)
        : this.db
            .prepare<[string, number], { id: number }>(
              'SELECT id FROM profil WHERE name = ? COLLATE NOCASE AND id <> ?',
            )
            .get(name, ausserId);
    return zeile !== undefined;
  }

  profilAnlegen(nameRoh: unknown): Profil {
    const name = this.profilnamePruefen(nameRoh);

    const anzahl =
      this.db.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil').get()
        ?.anzahl ?? 0;
    if (anzahl >= MAX_PROFILE) {
      abweisen(
        `Es sind höchstens ${String(MAX_PROFILE)} Profile möglich. ` +
          'Löschen Sie ein nicht mehr gebrauchtes, bevor Sie ein neues anlegen.',
      );
    }

    if (this.namenskonflikt(name)) {
      abweisen(`Es gibt bereits ein Profil mit dem Namen „${entschaerft(name)}“.`);
    }
    return this.profilEinfuegen(name);
  }

  /**
   * Löscht ein Profil samt allem, was daran hängt.
   *
   * Eine Zeile genügt: `frage_stand`, `antwort_log` und `pruefung_lauf`
   * verweisen mit `ON DELETE CASCADE` auf `profil`, und `PRAGMA foreign_keys`
   * ist eingeschaltet. Der Test `löscht alles, was am Profil hängt` weist
   * das nach – ohne ihn wäre es eine Annahme über SQLite, keine Zusage.
   *
   * Das letzte Profil bleibt. Ohne Profil hätte die Anwendung keinen Ort für
   * Antworten mehr, und ein neues entstünde erst beim nächsten Start – die
   * Anwendung stünde bis dahin ohne Lernstand da.
   */
  profilLoeschen(profilIdRoh: unknown): Profil[] {
    const profilId = this.profilIdPruefen(profilIdRoh);

    const anzahl =
      this.db.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil').get()
        ?.anzahl ?? 0;
    if (anzahl <= 1) {
      abweisen(
        'Das letzte Profil lässt sich nicht löschen. ' +
          'Wenn Sie neu anfangen wollen, setzen Sie stattdessen den Lernstand zurück.',
      );
    }

    this.db.prepare<[number]>('DELETE FROM profil WHERE id = ?').run(profilId);
    return this.profile();
  }

  /**
   * Ändert Name, Prüfungstermin und/oder die abgewählten Kapitel. Nicht
   * angegebene Felder bleiben unverändert; `pruefungstermin: null` löscht den
   * Termin.
   *
   * Für `kapitelAusschluss` gibt es bewusst **keinen** null-Zweig: Die leere
   * Liste ist bereits die Abwahl von nichts. Ein zweiter Weg dorthin wäre nur
   * eine zweite Schreibweise für dasselbe.
   */
  profilAktualisieren(anfrageRoh: unknown): Profil {
    const anfrage = nutzlast(anfrageRoh, 'Die Profiländerung');
    const id = this.profilIdPruefen(anfrage['id']);

    const nameRoh = anfrage['name'];
    const name = nameRoh === undefined ? undefined : this.profilnamePruefen(nameRoh);

    const terminRoh = anfrage['pruefungstermin'];
    let termin: string | null | undefined;
    if (terminRoh === undefined) {
      termin = undefined;
    } else if (terminRoh === null) {
      termin = null;
    } else if (typeof terminRoh === 'string' && ISO_DATUM_MUSTER.test(terminRoh)) {
      if (!istEchtesDatum(terminRoh)) {
        abweisen(`Der Prüfungstermin ist kein gültiges Datum: „${entschaerft(terminRoh)}“.`);
      }
      termin = terminRoh;
    } else {
      abweisen('Der Prüfungstermin muss ein ISO-Datum (JJJJ-MM-TT) oder null sein.');
    }

    const ausschlussRoh = anfrage['kapitelAusschluss'];
    let ausschluss: string | undefined;
    if (ausschlussRoh !== undefined) {
      if (!Array.isArray(ausschlussRoh)) {
        abweisen('Ungültige Anfrage: kapitelAusschluss muss eine Liste sein.');
      }
      const ids = ausschlussRoh.map((eintrag) => {
        if (typeof eintrag !== 'string') {
          abweisen('Ungültige Anfrage: kapitelAusschluss darf nur Zeichenketten enthalten.');
        }
        if (!this.kapitelIds.has(eintrag)) {
          abweisen(`Unbekanntes Kapitel: „${entschaerft(eintrag)}“.`);
        }
        return eintrag;
      });
      /* Doppelte fallen heraus, die Reihenfolge folgt dem Katalog: Was
         gespeichert wird, soll nicht davon abhängen, in welcher Reihenfolge
         jemand geklickt hat. */
      ausschluss = JSON.stringify(
        this.katalog.kapitel.map((kapitel) => kapitel.id).filter((id) => ids.includes(id)),
      );
    }

    const aendern = this.db.transaction(() => {
      if (name !== undefined) {
        /* Derselbe Maßstab wie beim Anlegen: Groß- und Kleinschreibung
           unterscheidet nicht. Vorher stand hier ein buchstabengenauer
           Vergleich – über das Umbenennen ließ sich also anlegen, was das
           Anlegen abweist. */
        if (this.namenskonflikt(name, id)) {
          abweisen(`Es gibt bereits ein Profil mit dem Namen „${entschaerft(name)}“.`);
        }
        this.db.prepare<[string, number]>('UPDATE profil SET name = ? WHERE id = ?').run(name, id);
      }
      if (termin !== undefined) {
        this.db
          .prepare<[string | null, number]>('UPDATE profil SET pruefungstermin = ? WHERE id = ?')
          .run(termin, id);
      }
      if (ausschluss !== undefined) {
        this.db
          .prepare<[string, number]>('UPDATE profil SET kapitel_ausschluss = ? WHERE id = ?')
          .run(ausschluss, id);
      }
    });
    aendern();

    return this.profilLesen(id);
  }

  private profilLesen(id: number): Profil {
    const zeile = this.db
      .prepare<[number], ProfilZeile>(
        'SELECT id, name, pruefungstermin, kapitel_ausschluss, erstellt_am FROM profil WHERE id = ?',
      )
      .get(id);
    if (zeile === undefined) {
      abweisen(`Unbekanntes Profil: ${String(id)}.`);
    }
    return this.zuProfil(zeile);
  }

  /**
   * Volle Tage bis zum Prüfungstermin des Profils.
   *
   * `null`, wenn kein Termin gesetzt ist. Negativ, wenn er vorbei ist – das
   * darf nicht verschluckt werden, sonst würde ein vergessener Termin still
   * wie „heute“ behandelt und alle Intervalle auf das Maximum ziehen.
   *
   * Gerechnet wird auf Kalendertage, nicht auf Stunden: Der Termin ist ein
   * Datum, keine Uhrzeit. Wer abends lernt und morgens geprüft wird, hat
   * einen Tag Zeit – nicht null.
   */
  private tageBisTermin(profilId: number, jetzt: Date): number | null {
    const zeile = this.db
      .prepare<[number], { pruefungstermin: string | null }>(
        'SELECT pruefungstermin FROM profil WHERE id = ?',
      )
      .get(profilId);

    /* Auch beim Lesen prüfen: Ein Lernstand aus einer früheren Fassung kann
       ein unmögliches Datum enthalten, das damals durchgelassen wurde. */
    const termin = zeile?.pruefungstermin ?? null;
    if (termin === null || !ISO_DATUM_MUSTER.test(termin) || !istEchtesDatum(termin)) {
      return null;
    }

    const ziel = Date.parse(`${termin}T00:00:00Z`);

    const heute = Date.parse(`${Lernstand.alsIsoDatum(jetzt)}T00:00:00Z`);
    return Math.round((ziel - heute) / TAG_MS);
  }

  /** Lokales Kalenderdatum als ISO-Datum, ohne Zeitzonenversatz. */
  private static alsIsoDatum(zeitpunkt: Date): string {
    const jahr = String(zeitpunkt.getFullYear()).padStart(4, '0');
    const monat = String(zeitpunkt.getMonth() + 1).padStart(2, '0');
    const tag = String(zeitpunkt.getDate()).padStart(2, '0');
    return `${jahr}-${monat}-${tag}`;
  }

  /**
   * Prüft eine Profil-ID aus dem Renderer gegen die tatsächlich vorhandenen
   * Profile. Öffentlich, weil die Prüfungssimulation dieselbe Prüfung
   * braucht, bevor sie einen Lauf speichert.
   */
  profilIdPruefen(wert: unknown): number {
    const id = ganzeZahl(wert, 'Die Profil-ID', 1, Number.MAX_SAFE_INTEGER);
    const zeile = this.db
      .prepare<[number], { id: number }>('SELECT id FROM profil WHERE id = ?')
      .get(id);
    if (zeile === undefined) {
      abweisen(`Unbekanntes Profil: ${String(id)}.`);
    }
    return id;
  }

  // ── Stand einzelner Fragen ───────────────────────────────────────────

  private static zuFrageStand(frageId: string, zeile: StandZeile | undefined): FrageStand {
    if (zeile === undefined) {
      return {
        frageId,
        versuche: 0,
        richtige: 0,
        zuletztBeantwortet: null,
        faelligAb: null,
        gemerkt: false,
        letzteBewertung: null,
      };
    }
    return {
      frageId,
      versuche: zeile.versuche,
      richtige: zeile.richtige,
      zuletztBeantwortet: zeile.zuletzt_beantwortet,
      faelligAb: zeile.faellig_ab,
      gemerkt: zeile.gemerkt === 1,
      letzteBewertung: istBewertung(zeile.letzte_bewertung) ? zeile.letzte_bewertung : null,
    };
  }

  /**
   * Die hartnäckigen Fragen dieses Profils – wiederholt danebengegangen.
   *
   * **Warum aus `antwort_log` und nicht aus `frage_stand`.** Die Standtabelle
   * ist eine Zusammenfassung; sie weiß, wie oft eine Frage richtig war, aber
   * nicht **wann** sie danebenging. Genau darauf kommt es an: Drei
   * Fehlschläge in einer Sitzung sind die gewöhnliche Wiedereinreihung, drei
   * an drei verschiedenen Tagen sind ein Knoten.
   *
   * **Was ausgeschlossen wird.** `nur_historie = 1` steht an Zeilen, die eine
   * Simulation für nie aufgeschlagene Fragen anlegt: Sie tragen `richtig = 0`,
   * ohne dass jemand sie je gesehen hätte (siehe `schema.ts` und
   * `entscheidung-motivation.md`). Sie hier mitzuzählen machte aus jedem
   * abgebrochenen Prüfungslauf ein Dutzend „hartnäckiger“ Fragen.
   *
   * Gerechnet wird über den **Kalendertag der Ortszeit**: `date(zeitpunkt)`
   * arbeitet auf der gespeicherten ISO-Zeit in UTC, deshalb die Umrechnung
   * über `localtime`. Wer um ein Uhr nachts lernt, lernt an dem Tag, der auf
   * seiner Uhr steht – dieselbe Regel wie in `tagesgrenzen`.
   */
  hartnaeckige(profilIdRoh: unknown): HartnaeckigeFrage[] {
    const profilId = this.profilIdPruefen(profilIdRoh);

    const zeilen = this.db
      .prepare<[number, number], HartnaeckigZeile>(
        `SELECT frage_id,
                COUNT(*)                                        AS fehlschlaege,
                COUNT(DISTINCT date(zeitpunkt, 'localtime'))    AS tage,
                MAX(zeitpunkt)                                  AS zuletzt
           FROM antwort_log
          WHERE profil_id = ? AND richtig = 0 AND nur_historie = 0
          GROUP BY frage_id
         HAVING tage >= ?
          ORDER BY tage DESC, fehlschlaege DESC, zuletzt DESC`,
      )
      .all(profilId, HARTNAECKIG_AB_TAGEN);

    /* Nur Fragen, die es im geladenen Katalog gibt und die zum Lernumfang
       des Profils gehören: Nach einem Katalogwechsel stehen verwaiste Zeilen
       im Protokoll, und abgewähltes Kapitel IV soll auch hier nicht
       auftauchen. */
    const umfang = new Set(this.lernfragen(profilId).map((frage) => frage.id));

    return zeilen
      .filter((zeile) => umfang.has(zeile.frage_id))
      .map((zeile) => {
        const fehlwahl = this.haeufigsteFehlwahl(profilId, zeile.frage_id);
        return {
          frageId: zeile.frage_id,
          fehlschlaege: zeile.fehlschlaege,
          tage: zeile.tage,
          zuletzt: zeile.zuletzt,
          ...(fehlwahl === null ? {} : { haeufigsteFehlwahl: fehlwahl }),
        };
      });
  }

  /**
   * Wie oft Wiederholungen mit Abstand wirklich saßen — die Ist-Quote.
   *
   * **Wozu.** Der Lernplan steuert auf eine Zielquote, die Reife-Ampel zeigt
   * modellierte Abrufwahrscheinlichkeiten. Wie oft der Lernende fällige
   * Wiederholungen **tatsächlich** trifft, stand bis 0.22.0 nirgends — obwohl
   * es im Protokoll steht und nur zu zählen war. Gemessene Vergangenheit,
   * keine Vorhersage.
   *
   * **Was ausgeschlossen wird, und zwar doppelt.** `nur_historie = 1` steht an
   * Zeilen, die eine Simulation für nie aufgeschlagene Fragen anlegt: Sie
   * tragen `richtig = 0`, ohne dass jemand sie gesehen hätte. Sie dürfen
   * weder als **gezählte Antwort** noch als **Vorgänger** durchgehen — als
   * Vorgänger verschöben sie den gemessenen Abstand, als Antwort brächten sie
   * erfundene Fehlschläge in die Quote. Das ist genau die Verunreinigung, die
   * `docs/entscheidung-reifegrad.md` Abschnitt 8 bei Simulationen benennt.
   *
   * **Warum der Vorgänger und nicht `frage_stand`.** Die Standtabelle kennt
   * nur die letzte Antwort. Der Abstand zwischen *jeder* Antwort und ihrer
   * Vorgängerin steht allein im Protokoll — und genau der entscheidet, ob
   * eine Antwort etwas über das Behalten aussagt oder bloß über das
   * Wiedererkennen.
   */
  behaltensquote(profilId: number): Behaltensquote | null {
    const zeile = this.db
      .prepare<[number, number], { gesamt: number; richtig: number }>(
        `WITH echte AS (
           SELECT frage_id, zeitpunkt, richtig,
                  LAG(zeitpunkt) OVER (PARTITION BY frage_id ORDER BY id) AS vorher
             FROM antwort_log
            WHERE profil_id = ? AND nur_historie = 0
         )
         SELECT COUNT(*) AS gesamt, SUM(richtig) AS richtig
           FROM echte
          WHERE vorher IS NOT NULL
            AND julianday(zeitpunkt) - julianday(vorher) >= ?`,
      )
      .get(profilId, MINDESTABSTAND_TAGE);

    if (zeile === undefined || zeile.gesamt < BEHALTENSQUOTE_MINDESTZAHL) {
      return null;
    }
    return { gesamt: zeile.gesamt, richtig: zeile.richtig };
  }

  /**
   * Die am häufigsten gewählte falsche Antwort einer Frage.
   *
   * `null`, wenn es keine gibt oder keine heraussticht — bei offenen Fragen
   * ist die Spalte leer, und ein Gleichstand sagt nichts.
   *
   * Die Spalte `auswahl` wurde bis 0.22.0 geschrieben und von keiner Abfrage
   * gelesen. Sie unterscheidet zwei didaktisch verschiedene Fälle: immer
   * derselbe falsche Buchstabe heißt Verwechslung, gestreute Fehlgriffe
   * heißen Nichtwissen.
   */
  private haeufigsteFehlwahl(profilId: number, frageId: string): string | null {
    const zeilen = this.db
      .prepare<[number, string], { auswahl: string }>(
        `SELECT auswahl FROM antwort_log
          WHERE profil_id = ? AND frage_id = ? AND richtig = 0 AND nur_historie = 0`,
      )
      .all(profilId, frageId);

    const zaehler = new Map<string, number>();
    for (const zeile of zeilen) {
      let labels: unknown;
      try {
        labels = JSON.parse(zeile.auswahl);
      } catch {
        continue;
      }
      if (!Array.isArray(labels) || labels.length === 0) {
        continue;
      }
      /* Die ganze Auswahl als Schlüssel, nicht die einzelnen Labels: „a und
         c“ ist ein anderer Fehlgriff als „a“ allein. */
      const schluessel = labels
        .filter((l) => typeof l === 'string')
        .sort()
        .join(', ');
      if (schluessel === '') {
        continue;
      }
      zaehler.set(schluessel, (zaehler.get(schluessel) ?? 0) + 1);
    }

    const sortiert = [...zaehler.entries()].sort((a, b) => b[1] - a[1]);
    const erste = sortiert[0];
    const zweite = sortiert[1];
    if (erste === undefined || erste[1] < 2) {
      return null;
    }
    /* Ein Gleichstand ist kein Muster – dann lieber nichts sagen. */
    if (zweite?.[1] === erste[1]) {
      return null;
    }
    return erste[0];
  }

  private standZeile(profilId: number, frageId: string): StandZeile | undefined {
    return this.db
      .prepare<[number, string], StandZeile>(
        `SELECT frage_id, versuche, richtige, zuletzt_beantwortet, faellig_ab,
                gemerkt, letzte_bewertung, intervall_tage, stabilitaet, schwierigkeit,
                bestaetigt
           FROM frage_stand WHERE profil_id = ? AND frage_id = ?`,
      )
      .get(profilId, frageId);
  }

  private standKarte(profilId: number): ReadonlyMap<string, StandZeile> {
    const zeilen = this.db
      .prepare<[number], StandZeile>(
        `SELECT frage_id, versuche, richtige, zuletzt_beantwortet, faellig_ab,
                gemerkt, letzte_bewertung, intervall_tage, stabilitaet, schwierigkeit,
                bestaetigt
           FROM frage_stand WHERE profil_id = ?`,
      )
      .all(profilId);
    return new Map(zeilen.map((z) => [z.frage_id, z]));
  }

  /**
   * Ergebnis der jeweils letzten Antwort je Frage.
   *
   * Quelle ist bewusst `antwort_log` und nicht `frage_stand`: das Protokoll
   * ist die Wahrheit, die Standtabelle nur eine Zusammenfassung.
   *
   * **Was ausgeschlossen wird.** `nur_historie = 1` steht an Zeilen, die ein
   * abgelaufener Prüfungsbogen für Fragen geschrieben hat, die nie
   * aufgeschlagen wurden (Schemafassung 8). Sie tragen `richtig = 0` und
   * hätten hier als „zuletzt falsch beantwortet“ gegolten – für den Filter
   * „nur Fehler“, für die Zahl auf dem Startbildschirm und, weil das
   * gedruckte Fehlerprotokoll seine Menge aus genau diesem Filter zieht, für
   * ein Blatt, das über nie gesehene Fragen behauptet, man habe sie falsch
   * beantwortet. Ein 80-Fragen-Bogen, bei dem die Zeit nach Frage 20 abläuft,
   * machte so aus 60 ungesehenen Fragen 60 Fehler.
   *
   * Der Ausschluss steht in derselben Form schon in {@link hartnaeckige},
   * {@link behaltensquote}, `haeufigsteFehlwahl` und `tagesbilanz`. Hier
   * fehlte er als einzige der sechs Abfragen über das Protokoll.
   */
  private letzteAntwortKarte(profilId: number): ReadonlyMap<string, boolean> {
    const zeilen = this.db
      .prepare<[number], LetzteAntwortZeile>(
        `SELECT l.frage_id AS frage_id, l.richtig AS richtig
           FROM antwort_log AS l
           JOIN (
             SELECT frage_id, MAX(id) AS letzte_id
               FROM antwort_log
              WHERE profil_id = ? AND nur_historie = 0
              GROUP BY frage_id
           ) AS m ON l.id = m.letzte_id`,
      )
      .all(profilId);
    return new Map(zeilen.map((z) => [z.frage_id, z.richtig === 1]));
  }

  /**
   * Die zuletzt geschriebene Freitextantwort je Frage.
   *
   * Dieselbe Bauform wie {@link letzteAntwortKarte}: eine Abfrage für die
   * ganze Sitzung statt einer je Frage. Nachgemessen an einem Bestand mit
   * 4.600 Protokollzeilen kostet das rund eine Millisekunde – der vorhandene
   * Index `idx_antwort_log_profil_frage` trägt sie.
   *
   * Leere Einträge fallen hier schon heraus: Das Feld darf leer bleiben, und
   * ein leerer Kasten mit der Überschrift „Beim letzten Mal schrieben Sie“
   * wäre eine Vorhaltung ohne Inhalt.
   */
  private letzterFreitextKarte(profilId: number): ReadonlyMap<string, LetzterFreitext> {
    const zeilen = this.db
      .prepare<[number], { frage_id: string; freitext: string; zeitpunkt: string }>(
        `SELECT l.frage_id AS frage_id, l.freitext AS freitext, l.zeitpunkt AS zeitpunkt
           FROM antwort_log AS l
           JOIN (
             SELECT frage_id, MAX(id) AS letzte_id
               FROM antwort_log
              WHERE profil_id = ? AND freitext IS NOT NULL AND TRIM(freitext) <> ''
              GROUP BY frage_id
           ) AS m ON l.id = m.letzte_id`,
      )
      .all(profilId);

    return new Map(zeilen.map((z) => [z.frage_id, { text: z.freitext, zeitpunkt: z.zeitpunkt }]));
  }

  /**
   * Aktueller Stand einer einzelnen Frage.
   *
   * **Bis 0.22.0 rief das niemand.** `FrageStand` wanderte über die Brücke,
   * und die Oberfläche benutzte davon ausschließlich `gemerkt`: Wie oft
   * jemand eine Frage schon hatte und wie oft davon richtig, stand in der
   * Datenbank und nirgends auf dem Bildschirm — „Warum kommt die schon
   * wieder?“ blieb ohne Antwort. Seither hängt der Frage-Steckbrief daran
   * (`lernen:fragestand`).
   *
   * Eine nie beantwortete Frage liefert einen Nullstand und keinen Fehler;
   * `versuche === 0` ist der Unterschied, den der Steckbrief zeigt.
   */
  frageStand(profilIdRoh: unknown, frageIdRoh: unknown): FrageStand {
    const profilId = this.profilIdPruefen(profilIdRoh);
    const frage = this.fragePruefen(frageIdRoh);
    return Lernstand.zuFrageStand(frage.id, this.standZeile(profilId, frage.id));
  }

  private fragePruefen(wert: unknown): Frage {
    if (typeof wert !== 'string' || wert.length === 0) {
      abweisen('Ungültige Anfrage: Die Frage-ID muss eine nicht-leere Zeichenkette sein.');
    }
    const frage = this.fragen.get(wert);
    if (frage === undefined) {
      abweisen(`Unbekannte Frage-ID: „${entschaerft(wert)}“.`);
    }
    return frage;
  }

  // ── Antworten ────────────────────────────────────────────────────────

  private protokollPruefen(wertRoh: unknown): GepruefterProtokoll {
    const roh = nutzlast(wertRoh, 'Das Antwortprotokoll');
    const frage = this.fragePruefen(roh['frageId']);

    const bewertung = roh['bewertung'];
    if (!istBewertung(bewertung)) {
      abweisen(
        `Ungültige Anfrage: bewertung muss eine von ${BEWERTUNGEN.join(', ')} sein, war aber „${entschaerft(bewertung)}“.`,
      );
    }

    /* Gekappt statt abgewiesen – dieselbe Regel wie in der Prüfung: Eine
       Sitzung, die über Nacht offen blieb, soll nicht ihren gesamten
       Lernfortschritt verlieren. */
    const dauerMs = Math.min(
      MAX_DAUER_MS,
      ganzeZahl(roh['dauerMs'] ?? 0, 'dauerMs', 0, Number.MAX_SAFE_INTEGER),
    );

    // Auswahl: nur Labels, die es bei genau dieser Frage gibt. Doppelte
    // Einträge werden zusammengefasst, die Reihenfolge normalisiert.
    const erlaubteLabels = new Set((frage.optionen ?? []).map((o) => o.label));
    const auswahlRoh = textliste(roh['auswahl'], 'auswahl');
    for (const label of auswahlRoh) {
      if (!erlaubteLabels.has(label)) {
        abweisen(
          `Ungültige Anfrage: „${entschaerft(label)}“ ist keine Antwortoption der Frage „${frage.id}“.`,
        );
      }
    }
    const auswahl = [...new Set(auswahlRoh)].sort();

    const freitextRoh = roh['freitext'];
    if (freitextRoh !== undefined && freitextRoh !== null && typeof freitextRoh !== 'string') {
      abweisen('Ungültige Anfrage: freitext muss eine Zeichenkette sein.');
    }
    const freitext =
      typeof freitextRoh === 'string' ? freitextRoh.slice(0, MAX_FREITEXT_LAENGE) : null;

    // Bei Multiple Choice entscheidet nicht der Renderer, sondern der
    // Katalog: `richtig` wird hier neu berechnet. Bei offenen Fragen gibt
    // es keine maschinelle Wahrheit – dort zählt die Selbsteinschätzung.
    const gemeldetRichtig = roh['richtig'];
    if (typeof gemeldetRichtig !== 'boolean') {
      abweisen('Ungültige Anfrage: richtig muss ein Wahrheitswert sein.');
    }
    const richtig = frage.typ === 'mc' ? bewerteAuswahl(frage, auswahl).richtig : gemeldetRichtig;

    return { frage, auswahl, freitext, richtig, bewertung, dauerMs };
  }

  /**
   * Protokolliert eine Antwort, schreibt den Fragenstand fort und plant die
   * Wiedervorlage. Log-Eintrag und Standfortschreibung gehören zusammen und
   * laufen deshalb in einer Transaktion.
   */
  /**
   * Schreibt einen Eintrag in die Antworthistorie.
   *
   * Getrennt herausgezogen, weil nicht jede protokollierte Antwort die
   * Wiedervorlage fortschreiben darf – siehe {@link protokollieren}.
   */
  /**
   * Schreibt eine Zeile in die Historie.
   *
   * `nurHistorie` steht für Fragen, die in einem Prüfungsbogen standen, aber
   * nie aufgeschlagen wurden. Sie gehören in die Historie, sind aber keine
   * Antwort – die Tagesbilanz zählt sie deshalb nicht mit.
   */
  private logEintrag(
    profilId: number,
    p: GepruefterProtokoll,
    zeitpunkt: string,
    nurHistorie = false,
  ): void {
    this.db
      .prepare<{
        profil_id: number;
        frage_id: string;
        zeitpunkt: string;
        richtig: number;
        bewertung: string;
        dauer_ms: number;
        auswahl: string;
        freitext: string | null;
        nur_historie: number;
      }>(
        `INSERT INTO antwort_log
           (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms, auswahl, freitext,
            nur_historie)
         VALUES
           (@profil_id, @frage_id, @zeitpunkt, @richtig, @bewertung, @dauer_ms, @auswahl,
            @freitext, @nur_historie)`,
      )
      .run({
        profil_id: profilId,
        frage_id: p.frage.id,
        zeitpunkt,
        nur_historie: nurHistorie ? 1 : 0,
        richtig: p.richtig ? 1 : 0,
        bewertung: p.bewertung,
        dauer_ms: p.dauerMs,
        auswahl: JSON.stringify(p.auswahl),
        freitext: p.freitext,
      });
  }

  /**
   * Protokolliert eine Antwort, **ohne** die Wiedervorlage fortzuschreiben.
   *
   * Gedacht für Fragen, die in einer Prüfungssimulation nie aufgeschlagen
   * wurden. Sie gehören in die Historie – der Bogen enthielt sie ja –, dürfen
   * aber den Lernstand nicht zurückstufen: Eine Frage, die viermal sicher
   * gewusst wurde, wäre sonst nach einem abgelaufenen Prüfungslauf wieder
   * sofort fällig, obwohl sie nie gezeigt wurde.
   */
  protokollieren(profilIdRoh: unknown, protokollRoh: unknown): void {
    const profilId = this.profilIdPruefen(profilIdRoh);
    const p = this.protokollPruefen(protokollRoh);
    this.logEintrag(profilId, p, this.jetzt().toISOString(), true);
  }

  antworten(profilIdRoh: unknown, protokollRoh: unknown): FrageStand {
    const profilId = this.profilIdPruefen(profilIdRoh);
    const p = this.protokollPruefen(protokollRoh);

    const jetzt = this.jetzt();
    const zeitpunkt = jetzt.toISOString();

    const schreiben = this.db.transaction(() => {
      this.logEintrag(profilId, p, zeitpunkt);

      const zeile = this.standZeile(profilId, p.frage.id);
      const abstandTage = tageZwischen(zeile?.zuletzt_beantwortet ?? null, jetzt);
      const vorlage = wiedervorlageBerechnen(
        gedaechtnisstandAus(zeile),
        FSRS_GRAD[p.bewertung],
        abstandTage,
        this.tageBisTermin(profilId, jetzt),
      );

      /*
        Der Beleg des Abrufs – die Regel steht in `shared/reife.ts`.

        Drei Fälle, und der dritte ist der, den man leicht übersieht: Eine
        falsche Antwort nimmt den Beleg weg, gleich wie lange die Frage vorher
        saß. Eine richtige Antwort nach mindestens einem Tag setzt ihn. Eine
        richtige Antwort am selben Tag lässt ihn, wie er ist – sie beweist
        nichts über das Behalten, spricht aber auch nicht dagegen.

        Die erste Antwort auf eine Frage hat definitionsgemäß den Abstand 0
        und belegt deshalb nie. Das ist keine Härte, sondern der Punkt:
        Wiedererkennen ist kein Erinnern.

        **Seit 0.22.0 zählt auch die Bewertung mit.** Bis dahin hing der Beleg
        allein an `richtig` — und `richtig` rechnet der Kern aus der Auswahl
        nach, ohne zu wissen, ob jemand die Antwort wusste oder traf. Wer über
        „Ich hatte geraten“ die Selbsteinschätzung nachreicht, sagt genau das:
        angekreuzt war das Richtige, gewusst war es nicht. Ein Beleg dafür
        wäre eine Reifezahl auf einem Zufallstreffer — gemessen an einem
        nachgestellten Rater fiel er an 40 Prozent der Tage zu Unrecht
        (`docs/entscheidung-ratewahrscheinlichkeit.md`). `giltAlsRichtig`
        zieht dieselbe Grenze, nach der auch die Wiedervorlage bucht.
      */
      const belegtDieseAntwort = p.richtig && giltAlsRichtig(p.bewertung);
      const bestaetigt = !belegtDieseAntwort
        ? p.richtig
          ? (zeile?.bestaetigt ?? 0)
          : 0
        : abstandTage >= MINDESTABSTAND_TAGE
          ? 1
          : (zeile?.bestaetigt ?? 0);
      const intervall = vorlage.intervallTage;
      const faelligAb = new Date(jetzt.getTime() + intervall * TAG_MS).toISOString();

      this.db
        .prepare<{
          profil_id: number;
          frage_id: string;
          richtig: number;
          zeitpunkt: string;
          faellig_ab: string;
          bewertung: string;
          intervall: number;
          stabilitaet: number;
          schwierigkeit: number;
          bestaetigt: number;
        }>(
          `INSERT INTO frage_stand
             (profil_id, frage_id, versuche, richtige, zuletzt_beantwortet,
              faellig_ab, gemerkt, letzte_bewertung, intervall_tage,
              stabilitaet, schwierigkeit, bestaetigt)
           VALUES
             (@profil_id, @frage_id, 1, @richtig, @zeitpunkt,
              @faellig_ab, 0, @bewertung, @intervall,
              @stabilitaet, @schwierigkeit, @bestaetigt)
           ON CONFLICT (profil_id, frage_id) DO UPDATE SET
             versuche            = versuche + 1,
             richtige            = richtige + @richtig,
             zuletzt_beantwortet = @zeitpunkt,
             faellig_ab          = @faellig_ab,
             letzte_bewertung    = @bewertung,
             intervall_tage      = @intervall,
             stabilitaet         = @stabilitaet,
             schwierigkeit       = @schwierigkeit,
             bestaetigt          = @bestaetigt`,
        )
        .run({
          profil_id: profilId,
          frage_id: p.frage.id,
          richtig: p.richtig ? 1 : 0,
          zeitpunkt,
          faellig_ab: faelligAb,
          bewertung: p.bewertung,
          intervall,
          stabilitaet: vorlage.stabilitaet,
          schwierigkeit: vorlage.schwierigkeit,
          bestaetigt,
        });
    });
    schreiben();

    return Lernstand.zuFrageStand(p.frage.id, this.standZeile(profilId, p.frage.id));
  }

  // ── Merkliste ────────────────────────────────────────────────────────

  merken(profilIdRoh: unknown, frageIdRoh: unknown, gemerktRoh: unknown): FrageStand {
    const profilId = this.profilIdPruefen(profilIdRoh);
    const frage = this.fragePruefen(frageIdRoh);
    if (typeof gemerktRoh !== 'boolean') {
      abweisen('Ungültige Anfrage: gemerkt muss ein Wahrheitswert sein.');
    }

    this.db
      .prepare<[number, string, number]>(
        `INSERT INTO frage_stand (profil_id, frage_id, gemerkt)
         VALUES (?, ?, ?)
         ON CONFLICT (profil_id, frage_id) DO UPDATE SET gemerkt = excluded.gemerkt`,
      )
      .run(profilId, frage.id, gemerktRoh ? 1 : 0);

    return Lernstand.zuFrageStand(frage.id, this.standZeile(profilId, frage.id));
  }

  // ── Sitzung ──────────────────────────────────────────────────────────

  private filterPruefen(wertRoh: unknown): GepruefterFilter {
    const roh = wertRoh === undefined || wertRoh === null ? {} : nutzlast(wertRoh, 'Der Filter');

    const kapitel = textliste(roh['kapitel'], 'kapitel');
    for (const id of kapitel) {
      if (!this.kapitelIds.has(id)) {
        abweisen(`Unbekanntes Kapitel: „${entschaerft(id)}“.`);
      }
    }

    const abschnitte = textliste(roh['abschnitte'], 'abschnitte');
    for (const id of abschnitte) {
      if (!this.abschnittIds.has(id)) {
        abweisen(`Unbekannter Abschnitt: „${entschaerft(id)}“.`);
      }
    }

    // `anzahl` ist laut Vertrag eine Höchstzahl. Fehlt sie, wird nichts
    // abgeschnitten – die Sitzung umfasst dann alle passenden Fragen.
    const anzahlRoh = roh['anzahl'];
    const anzahl =
      anzahlRoh === undefined || anzahlRoh === null
        ? this.katalog.fragen.length
        : ganzeZahl(anzahlRoh, 'anzahl', 1, MAX_SITZUNGSGROESSE);

    const mischen = wahrheitswert(roh['mischen'], 'mischen', true);

    return {
      kapitel,
      abschnitte,
      nurGemerkte: wahrheitswert(roh['nurGemerkte'], 'nurGemerkte', false),
      nurFehler: wahrheitswert(roh['nurFehler'], 'nurFehler', false),
      nurHartnaeckige: wahrheitswert(roh['nurHartnaeckige'], 'nurHartnaeckige', false),
      nurNeue: wahrheitswert(roh['nurNeue'], 'nurNeue', false),
      nurOffene: wahrheitswert(roh['nurOffene'], 'nurOffene', false),
      anzahl,
      mischen,
      /* Ohne eigene Angabe bleiben die Optionen in Katalogreihenfolge – und
         zwar unabhängig von `mischen`. Die frühere Kopplung an die
         Fragenreihenfolge war die eigentliche Ursache des Problems: Wer nur
         „Fragen mischen" sagte, mischte die Antworten stillschweigend mit.
         Beides leistet Unterschiedliches. Die Fragenreihenfolge zu mischen
         ist folgenlos; die Optionen zu mischen kostet den Gleichlauf mit dem
         amtlichen Katalog, in dem der Lernende nachschlägt, und hat keinen
         belegten Lernnutzen. Das darf nur auf ausdrücklichen Wunsch
         geschehen (siehe `docs/entscheidung-antwortreihenfolge.md`). */
      optionenMischen: wahrheitswert(roh['optionenMischen'], 'optionenMischen', false),
    };
  }

  private optionsReihenfolge(frage: Frage, mischen: boolean): string[] {
    const labels = (frage.optionen ?? []).map((o) => o.label);
    return mischen ? gemischt(labels, this.zufall) : labels;
  }

  /**
   * Stellt die Fragen einer Lernsitzung zusammen.
   *
   * Reihenfolge in vier Gruppen: zuerst die fälligen Wiederholungen, dann
   * neue Fragen bis zur Einführungsrate des Tages, dann bereits beantwortete,
   * noch nicht fällige Fragen, ganz hinten die übrigen neuen. Innerhalb jeder
   * Gruppe entscheidet `mischen` zwischen Zufall und Katalogreihenfolge.
   *
   * Die Rate ist **dieselbe Rechnung** wie das „neu“ im angezeigten
   * Tagespensum ({@link einfuehrungsrate}, gerechnet über den ganzen
   * Lernumfang, nicht über den gefilterten Ausschnitt – sonst wäre sie eine
   * zweite, andere Zahl): Was der Einstieg „9 neue“ nennt, ist auch das, was
   * eine Sitzung höchstens an Neuem vorlegt. Ohne Prüfungstermin liegt die
   * Rate beim Sitzungsumfang – für die übliche 20er-Sitzung also kein
   * Deckel, das bisherige Verhalten.
   *
   * Der Deckel ordnet, er versteckt nicht: Überzählige neue Fragen stehen am
   * Ende statt zu fehlen. Eine Anfrage ohne `anzahl` umfasst damit weiterhin
   * alle passenden Fragen, und wer ausdrücklich „nur Neue“ wählt, bekommt
   * sie auch am Prüfungstag – dort ist die Rate 0, und alles Neue steht in
   * der letzten Gruppe.
   *
   * Kapitel- und Abschnittsfilter wirken additiv (UND): sind beide gesetzt,
   * muss eine Frage beide Bedingungen erfüllen.
   */
  sitzung(profilIdRoh: unknown, filterRoh: unknown): SitzungsFrage[] {
    const profilId = this.profilIdPruefen(profilIdRoh);
    const filter = this.filterPruefen(filterRoh);

    const stand = this.standKarte(profilId);
    const letzteAntwort = this.letzteAntwortKarte(profilId);
    /* Nur geholt, wenn wirklich danach gefiltert wird – die Abfrage geht
       über das ganze Protokoll. */
    const hartnaeckigeIds = filter.nurHartnaeckige
      ? new Set(this.hartnaeckige(profilId).map((eintrag) => eintrag.frageId))
      : new Set<string>();
    const jetzt = this.jetzt();
    const jetztIso = jetzt.toISOString();

    const faellige: Frage[] = [];
    const neue: Frage[] = [];
    const rest: Frage[] = [];

    /* Die Quelle ist der Lernumfang des Profils, nicht der ganze Katalog –
       damit sind Weiterlernen, Kapitelwahl, „nur Fehler“, „nur Gemerkte“ und
       „nur Neue“ mit einem Eingriff richtig. Besonders „nur Fehler“ braucht
       das: Seine Quelle ist `antwort_log`, und dort stehen auch Antworten aus
       Prüfungsläufen, die Kapitel IV enthielten. Ohne die Vorfilterung legte
       ausgerechnet dieser Weg die abgewählten Fragen wieder vor. */
    for (const frage of this.lernfragen(profilId)) {
      if (filter.kapitel.length > 0 && !filter.kapitel.includes(frage.kapitel)) {
        continue;
      }
      if (
        filter.abschnitte.length > 0 &&
        (frage.abschnitt === null || !filter.abschnitte.includes(frage.abschnitt))
      ) {
        continue;
      }

      const zeile = stand.get(frage.id);
      const versuche = zeile?.versuche ?? 0;

      if (filter.nurGemerkte && (zeile?.gemerkt ?? 0) !== 1) {
        continue;
      }
      if (filter.nurNeue && versuche > 0) {
        continue;
      }
      if (filter.nurFehler && letzteAntwort.get(frage.id) !== false) {
        continue;
      }
      /* Neben „nur Fehler“ und nicht an seiner Stelle: Jener fragt die
         letzte Antwort, dieser die Historie. Eine Frage, die viermal
         durchfiel und gestern zufällig saß, steht nur hier. */
      if (filter.nurHartnaeckige && !hartnaeckigeIds.has(frage.id)) {
        continue;
      }
      /* Als einziger Filter aus dem Katalog statt aus dem Lernstand – der
         Fragetyp ist eine Eigenschaft der Frage, keine des Lernenden. */
      if (filter.nurOffene && frage.typ === 'mc') {
        continue;
      }

      const faellig = zeile?.faellig_ab != null && zeile.faellig_ab <= jetztIso;
      if (versuche === 0) {
        neue.push(frage);
      } else if (faellig) {
        faellige.push(frage);
      } else {
        rest.push(frage);
      }
    }

    /* Die Rate rechnet über den ganzen Lernumfang – dieselbe Grundmenge, aus
       der `lernplan()` das angezeigte Pensum bildet. Sie ist bewusst keine
       Tagesmenge mit Gedächtnis, sondern wird je Sitzung neu gebildet; das
       ist dieselbe Entscheidung, die der Einstieg als „Rate, die sich
       nachfüllt“ dokumentiert (`Einstieg.tsx`). */
    let nieBeantwortetGesamt = 0;
    for (const frage of this.lernfragen(profilId)) {
      if ((stand.get(frage.id)?.versuche ?? 0) === 0) {
        nieBeantwortetGesamt += 1;
      }
    }
    const rate = einfuehrungsrate(nieBeantwortetGesamt, this.tageBisTermin(profilId, jetzt));

    // Innerhalb der Gruppen mischen, nie über die Gruppengrenze hinweg:
    // Fälliges bleibt vor Neuem, Neues im Pensum vor dem Rest.
    const geordnet = (gruppe: Frage[]): Frage[] =>
      filter.mischen ? gemischt(gruppe, this.zufall) : gruppe;
    const neueGeordnet = geordnet(neue);
    const reihenfolge = [
      ...geordnet(faellige),
      ...neueGeordnet.slice(0, rate),
      ...geordnet(rest),
      ...neueGeordnet.slice(rate),
    ];

    /* Nur holen, wenn die Sitzung überhaupt eine offene Frage enthält –
       sonst kostet die Abfrage etwas für nichts. */
    const gewaehlt = reihenfolge.slice(0, filter.anzahl);
    const freitexte = gewaehlt.some((frage) => frage.typ !== 'mc')
      ? this.letzterFreitextKarte(profilId)
      : new Map<string, LetzterFreitext>();

    return gewaehlt.map((frage) => {
      const letzter = frage.typ === 'mc' ? undefined : freitexte.get(frage.id);
      return {
        frageId: frage.id,
        optionsReihenfolge: this.optionsReihenfolge(frage, filter.optionenMischen),
        /* Aus derselben Karte, aus der 80 Zeilen weiter oben der Filter
           „nur Gemerkte“ liest. Ohne diese Angabe begann die Oberfläche jede
           Sitzung mit einer leeren Merkliste. */
        gemerkt: (stand.get(frage.id)?.gemerkt ?? 0) === 1,
        /* `exactOptionalPropertyTypes`: das Feld nur setzen, wenn es wirklich
           einen Wert hat – und nur bei offenen Fragen. */
        ...(letzter === undefined ? {} : { letzterFreitext: letzter }),
      };
    });
  }

  // ── Übersicht ────────────────────────────────────────────────────────

  private tagesbilanz(profilId: number): TagesbilanzZeile {
    const jetzt = this.jetzt();
    // Kalendertag in der Zeitzone des Geräts – gespeichert wird UTC, deshalb
    // werden die Grenzen umgerechnet.
    const { von, bis } = tagesgrenzen(jetzt);

    const zeile = this.db
      .prepare<[number, string, string], TagesbilanzZeile>(
        `SELECT
           COALESCE(SUM(CASE WHEN richtig = 1 THEN 1 ELSE 0 END), 0) AS richtig,
           COALESCE(SUM(CASE WHEN richtig = 0 THEN 1 ELSE 0 END), 0) AS falsch
         FROM antwort_log
        WHERE profil_id = ? AND zeitpunkt >= ? AND zeitpunkt < ?
          AND nur_historie = 0`,
      )
      .get(profilId, von, bis);

    return zeile ?? { richtig: 0, falsch: 0 };
  }

  /**
   * Bereichsstatistik: in Kapiteln mit Abschnitten je Abschnitt, sonst je
   * Kapitel. Die Reihenfolge folgt dem Katalog.
   *
   * Rechnet aus derselben Zeilenform wie Gesamtzahl und Lernplan. Die
   * Bereichswerte und der Gesamtwert sind damit nicht bloß aufeinander
   * abgestimmt, sondern dieselbe Summe, nur anders gruppiert.
   */
  private bereiche(
    fragen: readonly PlanFrage[],
    stand: ReadonlyMap<string, StandZeile>,
    idsJeBereich: ReadonlyMap<string, readonly string[]>,
  ): BereichStatistik[] {
    const titel = new Map<string, string>();
    for (const kapitel of this.katalog.kapitel) {
      titel.set(kapitel.id, kapitel.titel);
      for (const abschnitt of kapitel.abschnitte) {
        titel.set(abschnitt.id, abschnitt.titel);
      }
    }

    const eimer = new Map<string, PlanFrage[]>();
    /* Ein abgewähltes Kapitel verschwindet aus der Aufschlüsselung, statt
       mit 0 dazustehen: Sonst summierten sich die Bereiche zu 575, während
       `fragenGesamt` 486 sagt – zwei Zahlen auf einem Bildschirm, die
       einander widersprechen. */
    for (const frage of fragen) {
      const liste = eimer.get(frage.bereich);
      if (liste === undefined) {
        eimer.set(frage.bereich, [frage]);
      } else {
        liste.push(frage);
      }
    }

    return [...eimer.entries()].map(([id, gruppe]) => {
      const ids = idsJeBereich.get(id) ?? [];
      const beantwortet = ids.filter((frageId) => (stand.get(frageId)?.versuche ?? 0) > 0).length;
      const reifegrad = reifegradVon(gruppe);
      const belegt = belegteFragen(reifegrad, gruppe.length);
      return {
        id,
        titel: titel.get(id) ?? id,
        fragenGesamt: gruppe.length,
        beantwortet,
        belegt,
        reifegrad,
        stufe: stufeFuer(belegt, gruppe.length),
      };
    });
  }

  /**
   * Die gemeinsame Zeilenform für Lernplan **und** Reifegrad.
   *
   * Es gibt sie genau einmal, und das ist der Kern dieses Schrittes: Vorher
   * baute `lernplan()` seine Sicht auf den Lernstand und `uebersicht()` eine
   * zweite, die etwas anderes bedeutete. Zwei Zahlen auf einem Bildschirm,
   * die dasselbe zu sagen schienen und es nicht taten – der Befund in
   * `docs/stand.md` 7.1.
   */
  private reifefragen(
    profilId: number,
    stand: ReadonlyMap<string, StandZeile>,
    jetzt: Date,
  ): readonly PlanFrage[] {
    return this.lernfragen(profilId).map((frage) => {
      const zeile = stand.get(frage.id);
      const beantwortet = zeile?.zuletzt_beantwortet ?? null;
      return {
        bereich: frage.abschnitt ?? frage.kapitel,
        stabilitaet: beantwortet === null ? null : (zeile?.stabilitaet ?? null),
        tageSeitAntwort: tageZwischen(beantwortet, jetzt),
        bestaetigt: (zeile?.bestaetigt ?? 0) === 1,
        /* Nie beantwortete Fragen sind nicht „fällig“, sondern neu. Sonst
           stünden sie in beiden Zahlen des Pensums. */
        faellig:
          beantwortet !== null &&
          zeile?.faellig_ab != null &&
          Date.parse(zeile.faellig_ab) <= jetzt.getTime(),
        /* Für die Arbeitslast-Vorschau: an welchem Tag diese Frage ansteht.
           `null` für nie beantwortete – sie haben keinen Termin, sondern
           warten auf die Einführungsrate.

           In **Kalendertagen**, nicht in Vierundzwanzig-Stunden-Blöcken: Die
           Vorschau beschriftet die Werte als „heute“, „morgen“ und danach mit
           Wochentag und Datum (`Lernplanung.tsx`). Bis Fassung 0.24.1 stand
           hier `Math.ceil(differenz / TAG_MS)`, und wer abends lernte und
           morgens plante, sah jeden Tag die Last des Vortages: Eine Montag um
           20 Uhr beantwortete Frage mit Intervall 1 wird Dienstag um 20 Uhr
           fällig – am Dienstagmorgen ergab die alte Rechnung `ceil(12/24) = 1`
           und stellte sie unter „morgen“. Dieselbe Regel wie in
           `kalendertageSeit()`. */
        faelligInTagen:
          beantwortet === null || zeile?.faellig_ab == null
            ? null
            : kalendertageBis(new Date(Date.parse(zeile.faellig_ab)), jetzt),
      };
    });
  }

  // ── Lernplan ─────────────────────────────────────────────────────────

  /**
   * Mittlere Bearbeitungsdauer je Frage in Sekunden.
   *
   * Der **Median** der letzten Antworten, nicht das arithmetische Mittel:
   * Eine einzige Sitzung, bei der jemand zwischendurch Kaffee holt, würde
   * einen Mittelwert um Minuten verschieben und die Zeitschätzung unbrauchbar
   * machen. Der Median stört sich daran nicht.
   *
   * `null`, solange zu wenige Antworten vorliegen – dann greift der
   * Vorgabewert aus `shared/lernplan.ts` statt einer Schätzung aus drei
   * Datenpunkten.
   *
   * **Ohne die Historienzeilen.** Ein abgelaufener Prüfungsbogen schreibt für
   * jede nie aufgeschlagene Frage `dauer_ms = Gesamtdauer / Fragenzahl`
   * (`pruefung.ts`) – eine gleichmäßig verteilte Rechengröße für etwas, das
   * niemand gelesen hat. Nach einem 80-Fragen-Bogen mit 60 ungesehenen Fragen
   * bestanden bis Fassung 0.24.1 sechzig der zweihundert Stichprobenwerte
   * daraus, und der Median – und mit ihm die Zeitschätzung des Tagespensums –
   * verschob sich auf Zahlen, die keine gemessene Bearbeitungszeit sind.
   */
  private sekundenProFrage(profilId: number): number | null {
    const zeilen = this.db
      .prepare<[number, number], { dauer_ms: number }>(
        `SELECT dauer_ms FROM antwort_log
          WHERE profil_id = ? AND dauer_ms > 0 AND nur_historie = 0
          ORDER BY id DESC LIMIT ?`,
      )
      .all(profilId, TEMPO_STICHPROBE);

    if (zeilen.length < TEMPO_MINDESTZAHL) {
      return null;
    }

    const sortiert = zeilen.map((z) => z.dauer_ms).sort((a, b) => a - b);
    const mitte = Math.floor(sortiert.length / 2);
    const median =
      sortiert.length % 2 === 1
        ? (sortiert[mitte] ?? 0)
        : ((sortiert[mitte - 1] ?? 0) + (sortiert[mitte] ?? 0)) / 2;

    return median > 0 ? median / 1000 : null;
  }

  /**
   * Stellt den Lernplan zusammen: Prognose, Tagespensum, Machbarkeit.
   *
   * Der Lernstand liefert hier ausschließlich Fakten aus der Datenbank; die
   * Bewertung findet in `shared/lernplan.ts` statt und ist dort ohne
   * Datenbank prüfbar.
   */
  lernplan(profilIdRoh: unknown): Lernplan {
    const profilId = this.profilIdPruefen(profilIdRoh);
    const jetzt = this.jetzt();

    return lernplanBerechnen({
      termin: this.profilLesen(profilId).pruefungstermin,
      tageBisTermin: this.tageBisTermin(profilId, jetzt),
      fragen: this.reifefragen(profilId, this.standKarte(profilId), jetzt),
      sekundenProFrage: this.sekundenProFrage(profilId),
    });
  }

  uebersicht(profilIdRoh: unknown): Lernuebersicht {
    const profilId = this.profilIdPruefen(profilIdRoh);
    return this.uebersichtIntern(profilId);
  }

  private uebersichtIntern(profilId: number): Lernuebersicht {
    const stand = this.standKarte(profilId);
    const jetzt = this.jetzt();
    const jetztIso = jetzt.toISOString();
    const lernfragen = this.lernfragen(profilId);

    let beantwortet = 0;
    let faellig = 0;
    let gemerkt = 0;
    let heuteBearbeitet = 0;
    let juengste = 0;

    /* Dieselben lokalen Mitternachtsgrenzen wie in `tagesbilanz()`. Ein Tag
       ist der Kalendertag des Nutzers, nicht der von UTC – wer um ein Uhr
       nachts lernt, lernt an dem Tag, der auf seiner Uhr steht. */
    const tagesbeginn = tagesgrenzen(jetzt).von;

    const idsJeBereich = new Map<string, string[]>();
    for (const frage of lernfragen) {
      const bereich = frage.abschnitt ?? frage.kapitel;
      const liste = idsJeBereich.get(bereich);
      if (liste === undefined) {
        idsJeBereich.set(bereich, [frage.id]);
      } else {
        liste.push(frage.id);
      }

      const zeile = stand.get(frage.id);
      if (zeile === undefined) {
        continue;
      }
      if (zeile.versuche > 0) {
        beantwortet += 1;
        if (zeile.faellig_ab != null && zeile.faellig_ab <= jetztIso) {
          faellig += 1;
        }
      }
      if (zeile.gemerkt === 1) {
        gemerkt += 1;
      }
      if (zeile.zuletzt_beantwortet !== null) {
        if (zeile.zuletzt_beantwortet >= tagesbeginn) {
          heuteBearbeitet += 1;
        }
        const zeitpunkt = Date.parse(zeile.zuletzt_beantwortet);
        if (!Number.isNaN(zeitpunkt) && zeitpunkt > juengste) {
          juengste = zeitpunkt;
        }
      }
    }

    const fragen = this.reifefragen(profilId, stand, jetzt);
    const letzteAntwort = this.letzteAntwortKarte(profilId);
    const reifegrad = reifegradVon(fragen);
    const belegt = belegteFragen(reifegrad, fragen.length);
    const bereiche = this.bereiche(fragen, stand, idsJeBereich);
    const bilanz = this.tagesbilanz(profilId);

    /* Manche Prüfungsordnungen lassen in Notwehr und Notstand höchstens zwei
       Fehler zu, gleich wie gut der Rest ist. Wer insgesamt gut dasteht und
       dort zurückliegt, fällt sicher durch – eine Ampel namens
       „Prüfungsreife“, die das verschweigt, wäre gefährlicher als gar keine.
       Deshalb deckelt der schwächste K.-o.-Bereich das Gesamturteil, und die
       Oberfläche bekommt seinen Namen mit, statt nur eine Stufe tiefer zu
       zeigen. */
    const koBereiche = bereiche.filter((bereich) => KO_BEREICHE.includes(bereich.id));

    return {
      fragenGesamt: fragen.length,
      beantwortet,
      belegt,
      reifegrad,
      stufe: gesamtstufeMitDeckel(
        stufeFuer(belegt, fragen.length),
        koBereiche.map((bereich) => bereich.stufe),
      ),
      deckelnd: koBereiche.filter((b) => b.stufe !== 'reif').map((b) => `${b.id} – ${b.titel}`),
      faellig,
      gemerkt,
      offen: this.lernfragen(profilId).filter((frage) => frage.typ !== 'mc').length,
      /* Aus derselben Karte wie der Filter „nur Fehler“, damit Einstieg,
         Zahl und gedrucktes Protokoll nicht auseinanderlaufen können. */
      fehler: this.lernfragen(profilId).filter((frage) => letzteAntwort.get(frage.id) === false)
        .length,
      heuteRichtig: bilanz.richtig,
      heuteFalsch: bilanz.falsch,
      heuteBearbeitet,
      tageSeitLetzterAntwort: juengste === 0 ? null : kalendertageSeit(new Date(juengste), jetzt),
      behaltensquote: this.behaltensquote(profilId),
      bereiche,
    };
  }

  // ── Zurücksetzen ─────────────────────────────────────────────────────

  /**
   * Löscht den Lernstand – wahlweise nur den eines Kapitels.
   *
   * Bewusst ohne jede Begrenzung: Zurücksetzen ist eine legitime Handlung
   * und darf beliebig oft geschehen. Historie und Stand werden gemeinsam
   * entfernt, damit keine widersprüchlichen Daten zurückbleiben.
   *
   * **Ein Unterschied zwischen beiden Wegen.** Vollständig heißt vollständig:
   * Antworten, Fortschritt, Merkliste, Prüfungsverlauf. Kapitelweise bleibt
   * die Merkliste stehen. Wer eine Frage als schwer markiert hat, will sie
   * wiederfinden – gerade dann, wenn er das Kapitel noch einmal von vorn
   * lernt. Dass beides in derselben Tabelle steht, ist eine Eigenheit der
   * Speicherung und darf keine Bedeutung bekommen.
   */
  zuruecksetzen(profilIdRoh: unknown, kapitelRoh: unknown): Lernuebersicht {
    const profilId = this.profilIdPruefen(profilIdRoh);

    let frageIds: string[] | null = null;
    if (kapitelRoh !== undefined && kapitelRoh !== null) {
      if (typeof kapitelRoh !== 'string') {
        abweisen('Ungültige Anfrage: kapitel muss eine Zeichenkette oder null sein.');
      }
      if (!this.kapitelIds.has(kapitelRoh)) {
        abweisen(`Unbekanntes Kapitel: „${entschaerft(kapitelRoh)}“.`);
      }
      frageIds = this.katalog.fragen.filter((f) => f.kapitel === kapitelRoh).map((f) => f.id);
    }

    const loeschen = this.db.transaction(() => {
      if (frageIds === null) {
        this.db.prepare<[number]>('DELETE FROM antwort_log WHERE profil_id = ?').run(profilId);
        this.db.prepare<[number]>('DELETE FROM frage_stand WHERE profil_id = ?').run(profilId);
        /* Auch der Prüfungsverlauf. Wer von vorn anfangen will, meint von
           vorn – alte Simulationsergebnisse stünden sonst weiter in der
           Auswertung und im Lernbericht, während der Lernstand bei null ist.
           Beim Zurücksetzen eines einzelnen Kapitels bleibt er dagegen: Ein
           Lauf geht über den ganzen Bogen und lässt sich nicht kapitelweise
           herausrechnen. */
        this.db.prepare<[number]>('DELETE FROM pruefung_lauf WHERE profil_id = ?').run(profilId);
        /* Auch ein unterbrochener Bogen. „Von vorn" und „aber die halb
           bearbeitete Prüfung von gestern liegt noch da" passen nicht
           zusammen; beim nächsten Start böte die Anwendung sie sonst zum
           Fortsetzen an, während der Lernstand bei null steht. Kapitelweise
           bleibt sie stehen – ein Bogen geht über den ganzen Katalog und
           lässt sich nicht kapitelweise herausrechnen. */
        this.db.prepare<[number]>('DELETE FROM pruefung_offen WHERE profil_id = ?').run(profilId);
        return;
      }

      // In Stapeln, damit die Zahl der gebundenen Parameter klein bleibt.
      for (let i = 0; i < frageIds.length; i += STAPELGROESSE) {
        const stapel = frageIds.slice(i, i + STAPELGROESSE);
        const platzhalter = stapel.map(() => '?').join(', ');
        this.db
          .prepare(`DELETE FROM antwort_log WHERE profil_id = ? AND frage_id IN (${platzhalter})`)
          .run(profilId, ...stapel);

        /*
          Zwei Anweisungen statt einer, weil `gemerkt` in derselben Tabelle
          steht wie der Fortschritt. Was nicht gemerkt ist, fällt ganz weg;
          was gemerkt ist, bleibt stehen und wird auf null gesetzt.

          `gemerkt` ist NOT NULL mit CHECK (0, 1) – die beiden Bedingungen
          decken also jede Zeile ab, keine bleibt ungenullt zurück.

          Was danach steht, ist kein neuer Zustand: Genau diese Zeile legt
          `merken()` an, wenn jemand eine nie beantwortete Frage markiert.
          Jeder Verbraucher kennt sie deshalb längst.

          Die SET-Liste nennt jede Spalte von `frage_stand` außer den
          Schlüsseln und dem absichtlich erhaltenen `gemerkt`. Kommt eine
          Spalte hinzu, gehört sie hierher – der Test „zurückgesetzt sieht
          aus wie nie beantwortet" vergleicht mit SELECT * und merkt es.
        */
        this.db
          .prepare(
            `DELETE FROM frage_stand
              WHERE profil_id = ? AND gemerkt = 0 AND frage_id IN (${platzhalter})`,
          )
          .run(profilId, ...stapel);
        this.db
          .prepare(
            `UPDATE frage_stand
                SET versuche            = 0,
                    richtige            = 0,
                    zuletzt_beantwortet = NULL,
                    faellig_ab          = NULL,
                    letzte_bewertung    = NULL,
                    intervall_tage      = 0,
                    stabilitaet         = NULL,
                    schwierigkeit       = NULL,
                    bestaetigt          = 0
              WHERE profil_id = ? AND gemerkt = 1 AND frage_id IN (${platzhalter})`,
          )
          .run(profilId, ...stapel);
      }
    });
    loeschen();

    return this.uebersichtIntern(profilId);
  }
}

// ─── Instanz für den Main-Prozess ───────────────────────────────────────────

let instanz: Lernstand | null = null;

/**
 * Der Konstruktor von better-sqlite3.
 *
 * Absichtlich `require` statt eines statischen Imports – wie in
 * `datenbank.ts`: better-sqlite3 ist ein natives CommonJS-Modul, das nicht
 * mitgebündelt wird, und ein Ladefehler soll beim ersten Zugriff auftreten
 * und nicht schon beim Start des Main-Prozesses.
 *
 * Herausgegeben, weil die Sicherung denselben Konstruktor braucht: Sie öffnet
 * fremde Dateien nur lesend. Zweimal geladen wäre es zweimal dasselbe native
 * Modul – und die Sicherung müsste die Begründung oben wiederholen.
 */
export function datenbankKonstruktor(): DatenbankKonstruktor {
  // eslint-disable-next-line @typescript-eslint/no-require-imports
  return require('better-sqlite3') as DatenbankKonstruktor;
}

function datenbankOeffnen(pfad: string): BetterSqlite3.Database {
  mkdirSync(dirname(pfad), { recursive: true });
  return new (datenbankKonstruktor())(pfad);
}

/**
 * Öffnet den Lernstand einmalig und liefert danach dieselbe Instanz.
 * Der Pfad kommt vom Aufrufer, damit dieses Modul Electron nicht kennen muss.
 */
export function lernstandInstanz(datenbankPfad: string, katalog: Katalog): Lernstand {
  if (instanz !== null) {
    return instanz;
  }

  /* Scheitert der Konstruktor – etwa bei einem Lernstand aus einer neueren
     Programmversion –, muss die eben geöffnete Verbindung wieder zu. Sonst
     bliebe bei jedem weiteren Versuch ein Handle liegen, und die Oberfläche
     versucht es nach jeder Sitzung erneut. */
  const datenbank = datenbankOeffnen(datenbankPfad);
  try {
    instanz = new Lernstand(datenbank, katalog);
  } catch (fehler: unknown) {
    datenbank.close();
    throw fehler;
  }

  return instanz;
}

/**
 * Der bereits geöffnete Lernstand, oder `null`.
 *
 * Ausdrücklich ohne zu öffnen: Wer das Programm startet und gleich wieder
 * beendet, soll keine Datenbank anlegen lassen, nur damit beim Herunterfahren
 * jemand nachsieht, ob eine da ist.
 */
export function lernstandOffen(): Lernstand | null {
  return instanz;
}

/** Schließt den Lernstand – beim Beenden der Anwendung und in Tests. */
export function lernstandSchliessen(): void {
  instanz?.schliessen();
  instanz = null;
}

/**
 * Legt eine beschädigte Lernstandsdatei beiseite und gibt ihren neuen Namen
 * zurück.
 *
 * **Beiseitelegen, nicht löschen.** Aus einer beschädigten SQLite-Datei ist
 * oft noch etwas zu holen – mit `.recover` der Kommandozeile, notfalls von
 * fremder Hand. Gelöscht ist sie dagegen endgültig weg, und der Lernstand ist
 * das Einzige, was der Anwendung anvertraut wurde. Der Name trägt einen
 * Zeitstempel, damit ein zweiter Fall den ersten nicht überschreibt.
 *
 * Die Nebendateien `-wal` und `-shm` wandern mit: Bleiben sie liegen, findet
 * die frisch angelegte Datenbank ein Schreibprotokoll vor, das nicht zu ihr
 * gehört – derselbe Fehler, den `aufraeumen()` beim Einspielen schon einmal
 * gemacht hat (docs/stand.md 7.8).
 */
export function lernstandBeiseitelegen(pfad: string, jetzt: Date = new Date()): string {
  const stempel = jetzt.toISOString().replace(/[:.]/gu, '-');
  const ziel = `${pfad}.beschaedigt-${stempel}`;

  renameSync(pfad, ziel);
  for (const anhang of ['-wal', '-shm']) {
    if (existsSync(`${pfad}${anhang}`)) {
      renameSync(`${pfad}${anhang}`, `${ziel}${anhang}`);
    }
  }

  return ziel;
}

/**
 * Anfang und Ende des Kalendertages, in dem `jetzt` liegt – als ISO-Zeit.
 *
 * Ein Tag ist der Kalendertag des Nutzers, nicht der von UTC: Wer um ein Uhr
 * nachts lernt, lernt an dem Tag, der auf seiner Uhr steht. `new Date(Jahr,
 * Monat, Tag)` baut örtliche Mitternacht, `toISOString()` rechnet sie in die
 * Form um, in der die Zeitpunkte gespeichert sind.
 */
function tagesgrenzen(jetzt: Date): { von: string; bis: string } {
  return {
    von: new Date(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate()).toISOString(),
    bis: new Date(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate() + 1).toISOString(),
  };
}

/**
 * Volle Kalendertage zwischen zwei Zeitpunkten.
 *
 * Über Kalendertage und nicht über Stunden: „gestern“ soll auch dann
 * „gestern“ heissen, wenn zwischen beiden Zeitpunkten dreissig Stunden
 * liegen. Wer gestern abend und heute früh lernt, hat nicht zwei Tage
 * Abstand.
 */
function kalendertageSeit(frueher: Date, jetzt: Date): number {
  const a = new Date(frueher.getFullYear(), frueher.getMonth(), frueher.getDate()).getTime();
  const b = new Date(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate()).getTime();
  return Math.max(0, Math.round((b - a) / TAG_MS));
}

/**
 * Kalendertage bis zu einem künftigen Termin – die Gegenrichtung.
 *
 * Getrennt von {@link kalendertageSeit}, weil beide Enden gedeckelt sind: Ein
 * Termin in der Vergangenheit ist „heute“ (0) und nicht „minus drei“.
 */
function kalendertageBis(termin: Date, jetzt: Date): number {
  const a = new Date(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate()).getTime();
  const b = new Date(termin.getFullYear(), termin.getMonth(), termin.getDate()).getTime();
  return Math.max(0, Math.round((b - a) / TAG_MS));
}
