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

import {
  istMehrfachauswahl,
  richtigeLabels,
  type Antwortoption,
  type Frage,
} from '../../../shared/katalog';
import {
  BEWERTUNG_BEZEICHNUNG,
  type Antwortprotokoll,
  type Bewertung,
  type LetzterFreitext,
  type SitzungsFilter,
} from '../../../shared/lernstand';
import {
  bewertungAusErgebnis,
  giltAlsRichtig,
  istAuswahlfrage,
  mcAnsage,
  mcErgebnis,
  selbstbewertungAnsage,
  type McErgebnis,
} from '../lernen/bewertung';
import { fehlerMeldung, kanalFehltMeldung, lernBruecke } from '../lernen/bruecke';
import {
  brauchtLernstand,
  fragenAusKatalog,
  mischen,
  reihenfolgeAnwenden,
} from '../lernen/sitzungsaufbau';
import type { FalscheFrage, SitzungsBilanz, Sitzungsauftrag } from '../lernen/typen';
import type { KatalogIndex } from './useKatalog';

/**
 * Bearbeitungsstand der aktuellen Frage.
 * Bei Multiple Choice wird `selbstbewertung` übersprungen, bei offenen Fragen
 * ist er der Zwischenschritt zwischen Musterantwort und Ergebnis.
 */
export type Schritt = 'antworten' | 'selbstbewertung' | 'ergebnis';

export interface SitzungsFrageAnsicht {
  readonly frage: Frage;
  /** Optionen in Anzeigereihenfolge (vom Anwendungskern gemischt). */
  readonly optionen: readonly Antwortoption[];
  readonly mehrfach: boolean;
  /**
   * Ist die Frage gemerkt? `null` heißt „noch nicht bekannt“.
   *
   * Bekannt ist es, wo der Anwendungskern die Sitzung gestellt hat. Bei einer
   * festen Fragenliste – aus der Suche, aus den Prüfungsfehlern, aus den
   * hartnäckigen Fragen – wurde er nicht gefragt; dort wird der Stand je
   * Frage nachgeholt (siehe den Effekt „Merkzustand nachladen“).
   */
  readonly gemerkt: boolean | null;
  /**
   * Was zuletzt zu dieser offenen Frage geschrieben wurde – falls überhaupt.
   *
   * Kommt aus dem Anwendungskern und wird erst nach dem Bestätigen gezeigt.
   * Die Ersatzsitzung ohne Lernstand hat es nicht: Dort gibt es keine
   * Historie zu lesen. Bei einer Wiedereinreihung in derselben Sitzung tritt
   * die Antwort von eben an seine Stelle – siehe {@link mitEigenerAntwort}.
   */
  readonly letzterFreitext?: LetzterFreitext;
}

type Laden =
  | { readonly art: 'laedt' }
  | {
      readonly art: 'bereit';
      readonly fragen: readonly SitzungsFrageAnsicht[];
      /** Warnung, obwohl geübt werden kann – etwa fehlender Lernstand. */
      readonly hinweis: string | null;
    }
  | { readonly art: 'leer'; readonly meldung: string }
  | { readonly art: 'fehler'; readonly meldung: string };

export interface SitzungSteuerung {
  readonly ladezustand: Laden['art'];
  readonly meldung: string | null;
  readonly hinweis: string | null;
  readonly speicherHinweis: string | null;
  readonly gesamt: number;
  /** Menschliche Zählung ab 1. */
  readonly nummer: number;
  readonly letzte: boolean;
  readonly aktuelle: SitzungsFrageAnsicht | null;
  readonly auswahl: readonly string[];
  readonly freitext: string;
  readonly schritt: Schritt;
  readonly ergebnis: McErgebnis | null;
  readonly bewertung: Bewertung | null;
  readonly ansage: string;
  readonly gemerkt: boolean;
  readonly optionWaehlen: (label: string) => void;
  readonly optionAnPosition: (position: number) => void;
  readonly freitextSetzen: (text: string) => void;
  readonly bestaetigen: () => void;
  readonly selbstBewerten: (bewertung: Bewertung) => void;
  /**
   * Ob sich die eben gefällte Selbstbewertung noch zurücknehmen lässt.
   *
   * Nur bei offenen Fragen und nur, solange nicht weitergeblättert wurde.
   * Bei Auswahlfragen gibt es nichts zurückzunehmen: Dort steht das Ergebnis
   * auf dem Bildschirm, und eine änderbare Antwort wäre kein Korrekturweg,
   * sondern ein zweiter Versuch mit bekannter Lösung.
   */
  readonly bewertungAenderbar: boolean;
  readonly bewertungAendern: () => void;
  /**
   * Ob sich eine richtige Auswahlantwort noch als geraten eingestehen lässt.
   *
   * Nur bei Auswahlfragen: Bei den offenen holt das Programm die
   * Selbsteinschätzung ohnehin ein.
   */
  readonly alsGeratenMoeglich: boolean;
  readonly alsGeratenWerten: () => void;
  readonly weiter: () => void;
  readonly abbrechen: () => void;
  readonly merkenUmschalten: () => void;
}

