import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

import { istMehrfachauswahl, type Frage } from '../../../shared/katalog';
import type {
  GesicherteEingabe,
  OffenerLauf,
  Pruefungsantwort,
  Pruefungsauftrag,
  Pruefungsprofil,
} from '../../../shared/pruefung';
import { istAuswahlfrage } from '../lernen/bewertung';
import { fehlerMeldung, kanalFehltMeldung, lernBruecke } from '../lernen/bruecke';
import { reihenfolgeAnwenden } from '../lernen/sitzungsaufbau';
import { wirksameZeitMinuten } from '../pruefung/auftrag';
import { ergebnisBerechnen } from '../pruefung/bewertung';
import { bogenAusKatalog } from '../pruefung/bogen';
import type { Bogenfrage, Pruefungsabschluss } from '../pruefung/typen';
import { restzeitText, ZEITWARNUNG_MS } from '../pruefung/zeit';
import type { KatalogIndex } from './useKatalog';
import { useRestzeit } from './useRestzeit';

/**
 * Ablaufphase eines Laufs.
 * `nachbewertung` gibt es nur, wenn der Bogen offene Fragen enthält: die
 * bewertet in der echten Prüfung der Ausschuss, hier der Prüfling selbst –
 * aber erst nach der Abgabe, nie währenddessen.
 */
export type Pruefungsphase = 'bearbeiten' | 'abgabefrage' | 'nachbewertung' | 'auswerten';

/** Eingaben zu einer Frage; im Bogen immer vollständig, damit sie änderbar bleiben. */
interface Eingabe {
  readonly auswahl: readonly string[];
  readonly freitext: string;
  /** Selbstbewertung offener Fragen; `null`, solange nicht bewertet. */
  readonly selbst: boolean | null;
}

const LEERE_EINGABE: Eingabe = Object.freeze({ auswahl: [], freitext: '', selbst: null });

/** Wartezeit nach der letzten Änderung, bevor gesichert wird. */
const ENTPRELLUNG_MS = 800;

/**
 * Abstand des Herzschlags.
 *
 * Er deckelt, wie viel Zeit eine Unterbrechung höchstens verschenken kann.
 * Zwanzig Sekunden sind wenig genug, dass es niemandem nützt, und selten
 * genug, dass es die Datei nicht belastet: In zwei Stunden sind das 360
 * schlanke Schreibvorgänge.
 */
const HERZSCHLAG_MS = 20_000;
const LEERER_BOGEN: readonly Bogenfrage[] = Object.freeze([]);
const KEINE_WARNUNGEN: readonly string[] = Object.freeze([]);

const KEINE_FRAGEN =
  'Zu diesem Prüfungsprofil lassen sich zurzeit keine Fragen zusammenstellen. Wählen Sie ein anderes Profil oder beziehen Sie Kapitel IV wieder ein.';

type Laden =
  | { readonly art: 'laedt' }
  | {
      readonly art: 'bereit';
      readonly fragen: readonly Bogenfrage[];
      /** Warnung, obwohl geprüft werden kann – etwa fehlender Anwendungskern. */
      readonly hinweis: string | null;
      /**
       * Abweichungen beim Zusammenstellen, unverändert aus dem Anwendungskern.
       *
       * Er formuliert sie fertig aus (`Pruefungsbogen.warnungen`); hier wird
       * nichts nachgedichtet. Leer, wenn der Bogen genau den Vorgaben folgt.
       */
      readonly warnungen: readonly string[];
    }
  | { readonly art: 'leer'; readonly meldung: string }
  | { readonly art: 'fehler'; readonly meldung: string };

export interface PruefungSteuerung {
  readonly ladezustand: Laden['art'];
  readonly meldung: string | null;
  readonly hinweis: string | null;
  /** Abweichungen beim Zusammenstellen des Bogens; leer, wenn es keine gab. */
  readonly warnungen: readonly string[];
  readonly phase: Pruefungsphase;
  readonly fragen: readonly Bogenfrage[];
  readonly gesamt: number;
  /** Menschliche Zählung ab 1. */
  readonly nummer: number;
  readonly aktuelle: Bogenfrage | null;
  readonly beantwortet: number;
  readonly offen: number;
  readonly istBeantwortet: (frageId: string) => boolean;
  readonly auswahlVon: (frageId: string) => readonly string[];
  readonly freitextVon: (frageId: string) => string;
  readonly optionWaehlen: (label: string) => void;
  readonly freitextSetzen: (text: string) => void;
  readonly zuFrage: (stelle: number) => void;
  readonly weiter: () => void;
  readonly zurueck: () => void;
  /** Verbleibende Zeit in Millisekunden; `null` heißt ohne Zeitbegrenzung. */
  readonly restzeitMs: number | null;
  readonly gesamtMinuten: number | null;
  readonly ansage: string;
  readonly abgabeAnfragen: () => void;
  readonly abgabeAbbrechen: () => void;
  readonly abgabeBestaetigen: () => void;
  /** Offene Fragen des Bogens – Grundlage der Nachbewertung. */
  readonly offeneFragen: readonly Bogenfrage[];
  readonly selbstbewertungVon: (frageId: string) => boolean | null;
  readonly selbstbewerten: (frageId: string, richtig: boolean) => void;
  /** Ist diese Frage gemerkt? */
  readonly istGemerkt: (frageId: string) => boolean;
  /** Merkzeichen der angezeigten Frage umschalten. */
  readonly merkenUmschalten: () => void;
  readonly nachbewertungAbschliessen: () => void;
}