const LEERE_FRAGEN: readonly SitzungsFrageAnsicht[] = [];
const LEERE_AUSWAHL: readonly string[] = [];

/**
 * Wie oft dieselbe Frage in einer Sitzung wieder eingereiht wird.
 *
 * Zweimal, nicht unbegrenzt: Wer eine Frage dreimal hintereinander nicht
 * weiß, lernt aus einer vierten Vorlage in derselben Stunde nichts mehr.
 * Danach übernimmt wie bisher die Wiedervorlage – „nochmal“ setzt das
 * Intervall auf 0, die nächste Sitzung legt die Frage vorn wieder vor
 * (`shared/lernplan.ts`, `wiedervorlageBerechnen`).
 */
const MAX_WIEDEREINREIHUNGEN = 2;

/** Zusatz zur Ergebnisansage, wenn die Frage wieder eingereiht wurde. */
const WIEDERHOLUNGS_ANSAGE = 'Die Frage kommt in dieser Sitzung noch einmal.';

const KEINE_FRAGEN =
  'Zu dieser Auswahl gibt es zurzeit keine Fragen. Wählen Sie ein anderes Kapitel oder lernen Sie weiter.';

function ansichtFuer(
  frage: Frage,
  reihenfolge: readonly string[],
  gemerkt: boolean | null,
  letzterFreitext?: LetzterFreitext,
): SitzungsFrageAnsicht {
  return {
    frage,
    optionen: reihenfolgeAnwenden(frage, reihenfolge),
    mehrfach: istMehrfachauswahl(frage),
    gemerkt,
    // `exactOptionalPropertyTypes`: nur setzen, wenn wirklich etwas da ist.
    ...(letzterFreitext === undefined ? {} : { letzterFreitext }),
  };
}

/**
 * Die Ansicht für die Wiedervorlage in derselben Sitzung.
 *
 * `letzterFreitext` sagt zu, was zu dieser Frage **zuletzt** geschrieben
 * wurde (`shared/lernstand.ts`). Beim Laden ist das der Eintrag aus dem
 * Lernstand; nach einer Wiedereinreihung ist es die Antwort von eben. Bliebe
 * der alte Eintrag stehen, widerspräche die Zusage der Anzeige – und der
 * Vergleich wäre der schwächere: Beim zweiten Anlauf zählt, was gerade
 * gefehlt hat, nicht was vor zwei Wochen dastand.
 *
 * Ein leer gelassenes Feld überschreibt nichts. Leer bleiben ist ausdrücklich
 * erlaubt, und ein Kasten über einer leeren Zeile wäre eine Vorhaltung ohne
 * Inhalt (siehe `LetzteAntwort`); zuletzt geschrieben wurde dann weiterhin
 * das Frühere.
 */
function mitEigenerAntwort(ansicht: SitzungsFrageAnsicht, freitext: string): SitzungsFrageAnsicht {
  if (freitext.trim() === '') {
    return ansicht;
  }
  return {
    ...ansicht,
    letzterFreitext: { text: freitext, zeitpunkt: new Date().toISOString() },
  };
}

/**
 * Ersatzsitzung aus dem Katalog, wenn der Lernstand nicht erreichbar ist.
 *
 * **Ohne die dauerhafte Kapitelabwahl, und das mit Absicht.** Sie steht in der
 * Profilzeile und käme über denselben Kanal, der hier gerade nicht antwortet –
 * es gäbe schlicht nichts zu lesen. Folgenlos ist es, weil dieser Weg
 * ausdrücklich nichts speichert: Der Hinweis unten sagt es, und ohne
 * `lernen:antworten` landet keine Antwort im Lernstand. Wer in diesem Zustand
 * eine abgewählte Frage sieht, sieht sie einmal und ohne Wirkung.
 */
function ersatzsitzung(katalogIndex: KatalogIndex, filter: SitzungsFilter, grund: string): Laden {
  if (brauchtLernstand(filter)) {
    return {
      art: 'fehler',
      meldung: `${grund} Diese Auswahl braucht Ihren gespeicherten Lernstand und lässt sich nicht aus dem Katalog ersetzen.`,
    };
  }

  /* Ohne Lernstand gibt es keinen Merkzustand – `false` ist hier keine
     Vermutung, sondern die Wahrheit: In dieser Sitzung wird nichts
     gespeichert, also ist auch nichts gemerkt. */
  const fragen = fragenAusKatalog(katalogIndex.katalog, filter).map((frage) =>
    ansichtFuer(frage, [], false),
  );
  if (fragen.length === 0) {
    return { art: 'leer', meldung: KEINE_FRAGEN };
  }
  return {
    art: 'bereit',
    fragen,
    hinweis: `${grund} Sie üben aus dem Fragenkatalog; Ihre Antworten werden nicht gespeichert.`,
  };
}