export interface PruefungAuftragung {
  readonly auftrag: Pruefungsauftrag;
  /**
   * Ein unterbrochener Lauf, der fortgesetzt wird.
   *
   * Liegt er vor, kommt der Bogen von dort statt aus einem neuen Zug – und
   * der Zeitursprung wird um die bereits verbrauchte Zeit zurückdatiert. Der
   * Wert wird beim Aufsetzen einmal gelesen; die Ansicht ist über die
   * Lauf-Kennung verschlüsselt und baut für jeden Lauf neu auf.
   */
  readonly fortsetzung?: OffenerLauf | null;
  /** Wirksames Profil, also mit den tatsächlich gewählten Werten. */
  readonly profil: Pruefungsprofil;
  readonly katalogIndex: KatalogIndex;
  readonly profilId: number | null;
  readonly onFertig: (abschluss: Pruefungsabschluss) => void;
}

function ansichtFuer(frage: Frage, reihenfolge: readonly string[]): Bogenfrage {
  return {
    frage,
    optionen: reihenfolgeAnwenden(frage, reihenfolge),
    mehrfach: istMehrfachauswahl(frage),
    auswahlfrage: istAuswahlfrage(frage),
  };
}

/** Bogen aus dem Katalog, wenn der Anwendungskern nicht erreichbar ist. */
function ersatzbogen(
  katalogIndex: KatalogIndex,
  profil: Pruefungsprofil,
  auftrag: Pruefungsauftrag,
  grund: string,
): Laden {
  const fragen = bogenAusKatalog(katalogIndex.katalog, profil, auftrag)
    .map((eintrag) => {
      const frage = katalogIndex.frage(eintrag.frageId);
      return frage ? ansichtFuer(frage, eintrag.optionsReihenfolge) : undefined;
    })
    .filter((eintrag): eintrag is Bogenfrage => eintrag !== undefined);

  if (fragen.length === 0) {
    return { art: 'leer', meldung: KEINE_FRAGEN };
  }
  return {
    art: 'bereit',
    fragen,
    hinweis: `${grund} Der Bogen wurde ersatzweise aus dem Fragenkatalog zusammengestellt; das Ergebnis wird nicht in Ihrem Verlauf gespeichert.`,
    /* Ohne Anwendungskern gibt es niemanden, der Abweichungen meldet. Der
       Satz oben sagt bereits, dass dieser Bogen eine Näherung ist. */
    warnungen: KEINE_WARNUNGEN,
  };
}

/** Bogen eines unterbrochenen Laufs – ohne Umweg über den Anwendungskern. */
function bogenAusFortsetzung(fortsetzung: OffenerLauf, katalogIndex: KatalogIndex): Laden {
  const fragen = fortsetzung.bogen
    .map((eintrag) => {
      const frage = katalogIndex.frage(eintrag.frageId);
      return frage ? ansichtFuer(frage, eintrag.optionsReihenfolge) : undefined;
    })
    .filter((eintrag): eintrag is Bogenfrage => eintrag !== undefined);

  /* Der Kern hat den Bogen bereits gegen den Katalog gehalten und eine Zeile
     mit unbekannten Fragen verworfen. Bleibt hier trotzdem etwas übrig, ist
     die Ansicht kaputt und nicht der Bogen – dann lieber gar nichts zeigen
     als eine Lücke. */
  /* Keine Warnungen beim Fortsetzen: Der Bogen steht längst, und die
     Abweichungen wurden beim Start dieses Laufs schon einmal gezeigt. Sie
     jetzt zu wiederholen, hieße einen alten Vorgang als neu auszugeben. */
  return fragen.length === fortsetzung.bogen.length
    ? { art: 'bereit', fragen, hinweis: null, warnungen: KEINE_WARNUNGEN }
    : { art: 'leer', meldung: KEINE_FRAGEN };
}

/** Gesicherte Eingaben in die Form, mit der der Lauf arbeitet. */
function eingabenAus(fortsetzung: OffenerLauf | null): ReadonlyMap<string, Eingabe> {
  const karte = new Map<string, Eingabe>();
  if (fortsetzung === null) {
    return karte;
  }
  for (const [frageId, eintrag] of Object.entries(fortsetzung.eingaben)) {
    karte.set(frageId, {
      auswahl: eintrag.auswahl,
      freitext: eintrag.freitext,
      /* Dreiwertig: „fehlt" heißt nicht bewertet, nicht „falsch bewertet". */
      selbst: eintrag.selbst ?? null,
    });
  }
  return karte;
}

async function bogenLaden(
  auftrag: Pruefungsauftrag,
  profil: Pruefungsprofil,
  katalogIndex: KatalogIndex,
  profilId: number | null,
): Promise<Laden> {
  const starten = lernBruecke()?.pruefungStarten;

  if (!starten || profilId === null) {
    return ersatzbogen(
      katalogIndex,
      profil,
      auftrag,
      kanalFehltMeldung('pruefung:starten', 'Der Prüfungsbogen kam nicht vom Anwendungskern.'),
    );
  }

  try {
    const geplant = await starten(profilId, auftrag);
    const fragen = geplant.fragen
      .map((eintrag) => {
        const frage = katalogIndex.frage(eintrag.frageId);
        return frage ? ansichtFuer(frage, eintrag.optionsReihenfolge) : undefined;
      })
      .filter((eintrag): eintrag is Bogenfrage => eintrag !== undefined);

    return fragen.length > 0
      ? { art: 'bereit', fragen, hinweis: null, warnungen: geplant.warnungen }
      : { art: 'leer', meldung: KEINE_FRAGEN };
  } catch (fehler: unknown) {
    return ersatzbogen(
      katalogIndex,
      profil,
      auftrag,
      fehlerMeldung(fehler, 'Der Prüfungsbogen konnte nicht geladen werden.'),
    );
  }
}

function zuAntwort(frageId: string, eingabe: Eingabe): Pruefungsantwort {
  const basis: Pruefungsantwort = {
    frageId,
    auswahl: eingabe.auswahl,
    freitext: eingabe.freitext,
  };
  return eingabe.selbst === null ? basis : { ...basis, selbstAlsRichtig: eingabe.selbst };
}

/**
 * Führt durch einen Simulationslauf.
 *
 * Anders als im Lernmodus gibt es keinerlei Rückmeldung vor der Abgabe: Alle
 * Antworten bleiben bis dahin änderbar, es wird nichts protokolliert und
 * nichts gewertet. Abgegeben wird ausschließlich auf ausdrückliche Bestätigung
 * – oder, angekündigt, beim Ablauf der Zeit.
 */