async function zusammenstellen(
  auftrag: Sitzungsauftrag,
  katalogIndex: KatalogIndex,
  profilId: number | null,
): Promise<Laden> {
  if (auftrag.quelle.art === 'fragen') {
    /* Die Reihenfolge der Antworten stellt hier die Oberfläche, weil der
       Anwendungskern bei einer festen Fragenliste gar nicht gefragt wird.
       Ohne das blieb der Schalter „Antwortmöglichkeiten mischen" auf diesem
       Weg wirkungslos – siehe `Sitzungsquelle`. */
    const mischenGewuenscht = auftrag.quelle.optionenMischen === true;
    const fragen = auftrag.quelle.frageIds
      .map((frageId) => katalogIndex.frage(frageId))
      .filter((frage): frage is Frage => frage !== undefined)
      .map((frage) =>
        ansichtFuer(
          frage,
          mischenGewuenscht ? mischen((frage.optionen ?? []).map((o) => o.label)) : [],
          null,
        ),
      );
    return fragen.length > 0
      ? { art: 'bereit', fragen, hinweis: null }
      : { art: 'leer', meldung: KEINE_FRAGEN };
  }

  const { filter } = auftrag.quelle;
  const sitzungLaden = lernBruecke()?.lernSitzung;

  if (!sitzungLaden || profilId === null) {
    return ersatzsitzung(
      katalogIndex,
      filter,
      kanalFehltMeldung('lernen:sitzung', 'Ihr Lernstand ist zurzeit nicht verfügbar.'),
    );
  }

  try {
    const geplant = await sitzungLaden(profilId, filter);
    const fragen = geplant
      .map((eintrag) => {
        const frage = katalogIndex.frage(eintrag.frageId);
        return frage
          ? ansichtFuer(frage, eintrag.optionsReihenfolge, eintrag.gemerkt, eintrag.letzterFreitext)
          : undefined;
      })
      .filter((eintrag): eintrag is SitzungsFrageAnsicht => eintrag !== undefined);
    return fragen.length > 0
      ? { art: 'bereit', fragen, hinweis: null }
      : { art: 'leer', meldung: KEINE_FRAGEN };
  } catch (fehler: unknown) {
    return ersatzsitzung(
      katalogIndex,
      filter,
      fehlerMeldung(fehler, 'Ihre Lernsitzung konnte nicht geladen werden.'),
    );
  }
}

function merklisteSetzen(
  bisher: ReadonlySet<string>,
  frageId: string,
  gemerkt: boolean,
): ReadonlySet<string> {
  const neu = new Set(bisher);
  if (gemerkt) {
    neu.add(frageId);
  } else {
    neu.delete(frageId);
  }
  return neu;
}

/**
 * Führt durch eine Lernsitzung: Fragen laden, Antworten prüfen,
 * protokollieren und zur nächsten Frage weitergehen. Falsch beantwortete
 * Fragen kommen noch in derselben Sitzung wieder – siehe
 * {@link wiedereinreihen} für Regel und Grenze.
 *
 * Es wird nie automatisch weitergeschaltet (WCAG 3.2.2) und es läuft keine
 * Zeit mit – jeder Schritt geschieht auf ausdrückliche Eingabe.
 */