export function usePruefung(auftragung: PruefungAuftragung): PruefungSteuerung {
  const { auftrag, profil, katalogIndex, profilId, onFertig } = auftragung;
  /*
    Einmal beim Aufsetzen gelesen und danach unveränderlich. Die Ansicht ist
    über die Lauf-Kennung verschlüsselt; ein anderer Lauf baut neu auf.
  */
  const [fortsetzung] = useState<OffenerLauf | null>(() => auftragung.fortsetzung ?? null);

  /*
    Beim Fortsetzen steht alles schon im ersten Bildaufbau.

    Nicht in einem Effekt: Sonst zeigte die Ansicht einen Takt lang die volle
    Zeit, Frage 1 und eine leere Nachbewertung – und die Restzeitanzeige
    spränge sichtbar zurück.
  */
  const [laden, setzeLaden] = useState<Laden>(() =>
    fortsetzung === null ? { art: 'laedt' } : bogenAusFortsetzung(fortsetzung, katalogIndex),
  );
  const [position, setzePosition] = useState(() => fortsetzung?.position ?? 0);
  const [eingaben, setzeEingaben] = useState<ReadonlyMap<string, Eingabe>>(() =>
    eingabenAus(fortsetzung),
  );
  const [phase, setzePhase] = useState<Pruefungsphase>(() => fortsetzung?.phase ?? 'bearbeiten');
  /**
   * Beginn der Bearbeitung; steht erst fest, wenn der Bogen da ist.
   *
   * Beim Fortsetzen um die verbrauchte Zeit zurückdatiert. Diese eine Zeile
   * stellt beides zugleich her: die Restzeit, weil `endeMs` daraus entsteht,
   * und die Bearbeitungsdauer, weil die Auswertung denselben Ursprung zählt.
   * Ein zweiter Zeitbegriff wäre der sichere Weg in zwei Zahlen, die
   * auseinanderlaufen.
   */
  const [startMs, setzeStartMs] = useState<number | null>(() =>
    fortsetzung === null ? null : Date.now() - fortsetzung.verbrauchtMs,
  );

  const abgegeben = useRef(false);
  /** Ob die Abgabe durch Zeitablauf ausgelöst wurde – über die Nachbewertung hinweg. */
  const zeitAusRef = useRef(fortsetzung?.zeitAbgelaufen ?? false);
  /**
   * Wann abgegeben wurde – der Endpunkt der Bearbeitungsdauer.
   *
   * Gebraucht, weil zwischen Abgabe und Auswertung die Selbstbewertung der
   * offenen Fragen liegen kann. Sie hat keine Uhr und gehört nicht zur
   * Bearbeitungszeit; siehe {@link auswerten}.
   */
  const abgabeMs = useRef<number | null>(null);

  useEffect(() => {
    if (fortsetzung !== null) {
      return undefined;
    }

    let abgebrochen = false;

    void bogenLaden(auftrag, profil, katalogIndex, profilId).then((ergebnis) => {
      if (!abgebrochen) {
        setzeStartMs(Date.now());
        setzeLaden(ergebnis);
      }
    });

    return () => {
      abgebrochen = true;
    };
  }, [auftrag, profil, katalogIndex, profilId, fortsetzung]);

  const gesamtMinuten = useMemo(() => wirksameZeitMinuten(profil, auftrag), [profil, auftrag]);

  /*
    Die Uhr beginnt, sobald Fragen dastehen. Der Endzeitpunkt wird beim Rendern
    abgeleitet und nicht in einem Effekt gesetzt: Sonst zeigte der erste
    Bildaufbau kurz „Ohne Zeitbegrenzung“, obwohl eine Zeit gilt.
  */
  const endeMs =
    laden.art === 'bereit' && startMs !== null && gesamtMinuten !== null
      ? startMs + gesamtMinuten * 60_000
      : null;

  const zeitLaeuft = phase === 'bearbeiten' || phase === 'abgabefrage';

  const fragen = laden.art === 'bereit' ? laden.fragen : LEERER_BOGEN;
  const ladeHinweis = laden.art === 'bereit' ? laden.hinweis : null;
  const warnungen = laden.art === 'bereit' ? laden.warnungen : KEINE_WARNUNGEN;
  const aktuelle = fragen[position] ?? null;

  /*
    Merken im Prüfungslauf.

    `merken()` ist im Kern seit jeher gebaut und geprüft – im Bogen fehlte
    allein der Weg dorthin. Wer im Lauf eine Frage geraten hat oder unsicher
    war, konnte das nirgends festhalten: Nach der Abgabe zählt nur richtig
    oder falsch, und eine glücklich getroffene Frage hinterließ keine Spur.

    Anders als in der Lernsitzung bringt der Bogen den Merkzustand nicht mit:
    Er wird im Renderer aus dem Katalog zusammengestellt (`bogenAusKatalog`)
    und kennt den Lernstand nicht. Geholt wird er deshalb erst, wenn die
    Frage angezeigt wird – ein Aufruf je Frage, und nur einmal.

    Das Merken verrät nichts über die Lösung und ist deshalb im Lauf
    unbedenklich; es ist eine Notiz an sich selbst, keine Rückmeldung.
  */
  const [gemerkte, setzeGemerkte] = useState<ReadonlySet<string>>(() => new Set<string>());
  /* Ein Merkbuch, kein Zustand: Welche Frage schon nachgeschlagen wurde,
     ändert nichts am Bild und darf deshalb kein Neuzeichnen auslösen –
     `setState` im Effekt verbietet die Projektregel (react-hooks) ohnehin. */
  const merkstandGeholt = useRef<Set<string>>(new Set<string>());
  /* Die Ansage hängt an ihrer Frage. So verfällt sie beim Blättern von
     selbst, ohne einen Effekt, der sie zurücksetzt – ein „Frage gemerkt",
     das an der nächsten Frage stehen bliebe, wäre eine Falschaussage. */
  const [merkansage, setzeMerkansage] = useState<{ id: string; text: string } | null>(null);

  const aktuelleId = aktuelle?.frage.id ?? null;

  useEffect(() => {
    const holen = lernBruecke()?.lernFragestand;
    if (
      aktuelleId === null ||
      profilId === null ||
      !holen ||
      merkstandGeholt.current.has(aktuelleId)
    ) {
      return;
    }
    /* Vor dem Aufruf vormerken, nicht danach: Sonst löste ein erneutes
       Zeichnen denselben Aufruf ein zweites Mal aus. */
    merkstandGeholt.current.add(aktuelleId);

    void holen(profilId, aktuelleId)
      .then((stand) => {
        setzeGemerkte((bisher) => {
          const neu = new Set(bisher);
          if (stand.gemerkt) {
            neu.add(stand.frageId);
          } else {
            neu.delete(stand.frageId);
          }
          return neu;
        });
      })
      .catch(() => {
        /* Ein nicht erreichbarer Lernstand darf einen laufenden Bogen nicht
           stören. Der Knopf steht dann auf „nicht gemerkt“ und wirkt beim
           Drücken – und wenn auch das misslingt, sagt die Ansage es. */
      });
  }, [aktuelleId, profilId]);

  const istGemerkt = useCallback((frageId: string): boolean => gemerkte.has(frageId), [gemerkte]);

  const merkenUmschalten = useCallback(() => {
    if (aktuelleId === null || profilId === null) {
      return;
    }
    const merken = lernBruecke()?.lernMerken;
    if (!merken) {
      setzeMerkansage({ id: aktuelleId, text: 'Die Merkliste ist zurzeit nicht verfügbar.' });
      return;
    }

    const neu = !gemerkte.has(aktuelleId);
    setzeGemerkte((bisher) => {
      const menge = new Set(bisher);
      if (neu) {
        menge.add(aktuelleId);
      } else {
        menge.delete(aktuelleId);
      }
      return menge;
    });
    setzeMerkansage({ id: aktuelleId, text: neu ? 'Frage gemerkt.' : 'Merken aufgehoben.' });

    void merken(profilId, aktuelleId, neu).catch(() => {
      /* Zurücknehmen, was nicht angekommen ist – ein Merkzeichen, das nach
         dem Lauf fehlt, wäre schlimmer als eines, das gar nicht erst
         erschien. */
      setzeGemerkte((bisher) => {
        const menge = new Set(bisher);
        if (neu) {
          menge.delete(aktuelleId);
        } else {
          menge.add(aktuelleId);
        }
        return menge;
      });
      setzeMerkansage({ id: aktuelleId, text: 'Das Merken ließ sich nicht speichern.' });
    });
  }, [aktuelleId, profilId, gemerkte]);

  const offeneFragen = useMemo(() => fragen.filter((eintrag) => !eintrag.auswahlfrage), [fragen]);

  const istBeantwortet = useCallback(
    (frageId: string): boolean => {
      const eingabe = eingaben.get(frageId);
      if (!eingabe) {
        return false;
      }
      return eingabe.auswahl.length > 0 || eingabe.freitext.trim().length > 0;
    },
    [eingaben],
  );

  const beantwortet = fragen.filter((eintrag) => istBeantwortet(eintrag.frage.id)).length;

  const auswerten = useCallback(
    async (zeitAus: boolean): Promise<void> => {
      /*
        Die Bearbeitungsdauer endet mit der Abgabe, nicht mit der Auswertung.

        Zwischen beidem liegt bei einem Bogen mit offenen Fragen die
        Selbstbewertung – ein Schritt ohne Uhr, für den sich niemand hetzen
        soll. Bis 0.24.1 zählte er mit: `Date.now() - startMs` lief weiter,
        obwohl `zeitLaeuft` längst falsch war. Ein Prüfling, der zehn Minuten
        gewissenhaft nachbewertete, bekam sie in den Verlauf geschrieben und
        in den Laufvergleich hinein.

        `abgabeMs` wird gesetzt, sobald abgegeben wird; steht dort nichts,
        ist die Abgabe der Auswertung unmittelbar vorausgegangen.
      */
      const ende = abgabeMs.current ?? Date.now();
      const dauerMs = startMs === null ? 0 : Math.max(0, ende - startMs);
      const antworten = fragen.map((eintrag) =>
        zuAntwort(eintrag.frage.id, eingaben.get(eintrag.frage.id) ?? LEERE_EINGABE),
      );

      const auswertenKanal = lernBruecke()?.pruefungAuswerten;
      if (auswertenKanal && profilId !== null) {
        try {
          const ergebnis = await auswertenKanal(profilId, auftrag, antworten, dauerMs, zeitAus);
          onFertig({ ergebnis, profilName: profil.name, auftrag, hinweis: ladeHinweis });
          return;
        } catch (fehler: unknown) {
          const meldung = fehlerMeldung(
            fehler,
            'Die Auswertung des Anwendungskerns war nicht erreichbar.',
          );
          onFertig({
            ergebnis: ergebnisBerechnen({
              profil,
              fragen: fragen.map((eintrag) => eintrag.frage),
              antworten,
              bereichTitel: katalogIndex.bereichTitel,
              dauerMs,
              zeitAbgelaufen: zeitAus,
              zeitpunkt: new Date().toISOString(),
            }),
            profilName: profil.name,
            auftrag,
            hinweis: `${meldung} Das Ergebnis wurde in der Oberfläche berechnet und nicht in Ihrem Verlauf gespeichert.`,
          });
          return;
        }
      }

      onFertig({
        ergebnis: ergebnisBerechnen({
          profil,
          fragen: fragen.map((eintrag) => eintrag.frage),
          antworten,
          bereichTitel: katalogIndex.bereichTitel,
          dauerMs,
          zeitAbgelaufen: zeitAus,
          zeitpunkt: new Date().toISOString(),
        }),
        profilName: profil.name,
        auftrag,
        hinweis:
          ladeHinweis ??
          kanalFehltMeldung(
            'pruefung:auswerten',
            'Das Ergebnis wurde in der Oberfläche berechnet und nicht in Ihrem Verlauf gespeichert.',
          ),
      });
    },
    [auftrag, eingaben, fragen, katalogIndex, ladeHinweis, onFertig, profil, profilId, startMs],
  );

  const abgabeStarten = useCallback(
    (zeitAus: boolean) => {
      if (abgegeben.current) {
        return;
      }

      /* Hier endet die Bearbeitungszeit – vor der Nachbewertung und nicht
         nach ihr. */
      abgabeMs.current = Date.now();

      if (offeneFragen.length > 0) {
        zeitAusRef.current = zeitAus;
        setzePhase('nachbewertung');
        return;
      }

      abgegeben.current = true;
      setzePhase('auswerten');
      void auswerten(zeitAus);
    },
    [auswerten, offeneFragen.length],
  );

  /* Der Zeitgeber gibt selbst ab, sobald die Zeit um ist – angekündigt war es
     schon in der Auswahl und in der Restzeitanzeige. */
  const beiAblauf = useCallback(() => {
    abgabeStarten(true);
  }, [abgabeStarten]);

  /*
    Der erste Messpunkt liegt vor dem Beginn der Bearbeitung – der Bogen wird
    ja erst geladen. Die Deckelung auf die Gesamtzeit sorgt dafür, dass die
    Anzeige nicht mit „2 Stunden 1 Minute“ startet, wenn zwei Stunden gelten.
  */
  const rohRestzeitMs = useRestzeit(zeitLaeuft ? endeMs : null, beiAblauf);
  const restzeitMs =
    rohRestzeitMs === null || gesamtMinuten === null
      ? rohRestzeitMs
      : Math.min(rohRestzeitMs, gesamtMinuten * 60_000);

  /*
    ── Sichern ────────────────────────────────────────────────────────────

    Zwei Auslöser, und beide sind nötig.

    Entprellt nach jeder Änderung: Eingaben, Position und Phase. Ohne die
    Entprellung schriebe jeder Tastendruck in die Datenbank.

    Und ein Herzschlag: Die Uhr läuft weiter, auch wenn niemand tippt. Wer
    zwanzig Minuten über einer Frage brütet und dann das Fenster schließt,
    bekäme sonst zwanzig Minuten geschenkt – die gesicherte Zeit stünde noch
    auf dem Stand der letzten Eingabe.

    Nach der Abgabe schweigen beide. Der Aufräumer des ersten Effekts löscht
    den entprellten Zeitgeber, sobald die Phase auf „auswerten" springt, und
    `sichern` prüft zusätzlich `abgegeben`. Der Anwendungskern hat den dritten
    Riegel: Er sichert nur per UPDATE auf eine Lauf-Kennung, die es dann nicht
    mehr gibt.
  */
  const standRef = useRef({ eingaben, position, phase, startMs });
  useEffect(() => {
    standRef.current = { eingaben, position, phase, startMs };
  }, [eingaben, position, phase, startMs]);

  /*
    ── Energiesparmodus ───────────────────────────────────────────────────

    Die Uhr hängt an der Wanduhr: `endeMs` und `verbrauchtMs` leiten sich
    beide aus `startMs` ab. Klappt jemand mitten im Bogen den Laptop zu,
    zählte die gesamte Schlafzeit als Bearbeitungszeit – nach einer
    Mittagspause wäre der Bogen beim Aufwachen abgelaufen. Der Hauptprozess
    misst die Schlafdauer (`main/energie.ts`), und hier wird der Zeitursprung
    um denselben Betrag vorgeschrieben. Weil alles aus `startMs` folgt, sind
    damit Restzeit, Dauer und gesicherter Zwischenstand in einem Zug richtig.

    Warum die Messung im Hauptprozess sitzt und nicht hier, steht am Kanal in
    `shared/ipc.ts`: Ein Sekundentakt im Renderer verwechselte ein
    minimiertes Fenster mit einem schlafenden Rechner – die Prüfungsuhr ließe
    sich durch Minimieren anhalten.
  */
  const [pausiertMs, setzePausiertMs] = useState(0);

  useEffect(() => {
    const beobachten = lernBruecke()?.energiesparenBeobachten;
    if (!beobachten || laden.art !== 'bereit') {
      return undefined;
    }

    return beobachten((schlafMs: number) => {
      if (abgegeben.current) {
        return;
      }

      /* Synchron mitgeführt, nicht erst im Effekt: Der Herzschlag liest
         `standRef` und kann feuern, bevor React den neuen Zustand gerendert
         hat. Ohne diese Zeile schriebe er die geschenkte Zeit als verbraucht
         in die Datenbank – genau der Fehler, den die Gutschrift verhindert. */
      const stand = standRef.current;
      if (stand.startMs !== null) {
        standRef.current = { ...stand, startMs: stand.startMs + schlafMs };
      }

      setzeStartMs((bisher) => (bisher === null ? bisher : bisher + schlafMs));
      setzePausiertMs((bisher) => bisher + schlafMs);
    });
  }, [laden.art]);

  const sichern = useCallback(() => {
    const kanal = lernBruecke()?.pruefungSichern;
    const stand = standRef.current;
    if (!kanal || profilId === null || abgegeben.current || stand.phase === 'auswerten') {
      return;
    }

    const eintraege: Record<string, GesicherteEingabe> = {};
    for (const [frageId, eingabe] of stand.eingaben) {
      eintraege[frageId] = {
        auswahl: eingabe.auswahl,
        freitext: eingabe.freitext,
        // `exactOptionalPropertyTypes`: nur setzen, wenn wirklich bewertet.
        ...(eingabe.selbst === null ? {} : { selbst: eingabe.selbst }),
      };
    }

    void kanal(profilId, {
      eingaben: eintraege,
      position: stand.position,
      // Eine offene Rückfrage gehört nicht wiederhergestellt.
      phase: stand.phase === 'nachbewertung' ? 'nachbewertung' : 'bearbeiten',
      zeitAbgelaufen: zeitAusRef.current,
      verbrauchtMs: stand.startMs === null ? 0 : Math.max(0, Date.now() - stand.startMs),
    }).catch((fehler: unknown) => {
      /* Das Sichern darf scheitern, ohne den Lauf anzuhalten – wer gerade
         prüft, soll deswegen nicht unterbrochen werden. */
      console.warn('[pruefung] Zwischenstand konnte nicht gesichert werden:', fehler);
    });
  }, [profilId]);

  useEffect(() => {
    if (laden.art !== 'bereit' || abgegeben.current) {
      return undefined;
    }
    const kennung = window.setTimeout(sichern, ENTPRELLUNG_MS);
    return () => {
      window.clearTimeout(kennung);
    };
  }, [eingaben, position, phase, laden.art, sichern]);

  useEffect(() => {
    if (!zeitLaeuft || laden.art !== 'bereit') {
      return undefined;
    }
    const kennung = window.setInterval(sichern, HERZSCHLAG_MS);

    /* Zusätzlich, sobald das Fenster aus dem Blick gerät: Der Herzschlag
       deckelt den Verlust auf sein Intervall, das hier drückt ihn im
       häufigsten Fall – wegklicken, dann schließen – auf null. */
    const beiAbwendung = (): void => {
      sichern();
    };
    window.addEventListener('blur', beiAbwendung);
    document.addEventListener('visibilitychange', beiAbwendung);

    return () => {
      window.clearInterval(kennung);
      window.removeEventListener('blur', beiAbwendung);
      document.removeEventListener('visibilitychange', beiAbwendung);
    };
  }, [zeitLaeuft, laden.art, sichern]);

  /*
    Dem Hauptprozess sagen, dass geprüft wird.

    Er fragt beim Schließen des Fensters nach – das entsteht dort, am
    Fensterkreuz und über Alt+F4, und der Renderer bekommt davon nichts mit.
    Gemeldet wird auch das Ende: Der Aufräumer schaltet ab, sobald der Lauf
    vorbei ist oder die Ansicht wechselt.
  */
  useEffect(() => {
    const melden = lernBruecke()?.pruefungLaeuft;
    if (!melden) {
      return undefined;
    }

    const laeuft = laden.art === 'bereit' && phase !== 'auswerten';
    void melden(laeuft).catch(() => {
      /* Ohne Meldung schließt das Fenster ohne Rückfrage – unschön, aber
         kein Grund, den Lauf anzuhalten. Der Bogen ist gesichert. */
    });

    return () => {
      void melden(false).catch(() => undefined);
    };
  }, [laden.art, phase]);

  const optionWaehlen = useCallback(
    (label: string) => {
      if (aktuelle === null || phase === 'auswerten' || phase === 'nachbewertung') {
        return;
      }
      const frageId = aktuelle.frage.id;

      setzeEingaben((bisher) => {
        const vorher = bisher.get(frageId) ?? LEERE_EINGABE;
        /* In der Simulation wird bei jeder Multiple-Choice-Frage kumuliert,
           auch bei solchen mit nur einer richtigen Antwort: Die Oberfläche
           verrät die Antwortzahl hier nicht, also darf sie auch nicht durch
           erzwungene Einfachauswahl durchscheinen. Eine zu große Auswahl ist
           schlicht falsch – genau wie in der Prüfung. */
        const auswahl = vorher.auswahl.includes(label)
          ? vorher.auswahl.filter((eintrag) => eintrag !== label)
          : [...vorher.auswahl, label];
        const neu = new Map(bisher);
        neu.set(frageId, { ...vorher, auswahl });
        return neu;
      });
    },
    [aktuelle, phase],
  );

  const freitextSetzen = useCallback(
    (text: string) => {
      if (aktuelle === null) {
        return;
      }
      const frageId = aktuelle.frage.id;
      setzeEingaben((bisher) => {
        const vorher = bisher.get(frageId) ?? LEERE_EINGABE;
        const neu = new Map(bisher);
        neu.set(frageId, { ...vorher, freitext: text });
        return neu;
      });
    },
    [aktuelle],
  );

  const zuFrage = useCallback(
    (stelle: number) => {
      setzePosition(Math.min(Math.max(0, stelle), Math.max(0, fragen.length - 1)));
    },
    [fragen.length],
  );

  const weiter = useCallback(() => {
    setzePosition((bisher) => Math.min(bisher + 1, Math.max(0, fragen.length - 1)));
  }, [fragen.length]);

  const zurueck = useCallback(() => {
    setzePosition((bisher) => Math.max(0, bisher - 1));
  }, []);

  const abgabeAnfragen = useCallback(() => {
    setzePhase('abgabefrage');
  }, []);

  const abgabeAbbrechen = useCallback(() => {
    setzePhase('bearbeiten');
  }, []);

  const abgabeBestaetigen = useCallback(() => {
    abgabeStarten(false);
  }, [abgabeStarten]);

  const selbstbewerten = useCallback((frageId: string, richtig: boolean) => {
    setzeEingaben((bisher) => {
      const vorher = bisher.get(frageId) ?? LEERE_EINGABE;
      const neu = new Map(bisher);
      neu.set(frageId, { ...vorher, selbst: richtig });
      return neu;
    });
  }, []);

  const nachbewertungAbschliessen = useCallback(() => {
    if (abgegeben.current) {
      return;
    }
    abgegeben.current = true;
    setzePhase('auswerten');
    void auswerten(zeitAusRef.current);
  }, [auswerten]);

  /*
    Die Ansage wird abgeleitet, nicht gespeichert. Solange derselbe Satz
    dasteht, ändert React den Textknoten nicht – die höfliche Live-Region
    meldet ihn damit von selbst genau einmal, ohne jede Sekunde zu wiederholen.
  */
  const zeitknapp =
    zeitLaeuft &&
    restzeitMs !== null &&
    restzeitMs <= ZEITWARNUNG_MS &&
    gesamtMinuten !== null &&
    gesamtMinuten * 60_000 > ZEITWARNUNG_MS;

  /*
    Die Pausenansage nennt die aufgelaufene Gesamtdauer, nicht die des
    letzten Schlafs. Zwei gleich lange Pausen ergäben sonst denselben Satz,
    und die höfliche Live-Region meldete die zweite gar nicht – der Text
    änderte sich ja nicht.
  */
  const pausenansage =
    pausiertMs > 0
      ? `Der Rechner war im Energiesparmodus. Die Uhr stand so lange still, insgesamt ${restzeitText(pausiertMs)}; Ihre Restzeit ist dadurch unverändert.`
      : '';

  const ansage =
    phase === 'nachbewertung'
      ? 'Die Simulation ist abgegeben. Bitte bewerten Sie zuerst Ihre Antworten auf die offenen Fragen.'
      : zeitknapp
        ? 'Noch fünf Minuten Restzeit. Danach wird die Simulation automatisch abgegeben und ausgewertet.'
        : merkansage !== null && merkansage.id === aktuelleId
          ? merkansage.text
          : pausenansage;

  return {
    ladezustand: laden.art,
    meldung: laden.art === 'leer' || laden.art === 'fehler' ? laden.meldung : null,
    hinweis: ladeHinweis,
    warnungen,
    phase,
    fragen,
    gesamt: fragen.length,
    nummer: position + 1,
    aktuelle,
    beantwortet,
    offen: fragen.length - beantwortet,
    istBeantwortet,
    auswahlVon: (frageId) => eingaben.get(frageId)?.auswahl ?? LEERE_EINGABE.auswahl,
    freitextVon: (frageId) => eingaben.get(frageId)?.freitext ?? '',
    optionWaehlen,
    freitextSetzen,
    zuFrage,
    weiter,
    zurueck,
    restzeitMs,
    gesamtMinuten,
    ansage,
    abgabeAnfragen,
    abgabeAbbrechen,
    abgabeBestaetigen,
    offeneFragen,
    selbstbewertungVon: (frageId) => eingaben.get(frageId)?.selbst ?? null,
    selbstbewerten,
    istGemerkt,
    merkenUmschalten,
    nachbewertungAbschliessen,
  };
}