export function useSitzung(
  auftrag: Sitzungsauftrag,
  katalogIndex: KatalogIndex,
  profilId: number | null,
  onBeenden: (bilanz: SitzungsBilanz) => void,
  /**
   * Verrät die Darstellung, wie viele Antworten richtig sind?
   *
   * Muss denselben Wert tragen wie die gleichnamige Eigenschaft an
   * `AuswahlAntwort` – sonst widerspricht die Auswahllogik der Bauart der
   * Bedienelemente (siehe {@link umschalten}).
   */
  anzahlVerraten = true,
): SitzungSteuerung {
  const [laden, setzeLaden] = useState<Laden>({ art: 'laedt' });
  const [position, setzePosition] = useState(0);
  const [auswahl, setzeAuswahl] = useState<readonly string[]>(LEERE_AUSWAHL);
  const [freitext, setzeFreitext] = useState('');
  const [schritt, setzeSchritt] = useState<Schritt>('antworten');
  const [ergebnis, setzeErgebnis] = useState<McErgebnis | null>(null);
  const [bewertung, setzeBewertung] = useState<Bewertung | null>(null);
  const [protokolle, setzeProtokolle] = useState<readonly Antwortprotokoll[]>([]);
  const [gemerkte, setzeGemerkte] = useState<ReadonlySet<string>>(() => new Set<string>());
  const [ansage, setzeAnsage] = useState('');
  const [speicherHinweis, setzeSpeicherHinweis] = useState<string | null>(null);
  const [nachgereiht, setzeNachgereiht] = useState<readonly SitzungsFrageAnsicht[]>(LEERE_FRAGEN);

  const startzeit = useRef(0);
  /* Zählt je Frage die Wiedereinreihungen. Ein Ref, kein State: Der Wert
     steuert keine Darstellung, nur die Entscheidung im Handler. Frisch je
     Sitzung, weil `Lernsitzung` über den React-Schlüssel des Auftrags neu
     aufsetzt – wie alle Zustände dieses Hooks. */
  const wiedereinreihungen = useRef(new Map<string, number>());

  useEffect(() => {
    let abgebrochen = false;

    void zusammenstellen(auftrag, katalogIndex, profilId).then((ergebnisLaden) => {
      if (!abgebrochen) {
        setzeLaden(ergebnisLaden);
        /* Die Merkliste beginnt mit dem, was der Anwendungskern mitgeschickt
           hat. Ohne das begann sie leer, und der Stern stand an jeder Frage
           auf „nicht gemerkt“ – in einer Sitzung „nur Gemerkte“ an jeder
           einzelnen. Fragen mit `null` sind noch offen; für sie holt der
           Effekt darunter den Stand nach. */
        if (ergebnisLaden.art === 'bereit') {
          setzeGemerkte(
            new Set(
              ergebnisLaden.fragen
                .filter((eintrag) => eintrag.gemerkt === true)
                .map((eintrag) => eintrag.frage.id),
            ),
          );
        }
      }
    });

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

  // Bearbeitungsdauer je Frage – ohne Zeitdruck, rein zur Protokollierung.
  useEffect(() => {
    startzeit.current = Date.now();
  }, [position, laden.art]);

  /*
    Die Sitzung wächst am Ende: Falsch beantwortete Fragen werden wieder
    eingereiht (siehe {@link wiedereinreihen}). „Frage X von Y“ bleibt dabei
    ehrlich, weil Y mitwächst – die Anzeige zählt Vorlagen, nicht
    verschiedene Fragen, und verspricht so nie ein Ende, das dann doch nicht
    kommt.
  */
  const geladene = laden.art === 'bereit' ? laden.fragen : LEERE_FRAGEN;
  const fragen = useMemo(
    () => (nachgereiht.length === 0 ? geladene : [...geladene, ...nachgereiht]),
    [geladene, nachgereiht],
  );
  const aktuelle = fragen[position] ?? null;
  const frageId = aktuelle?.frage.id ?? null;

  /*
    Merkzustand nachladen.

    Eine Sitzung aus fester Fragenliste – aus der Suche, aus den
    Prüfungsfehlern, aus den hartnäckigen Fragen – geht nicht über
    `lernen:sitzung`; für sie hat niemand den Merkzustand mitgeschickt
    (`gemerkt === null`). Geholt wird er je Frage und erst beim Aufschlagen:
    Eine Sitzung aus 575 Suchtreffern soll nicht 575 Abfragen auslösen, nur
    weil sie geöffnet wurde.
  */
  const merkstandGeholt = useRef(new Set<string>());
  useEffect(() => {
    if (frageId === null || aktuelle?.gemerkt !== null || profilId === null) {
      return;
    }
    if (merkstandGeholt.current.has(frageId)) {
      return;
    }
    const lesen = lernBruecke()?.lernFragestand;
    if (!lesen) {
      return;
    }
    merkstandGeholt.current.add(frageId);

    let lebt = true;
    void lesen(profilId, frageId)
      .then((stand) => {
        if (lebt) {
          setzeGemerkte((bisher) => merklisteSetzen(bisher, stand.frageId, stand.gemerkt));
        }
      })
      .catch(() => {
        /* Ohne Auskunft bleibt der Stern auf „nicht gemerkt“ – er ist eine
           Zugabe, und eine Fehlermeldung dafür wäre lauter als die Sache. */
        merkstandGeholt.current.delete(frageId);
      });

    return () => {
      lebt = false;
    };
  }, [aktuelle?.gemerkt, frageId, profilId]);

  /**
   * Ob diese Frage noch einmal eingereiht werden dürfte.
   *
   * Getrennt vom Einreihen selbst, weil die Anzeige die Antwort **vorher**
   * braucht: Solange die Buchung zurückgehalten wird, ist die Sitzung noch
   * nicht gewachsen – ohne diese Vorschau hieße der Knopf im letzten Schritt
   * „Sitzung auswerten“, obwohl die Frage gleich noch einmal kommt.
   */
  const kannWiedereinreihen = useCallback((frageId: string): boolean => {
    return (wiedereinreihungen.current.get(frageId) ?? 0) < MAX_WIEDEREINREIHUNGEN;
  }, []);

  /**
   * Reiht eine falsch oder unvollständig beantwortete Frage ans Ende der
   * laufenden Sitzung – bis sie einmal richtig beantwortet wurde, höchstens
   * aber {@link MAX_WIEDEREINREIHUNGEN} Mal (Schutz gegen Frustschleifen).
   *
   * Die Antwort auf die Wiederholung läuft über denselben Antwortweg wie
   * jede andere: FSRS-6 verrechnet Wiederholungen am selben Tag über seine
   * Kurzzeitparameter, und einen Beleg erzeugt eine Same-Day-Richtige
   * ohnehin nicht (`shared/reife.ts`, Mindestabstand ein Tag).
   *
   * @returns ob eingereiht wurde – die Ansage sagt es dann mit an.
   */
  const wiedereinreihen = useCallback(
    (ansicht: SitzungsFrageAnsicht): boolean => {
      if (!kannWiedereinreihen(ansicht.frage.id)) {
        return false;
      }
      const bisher = wiedereinreihungen.current.get(ansicht.frage.id) ?? 0;
      wiedereinreihungen.current.set(ansicht.frage.id, bisher + 1);
      setzeNachgereiht((alte) => [...alte, ansicht]);
      return true;
    },
    [kannWiedereinreihen],
  );

  /**
   * Die Bilanz der Sitzung.
   *
   * `zusaetzlich` ist eine gerade erst abgeschickte Buchung: `protokolle` ist
   * Zustand und erst nach dem nächsten Rendern nachgezogen. Ohne diesen Weg
   * fehlte die zuletzt bewertete Frage in der Auswertung – ausgerechnet die,
   * die man eben bearbeitet hat.
   */
  const bilanzJetzt = useCallback(
    (zusaetzlich: Antwortprotokoll | null = null): SitzungsBilanz => {
      const alle = zusaetzlich === null ? protokolle : [...protokolle, zusaetzlich];
      const richtig = alle.filter((protokoll) => protokoll.richtig).length;

      /* Je Frage ein Eintrag, auch wenn sie nach Wiedereinreihung mehrfach
         falsch war: Die Liste benennt Fragen, keine Versuche – und „Fehler
         wiederholen“ soll dieselbe Frage nicht doppelt vorlegen. Eine in der
         Wiederholung dann richtig beantwortete Frage bleibt aufgeführt: Der
         erste Versuch war falsch, und genau das soll die Auswertung sagen. */
      const falscheFragen: FalscheFrage[] = [];
      const aufgefuehrt = new Set<string>();
      for (const protokoll of alle) {
        if (protokoll.richtig || aufgefuehrt.has(protokoll.frageId)) {
          continue;
        }
        aufgefuehrt.add(protokoll.frageId);
        const frage = katalogIndex.frage(protokoll.frageId);
        falscheFragen.push({
          frageId: protokoll.frageId,
          amtlicheNummer: frage?.amtliche_nummer ?? protokoll.frageId,
          text: frage?.frage.text ?? '',
        });
      }

      return {
        titel: auftrag.titel,
        ausSuche: auftrag.ausSuche,
        gesamt: fragen.length,
        beantwortet: alle.length,
        richtig,
        falsch: alle.length - richtig,
        falscheFragen,
      };
    },
    [auftrag.ausSuche, auftrag.titel, fragen.length, katalogIndex, protokolle],
  );

  const protokollieren = useCallback(
    (protokoll: Antwortprotokoll) => {
      setzeProtokolle((bisher) => [...bisher, protokoll]);

      const speichern = lernBruecke()?.lernAntworten;
      if (!speichern || profilId === null) {
        return;
      }

      void speichern(profilId, protokoll)
        .then((stand) => {
          setzeGemerkte((bisher) => merklisteSetzen(bisher, stand.frageId, stand.gemerkt));
        })
        .catch((fehler: unknown) => {
          setzeSpeicherHinweis(
            fehlerMeldung(fehler, 'Ihre Antwort konnte nicht gespeichert werden.'),
          );
        });
    },
    [profilId],
  );

  const umschalten = useCallback(
    (label: string, mitAnsage: boolean) => {
      if (aktuelle === null || schritt !== 'antworten') {
        return;
      }

      /* Kumuliert wird immer dann, wenn die Darstellung Kontrollkästchen
         zeigt – also bei Mehrfachauswahl und immer dann, wenn die Antwortzahl
         verborgen ist. Sonst schiene die verschwiegene Zahl durch die
         Bedienung hindurch: Wer ein zweites Kästchen anhakt und das erste
         ausgehen sieht, weiß sofort, dass genau eine Antwort richtig ist.
         Eine zu große Auswahl ist dann schlicht falsch – genau wie in der
         Prüfung, die es ebenso hält (siehe `usePruefung.optionWaehlen`). */
      if (aktuelle.mehrfach || !anzahlVerraten) {
        const gewaehlt = !auswahl.includes(label);
        setzeAuswahl(gewaehlt ? [...auswahl, label] : auswahl.filter((l) => l !== label));
        if (mitAnsage) {
          setzeAnsage(`Antwort ${label} ${gewaehlt ? 'gewählt' : 'abgewählt'}.`);
        }
        return;
      }

      setzeAuswahl([label]);
      if (mitAnsage) {
        setzeAnsage(`Antwort ${label} gewählt.`);
      }
    },
    [aktuelle, anzahlVerraten, auswahl, schritt],
  );

  const optionWaehlen = useCallback(
    (label: string) => {
      umschalten(label, false);
    },
    [umschalten],
  );

  const optionAnPosition = useCallback(
    (anzeigePosition: number) => {
      const option = aktuelle?.optionen[anzeigePosition - 1];
      if (option) {
        umschalten(option.label, true);
      }
    },
    [aktuelle, umschalten],
  );

  /*
    ── Die zurückgehaltene Buchung ────────────────────────────────────────

    Beide Fragetypen buchen erst beim Weiterblättern, aus zwei verschiedenen
    Gründen:

    **Offene Fragen:** Die Selbstbewertung steuert unmittelbar die
    Wiedervorlage. Ein Fehlgriff auf „Gut“ statt „Nochmal“ – bei vier eng
    beieinanderliegenden Knöpfen kein Randfall – schrieb bis 0.22.0 sofort und
    endgültig eine falsche FSRS-Buchung: Stabilität hoch, Intervall lang,
    unter Umständen ein Reife-Beleg dazu. Danach bot das Programm nie wieder
    an, das zu berichtigen. Die andere Richtung ist harmlos: Eine zu streng
    bewertete Antwort kommt in derselben Sitzung wieder, und FSRS verkürzt das
    Intervall ohnehin.

    **Auswahlfragen:** Dort vergibt `bewertungAusErgebnis` für jede richtige
    Antwort ausnahmslos „gut“ – auch für die geratene. Die Entscheidungsnotiz
    zur Ratewahrscheinlichkeit benennt die Ursache wörtlich: „Das Problem
    sitzt im Eingang (ein Klick ist keine Selbsteinschätzung), nicht in den
    Parametern.“ Genau diesen Eingang macht {@link alsGeratenWerten} auf.

    Die **Antwort** bleibt in beiden Fällen unantastbar; änderbar ist allein
    die Selbsteinschätzung.

    Was das kostet: Bricht das Programm zwischen Antwort und Weiterblättern
    ab, ist diese eine Buchung nicht geschrieben – derselbe Umfang wie ein
    Absturz während des Antwortens, und leichter als eine falsche Buchung,
    die niemand mehr findet.
  */
  const [ausstehend, setzeAusstehend] = useState<{
    readonly protokoll: Antwortprotokoll;
    readonly ansicht: SitzungsFrageAnsicht;
    /**
     * Ob nach dem Weiterblättern eine Wiedervorlage ansteht.
     *
     * Beim Antworten festgehalten und nicht beim Rendern nachgerechnet: Die
     * Zählung der Wiedereinreihungen liegt in einem Ref, und ein Ref darf
     * während des Renderns nicht gelesen werden.
     */
    readonly kommtWieder: boolean;
  } | null>(null);

  const bestaetigen = useCallback(() => {
    if (aktuelle === null || schritt !== 'antworten') {
      return;
    }
    const { frage } = aktuelle;

    if (!istAuswahlfrage(frage)) {
      setzeSchritt('selbstbewertung');
      setzeAnsage('Musterantwort wird angezeigt. Bitte bewerten Sie sich anschließend selbst.');
      return;
    }

    if (auswahl.length === 0) {
      setzeAnsage('Bitte wählen Sie zuerst eine Antwort aus.');
      return;
    }

    const neuesErgebnis = mcErgebnis(frage, auswahl);
    const neueBewertung = bewertungAusErgebnis(neuesErgebnis);
    setzeErgebnis(neuesErgebnis);
    setzeBewertung(neueBewertung);
    setzeSchritt('ergebnis');

    /* „Unvollständig“ zählt als nicht richtig und kommt deshalb ebenso
       wieder – dieselbe Grenze, nach der auch der Lernstand bucht. */
    const kommtWieder = !neuesErgebnis.richtig && kannWiedereinreihen(frage.id);
    const ansageText = mcAnsage(neuesErgebnis, richtigeLabels(frage));
    setzeAnsage(kommtWieder ? `${ansageText} ${WIEDERHOLUNGS_ANSAGE}` : ansageText);

    /*
      Zurückgehalten wie die Selbstbewertung – aber aus einem anderen Grund.

      Die **Antwort** bleibt unantastbar: Sie ist gegeben, das Ergebnis steht
      auf dem Bildschirm, und eine änderbare Auswahl wäre ein zweiter Versuch
      mit bekannter Lösung. Änderbar ist allein die **Selbsteinschätzung** –
      „Ich hatte geraten“ (siehe {@link alsGeratenWerten}). Dafür muss die
      Buchung so lange warten, wie sie sich noch ändern kann.
    */
    setzeAusstehend({
      protokoll: {
        frageId: frage.id,
        auswahl: [...auswahl],
        richtig: neuesErgebnis.richtig,
        bewertung: neueBewertung,
        dauerMs: Math.max(0, Date.now() - startzeit.current),
      },
      ansicht: aktuelle,
      kommtWieder,
    });
  }, [aktuelle, auswahl, kannWiedereinreihen, schritt]);

  const selbstBewerten = useCallback(
    (neueBewertung: Bewertung) => {
      if (aktuelle === null || schritt !== 'selbstbewertung') {
        return;
      }

      setzeBewertung(neueBewertung);
      setzeSchritt('ergebnis');

      const richtig = giltAlsRichtig(neueBewertung);
      const ansageText = selbstbewertungAnsage(BEWERTUNG_BEZEICHNUNG[neueBewertung]);
      /* Angesagt wird die Wiedervorlage schon jetzt – sie ist die Folge der
         Bewertung, die gerade gefallen ist. Eingereiht wird erst beim
         Weiterblättern, sonst stünde die Frage nach einer Korrektur zweimal
         in der Sitzung. */
      const kommtWieder = !richtig && kannWiedereinreihen(aktuelle.frage.id);
      setzeAnsage(kommtWieder ? `${ansageText} ${WIEDERHOLUNGS_ANSAGE}` : ansageText);

      setzeAusstehend({
        protokoll: {
          frageId: aktuelle.frage.id,
          auswahl: [],
          freitext,
          richtig,
          bewertung: neueBewertung,
          dauerMs: Math.max(0, Date.now() - startzeit.current),
        },
        ansicht: mitEigenerAntwort(aktuelle, freitext),
        kommtWieder,
      });
    },
    [aktuelle, freitext, kannWiedereinreihen, schritt],
  );

  /**
   * „Ich hatte geraten“ – die Selbsteinschätzung bei einer richtigen
   * Auswahlantwort nachreichen.
   *
   * **Warum das kein zweiter Anlauf der abgelehnten Automatismen ist.**
   * `docs/entscheidung-ratewahrscheinlichkeit.md` lehnt zwei **automatische**
   * Milderungen ab – eine konservative Ersteinstufung und einen globalen
   * Schwierigkeitsaufschlag – und benennt die Ursache wörtlich: „Das Problem
   * sitzt im Eingang (ein Klick ist keine Selbsteinschätzung), nicht in den
   * Parametern.“ Hier wird genau dieser Eingang geöffnet: Der Lernende
   * liefert die Einschätzung nach, auf die FSRS geeicht ist und die das
   * Programm bei den 104 offenen Fragen ohnehin einholt. Keine Vorgabe
   * ändert sich, kein Parameter wird verstellt, und wer nichts drückt, merkt
   * nichts.
   *
   * **Die Antwort bleibt richtig.** Verstellt wird ausschließlich die
   * Bewertung; `richtig` rechnet der Anwendungskern ohnehin selbst aus der
   * Auswahl nach. Das ist auch sachlich richtig: Angekreuzt war das
   * Richtige — gewusst war es nicht.
   */
  const alsGeratenWerten = useCallback(() => {
    setzeAusstehend((bisher) =>
      bisher === null
        ? null
        : {
            ...bisher,
            protokoll: { ...bisher.protokoll, bewertung: 'nochmal' },
            /* Wer sagt, dass er geraten hat, soll die Frage in dieser
               Sitzung noch einmal sehen – so wie bei jeder anderen
               „Nicht gewusst“-Antwort. */
            kommtWieder: kannWiedereinreihen(bisher.protokoll.frageId),
          },
    );
    setzeBewertung('nochmal');
    /*
      Der Satz sagt genau das, was geschieht – nicht mehr.

      Bis Fassung 0.24.1 stand hier „Ihr Reifegrad zählt sie nicht mit“. Das
      trifft nur auf eine Frage zu, die vorher noch keinen Beleg hatte: Der
      Anwendungskern verhindert, dass ein Zufallstreffer einen Beleg
      **setzt** (`lernstand.ts`, `belegtDieseAntwort`) – einen vorhandenen
      **nimmt** er nicht weg, denn das tut nur eine falsche Antwort. Für
      jemanden am Bildschirmleser ist diese Ansage die einzige Rückmeldung
      auf den Knopf; sie darf nicht das Gegenteil dessen behaupten, was der
      Kern tut.
    */
    setzeAnsage(
      'Als „Nicht gewusst“ gewertet. Die Frage kommt bald wieder, und als neuer Beleg für Ihren Reifegrad zählt sie nicht.',
    );
  }, [kannWiedereinreihen]);

  /** Zurück zur Selbstbewertung; die zurückgehaltene Buchung entfällt. */
  const bewertungAendern = useCallback(() => {
    if (ausstehend === null || schritt !== 'ergebnis') {
      return;
    }
    setzeAusstehend(null);
    setzeBewertung(null);
    setzeSchritt('selbstbewertung');
    setzeAnsage('Die Bewertung wurde zurückgenommen. Bitte bewerten Sie sich noch einmal.');
  }, [ausstehend, schritt]);

  /**
   * Schickt eine zurückgehaltene Buchung ab.
   *
   * @returns das gebuchte Protokoll, oder `null` – die Bilanz braucht es,
   *   weil `protokolle` erst nach dem nächsten Rendern nachgezogen ist.
   */
  const ausstehendeBuchen = useCallback(
    (mitWiedereinreihung: boolean): { protokoll: Antwortprotokoll; eingereiht: boolean } | null => {
      if (ausstehend === null) {
        return null;
      }
      setzeAusstehend(null);
      const eingereiht =
        mitWiedereinreihung && ausstehend.kommtWieder ? wiedereinreihen(ausstehend.ansicht) : false;
      protokollieren(ausstehend.protokoll);
      return { protokoll: ausstehend.protokoll, eingereiht };
    },
    [ausstehend, protokollieren, wiedereinreihen],
  );

  const weiter = useCallback(() => {
    const gebucht = ausstehendeBuchen(true);

    /* Eine Wiedereinreihung verlängert die Sitzung. Der Zustand ist in
       diesem Durchlauf noch nicht nachgezogen, deshalb wird hier gegen die
       Länge nach dem Buchen geprüft und nicht gegen die davor – sonst endete
       die Sitzung, obwohl gerade eine Frage angehängt wurde. */
    const laenge = gebucht?.eingereiht === true ? fragen.length + 1 : fragen.length;
    if (position + 1 >= laenge) {
      onBeenden(bilanzJetzt(gebucht?.protokoll ?? null));
      return;
    }
    setzePosition(position + 1);
    setzeAuswahl(LEERE_AUSWAHL);
    setzeFreitext('');
    setzeSchritt('antworten');
    setzeErgebnis(null);
    setzeBewertung(null);
    setzeAnsage('');
  }, [ausstehendeBuchen, bilanzJetzt, fragen.length, onBeenden, position]);

  const abbrechen = useCallback(() => {
    /* Auch beim Verlassen wird abgeschickt: Wer bewertet hat und dann zum
       Start geht, hat die Frage bearbeitet – sie darf nicht verfallen.
       Wiedereingereiht wird dabei nicht: Die Sitzung endet ja gerade. */
    const gebucht = ausstehendeBuchen(false);
    onBeenden(bilanzJetzt(gebucht?.protokoll ?? null));
  }, [ausstehendeBuchen, bilanzJetzt, onBeenden]);

  /** Ob nach dem Weiterblättern eine Wiedervorlage ansteht. */
  const wiedervorlageSteht = ausstehend?.kommtWieder === true;

  const merkenUmschalten = useCallback(() => {
    if (frageId === null) {
      return;
    }

    const merken = lernBruecke()?.lernMerken;
    if (!merken || profilId === null) {
      const meldung = kanalFehltMeldung(
        'lernen:merken',
        'Die Merkliste ist zurzeit nicht verfügbar.',
      );
      setzeSpeicherHinweis(meldung);
      setzeAnsage(meldung);
      return;
    }

    const neu = !gemerkte.has(frageId);
    setzeGemerkte(merklisteSetzen(gemerkte, frageId, neu));
    setzeAnsage(neu ? 'Frage gemerkt.' : 'Merken aufgehoben.');

    void merken(profilId, frageId, neu)
      .then((stand) => {
        setzeGemerkte((bisher) => merklisteSetzen(bisher, stand.frageId, stand.gemerkt));
      })
      .catch((fehler: unknown) => {
        setzeGemerkte((bisher) => merklisteSetzen(bisher, frageId, !neu));
        setzeSpeicherHinweis(
          fehlerMeldung(fehler, 'Die Merkliste konnte nicht gespeichert werden.'),
        );
      });
  }, [frageId, gemerkte, profilId]);

  return {
    ladezustand: laden.art,
    meldung: laden.art === 'leer' || laden.art === 'fehler' ? laden.meldung : null,
    hinweis: laden.art === 'bereit' ? laden.hinweis : null,
    speicherHinweis,
    /* Beide Zahlen zählen die zurückgehaltene Wiedervorlage mit. Sonst
       stünde im letzten Schritt „Sitzung auswerten“ auf dem Knopf, obwohl
       die Frage gleich noch einmal kommt – und „Frage 5 von 5“, obwohl
       sechs Vorlagen folgen. */
    gesamt: fragen.length + (wiedervorlageSteht ? 1 : 0),
    nummer: position + 1,
    letzte: position + 1 >= fragen.length && !wiedervorlageSteht,
    aktuelle,
    auswahl,
    freitext,
    schritt,
    ergebnis,
    bewertung,
    ansage,
    gemerkt: frageId !== null && gemerkte.has(frageId),
    optionWaehlen,
    optionAnPosition,
    freitextSetzen: setzeFreitext,
    bestaetigen,
    selbstBewerten,
    /* Nur angeboten, solange wirklich etwas zurückzunehmen ist – also im
       Ergebnisschritt einer offenen Frage. */
    bewertungAenderbar: ausstehend !== null && schritt === 'ergebnis' && ergebnis === null,
    bewertungAendern,
    /* Nur bei einer **richtigen** Auswahlantwort, die noch nicht als
       geraten gilt: Bei einer falschen gibt es nichts einzugestehen, und
       zweimal drücken soll nichts Weiteres tun. */
    alsGeratenMoeglich:
      ausstehend !== null &&
      schritt === 'ergebnis' &&
      ergebnis !== null &&
      ergebnis.richtig &&
      ausstehend.protokoll.bewertung !== 'nochmal',
    alsGeratenWerten,
    weiter,
    abbrechen,
    merkenUmschalten,
  };
}
