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

import { useFokusBeimAufbau } from '../../hooks/useFokusBeimAufbau';
import { bridge } from '../../bridge';
import { fehlerMeldung, kanalFehltMeldung } from '../../lernen/bruecke';
import type { Kennzahlen, Pruefergebnis } from '../../../../shared/sicherung';
import { OHNE_BEFUND_SATZ, verlustbefund, verlustsaetze } from '../../../../shared/verlust';
import { sicherungsstandSatz } from '../../lernen/zeitsaetze';
import { Dateiknoepfe } from './Dateiknoepfe';
import { Hinweis } from './Hinweis';

type Zustand =
  | { readonly art: 'bereit' }
  | { readonly art: 'sichert' }
  | { readonly art: 'prueft' }
  | { readonly art: 'rueckfrage'; readonly befund: Extract<Pruefergebnis, { art: 'brauchbar' }> }
  | { readonly art: 'spielt-ein' }
  | { readonly art: 'meldung'; readonly text: string; readonly kennung?: string | undefined }
  | { readonly art: 'fehler'; readonly meldung: string };

export interface DatensicherungProps {
  /**
   * Wohin die Oberfläche danach wechseln muss.
   *
   * Nach dem Ersetzen ist es das erste Profil der eingespielten Datei; nach
   * dem Übernehmen das dazugekommene. In beiden Fällen zwingend: Die zuletzt
   * gewählte Nummer überlebt ein Neuladen in einem Ref, und eine Nummer, die
   * es zufällig auch in der neuen Lage gibt, führte geradewegs woandershin.
   */
  readonly onEingespielt: (profilId: number) => void;
}

/**
 * Lernstand sichern und übertragen.
 *
 * ## Warum die beiden Wege ungleich heißen
 *
 * „Sicherung anlegen“ und „Sicherung einspielen“ wären sich zu ähnlich –
 * gleicher Anfang, gleicher Rhythmus, und der gefährlichste Bedienfehler ist
 * die falsche **Richtung**. Hier heißen sie „Sicherung speichern …“ und
 * „Sicherung auswählen und ansehen …“. Der zweite Name verspricht außerdem
 * nur das Ansehen – und das stimmt: Ersetzt wird erst nach der Rückfrage.
 *
 * ## Warum erst gezeigt und dann gefragt wird
 *
 * Der Datei sieht niemand an, von wann sie ist. Die Rückfrage nennt deshalb
 * beide Seiten mit denselben Zahlen: was in der Datei steht und was gerade da
 * ist. Sie erscheint erst nach bestandener Prüfung – eine Rückfrage zu einer
 * Datei, die gar nicht einspielbar ist, wäre eine Zumutung.
 *
 * ## Warum kein `disabled`
 *
 * Während eines Laufs trägt der Knopf `aria-disabled` und der Klickbehandler
 * kehrt früh zurück. Ein gesperrtes Element verliert den Fokus, und der fällt
 * auf `<body>` – dieselbe Begründung wie in `Lernberichtausgabe.tsx`.
 */
export function Datensicherung({ onEingespielt }: DatensicherungProps): React.JSX.Element {
  const [zustand, setzeZustand] = useState<Zustand>({ art: 'bereit' });
  const sichernRef = useRef<HTMLButtonElement | null>(null);
  const einlesenRef = useRef<HTMLButtonElement | null>(null);

  /*
    Der Weg zurück aus der Rückfrage.

    `einlesenRef.current?.focus()` im Klickbehandler genügt hier **nicht**:
    Solange die Rückfrage steht, ist der Knopf mit dieser Referenz gar nicht
    im Baum – `current` ist `null`, der Aufruf verpufft, und der Fokus fällt
    auf `<body>`. An den übrigen drei Stellen trifft das nicht zu, dort ist
    der Knopf während des Vorgangs sichtbar.

    Deshalb dieselbe Rückruf-Referenz wie in `Unterbrochen.tsx`: Der Knopf
    meldet sich, sobald er wieder da ist, und nimmt den Fokus im selben Zug
    mit.
  */
  const [fokusZurueck, setzeFokusZurueck] = useState(false);
  const einlesenKnopf = (element: HTMLButtonElement | null): void => {
    einlesenRef.current = element;
    if (element !== null && fokusZurueck) {
      element.focus();
      setzeFokusZurueck(false);
    }
  };

  /*
    Wann zuletzt gesichert wurde.

    `undefined` heißt „noch nicht nachgesehen“, `null` heißt „noch nie
    gesichert“. Der Unterschied ist wichtig: Beim Laden darf hier nichts
    stehen, und „noch nie“ ist eine Aussage, kein fehlender Wert.

    Gelesen wird der Vermerk hier und nicht über den gemeinsamen
    Einstellungs-Hook: Er ändert sich genau an dieser Stelle, und ein Wert,
    der durch die halbe Anwendung gereicht wird, damit ihn eine Karte anzeigt,
    wäre eine Leitung ohne Verbraucher.
  */
  /* Ohne Kanal wird nie etwas nachgeladen – dann ist der Anfangswert schon
     der Endwert. Das gleich hier zu entscheiden erspart einen
     Zustandswechsel aus dem Effekt heraus und damit eine überflüssige
     Renderrunde; dasselbe Muster wie in `useEinstellungen`. */
  const [letzteSicherung, setzeLetzteSicherung] = useState<string | null | undefined>(() =>
    bridge()?.einstellungenLesen === undefined ? null : undefined,
  );

  const sicherungsstandLesen = useCallback(() => {
    const lesen = bridge()?.einstellungenLesen;
    if (lesen === undefined) {
      return;
    }
    void lesen()
      .then((werte) => {
        setzeLetzteSicherung(werte.letzteSicherung ?? null);
      })
      .catch(() => {
        /* Ohne Auskunft lieber gar nichts behaupten als ein Datum erfinden. */
        setzeLetzteSicherung(null);
      });
  }, []);

  useEffect(() => {
    sicherungsstandLesen();
  }, [sicherungsstandLesen]);

  /**
   * Ein Profil dazunehmen.
   *
   * Getrennt von {@link einspielen}, weil es etwas grundlegend anderes tut:
   * Hier wird in die lebende Datenbank geschrieben statt eine Datei
   * getauscht. Ein Rückweg über das Zurücktauschen gibt es nicht – nur die
   * Sicherheitskopie, die der Anwendungskern unter eigenem Namen anlegt.
   */
  const uebernehmen = useCallback(
    (vorgang: string, profilIndex: number) => {
      const kanal = bridge()?.sicherungUebernehmen;
      if (!kanal) {
        setzeZustand({
          art: 'fehler',
          meldung: kanalFehltMeldung(
            'sicherung:uebernehmen',
            'Das Profil kann nicht übernommen werden.',
          ),
        });
        return;
      }

      setzeZustand({ art: 'spielt-ein' });
      void kanal(vorgang, profilIndex)
        .then((ergebnis) => {
          if (ergebnis.art === 'uebernommen') {
            setzeZustand({
              art: 'meldung',
              text:
                `Das Profil „${ergebnis.name}“ wurde übernommen: ` +
                `${String(ergebnis.antworten)} ${ergebnis.antworten === 1 ? 'Antwort' : 'Antworten'} ` +
                `zu ${String(ergebnis.bearbeiteteFragen)} verschiedenen Fragen. ` +
                `Ihre übrigen Profile sind ` +
                `unverändert. Eine Sicherung Ihres vorigen Standes liegt als ` +
                `„${ergebnis.sicherheitskopie}“ bereit.`,
            });
            onEingespielt(ergebnis.profilId);
            return;
          }
          setzeZustand({
            art: 'fehler',
            meldung:
              ergebnis.art === 'abgelaufen'
                ? 'Die geprüfte Datei ist nicht mehr gültig. Bitte wählen Sie sie erneut.'
                : ergebnis.grund,
          });
        })
        .catch((fehler: unknown) => {
          setzeZustand({
            art: 'fehler',
            meldung: fehlerMeldung(fehler, 'Das Profil konnte nicht übernommen werden.'),
          });
        });
    },
    [onEingespielt],
  );

  const laeuft =
    zustand.art === 'sichert' || zustand.art === 'prueft' || zustand.art === 'spielt-ein';

  /**
   * Öffnet den Ordner mit den selbsttätigen Kopien im Dateimanager.
   *
   * Der Weg dorthin fehlte bis 0.26.7 vollständig: Die Karte erzählte von
   * der Kopie, aber weder eine Liste noch ein Dateidialog führte hin, und
   * der Ordnername kam in der ganzen Oberfläche nicht vor. Wer eine Kopie
   * im Ernstfall brauchte, musste den Pfad kennen.
   */
  function ordnerOeffnen(): void {
    const zeigen = bridge()?.sicherungsordnerZeigen;
    if (zeigen === undefined) {
      setzeZustand({
        art: 'fehler',
        meldung: kanalFehltMeldung('sicherung:ordner-zeigen', 'Der Ordner ließ sich nicht öffnen.'),
      });
      return;
    }

    void zeigen().then(
      (geoeffnet) => {
        setzeZustand(
          geoeffnet
            ? { art: 'meldung', text: 'Der Ordner ist geöffnet.' }
            : {
                art: 'meldung',
                text:
                  'Den Ordner gibt es noch nicht – er entsteht mit der ersten ' +
                  'selbsttätigen Kopie beim Beenden.',
              },
        );
      },
      () => {
        setzeZustand({ art: 'fehler', meldung: 'Der Ordner ließ sich nicht öffnen.' });
      },
    );
  }

  function sichern(): void {
    const anlegen = bridge()?.sicherungAnlegen;
    if (anlegen === undefined) {
      setzeZustand({
        art: 'fehler',
        meldung: kanalFehltMeldung('sicherung:anlegen', 'Es ließ sich keine Sicherung anlegen.'),
      });
      return;
    }
    setzeZustand({ art: 'sichert' });
    void anlegen()
      .then((ergebnis) => {
        setzeZustand(
          ergebnis.gespeichert
            ? {
                art: 'meldung',
                text: `Die Sicherung ist geschrieben: ${ergebnis.pfad ?? ''}`,
                kennung: ergebnis.dateiKennung,
              }
            : /* Ein Abbruch im Systemdialog ist eine Entscheidung, kein
                 Ereignis – dazu gibt es keine Meldung. */
              { art: 'bereit' },
        );
        if (ergebnis.gespeichert) {
          sicherungsstandLesen();
        }
        sichernRef.current?.focus();
      })
      .catch((fehler: unknown) => {
        setzeZustand({
          art: 'fehler',
          meldung: fehlerMeldung(fehler, 'Die Sicherung ließ sich nicht schreiben.'),
        });
      });
  }

  function pruefen(): void {
    const pruefenKanal = bridge()?.sicherungPruefen;
    if (pruefenKanal === undefined) {
      setzeZustand({
        art: 'fehler',
        meldung: kanalFehltMeldung(
          'sicherung:pruefen',
          'Die Datei ließ sich nicht prüfen. Es wurde nichts verändert.',
        ),
      });
      return;
    }
    setzeZustand({ art: 'prueft' });
    void pruefenKanal()
      .then((ergebnis) => {
        if (ergebnis.art === 'brauchbar') {
          setzeZustand({ art: 'rueckfrage', befund: ergebnis });
          return;
        }
        setzeZustand(
          ergebnis.art === 'abgelehnt'
            ? { art: 'fehler', meldung: ergebnis.grund }
            : { art: 'bereit' },
        );
        einlesenRef.current?.focus();
      })
      .catch((fehler: unknown) => {
        setzeZustand({
          art: 'fehler',
          meldung: fehlerMeldung(
            fehler,
            'Die Datei ließ sich nicht prüfen. Es wurde nichts verändert.',
          ),
        });
      });
  }

  function einspielen(vorgang: string): void {
    const kanal = bridge()?.sicherungEinspielen;
    if (kanal === undefined) {
      setzeZustand({
        art: 'fehler',
        meldung: kanalFehltMeldung(
          'sicherung:einspielen',
          'Es wurde nichts eingelesen und nichts verändert.',
        ),
      });
      return;
    }
    setzeZustand({ art: 'spielt-ein' });
    void kanal(vorgang)
      .then((ergebnis) => {
        if (ergebnis.art === 'eingespielt') {
          onEingespielt(ergebnis.ersteProfilId);
          setzeZustand({
            art: 'meldung',
            text:
              `Der Lernstand ist eingelesen: ${zahl(ergebnis.profilzahl)} ` +
              `${ergebnis.profilzahl === 1 ? 'Profil' : 'Profile'}, ` +
              `${zahl(ergebnis.bearbeiteteFragen)} verschiedene Fragen bearbeitet. ` +
              `Ihr bisheriger Stand liegt als ` +
              `Sicherung unter ${ergebnis.sicherheitskopie}.`,
          });
        } else if (ergebnis.art === 'abgelaufen') {
          setzeZustand({
            art: 'fehler',
            meldung:
              'Die Auswahl ist verfallen. Bitte wählen Sie die Datei noch einmal aus. ' +
              'Es wurde nichts verändert.',
          });
        } else {
          setzeZustand({ art: 'fehler', meldung: ergebnis.grund });
        }
        einlesenRef.current?.focus();
      })
      .catch((fehler: unknown) => {
        /* Auch hier der Vorbehalt zuerst: Wer eine Fehlermeldung liest, will
           als Erstes wissen, ob sein Lernstand noch da ist. */
        setzeZustand({
          art: 'fehler',
          meldung: fehlerMeldung(
            fehler,
            'Das Einlesen ist gescheitert. Ihr bisheriger Lernstand ist unverändert.',
          ),
        });
      });
  }

  return (
    <div className="sicherung">
      <p>
        Ihr Lernstand liegt nur auf diesem Rechner. Eine Sicherung schreibt ihn vollständig in eine
        einzige Datei: alle Profile, alle Antworten, die Merklisten, die Prüfungstermine und den
        Verlauf Ihrer Prüfungssimulationen. Diese Datei können Sie aufheben, auf einen Stick
        kopieren oder auf einen neuen Rechner mitnehmen. Übertragen wird nichts von selbst – die
        Datei geht nur dorthin, wohin Sie sie legen.
      </p>
      <p>
        Ihre Einstellungen – Farbschema, Anzeigegröße, Vorlesen – stehen nicht darin; die sind auf
        dem neuen Rechner in einer Minute wieder eingestellt. Der Fragenkatalog auch nicht: Er
        gehört zum Programm. Die Datei ist nicht verschlüsselt – wer sie hat, kann sie lesen.
      </p>
      <p>
        Einmal wöchentlich legt das Programm beim Beenden zusätzlich eine Kopie ab und behält die
        drei jüngsten. Sie liegen im Unterordner <code>sicherungen</code> neben Ihrer
        Lernstandsdatei; wo diese liegt, sagt Abschnitt 4 der Datenschutzerklärung unter „Über diese
        Software“. Die Kopie hilft, wenn die Lernstandsdatei beschädigt wird – aber sie liegt auf
        derselben Festplatte und ersetzt deshalb keine Sicherung, die Sie anderswo aufheben.
      </p>
      <p>
        Vor den beiden Schritten, die etwas vernichten – „Neu anfangen“ und das Löschen eines
        Lernprofils – entsteht ebenfalls eine Kopie im selben Ordner. Lässt sie sich nicht
        schreiben, unterbleibt der Schritt.
      </p>
      <p>
        <button type="button" className="knopf knopf--leise" onClick={ordnerOeffnen}>
          Ordner der Sicherheitskopien öffnen
        </button>
      </p>

      <h3 className="sicherung__titel">Sicherung speichern</h3>
      <p>
        Empfohlen vor jedem Rechnerwechsel und ab und zu nebenbei. Die Datei bekommt Datum und
        Uhrzeit in den Namen. Es wird nichts verändert und nichts überschrieben – es entsteht nur
        eine Datei.
      </p>
      {letzteSicherung === undefined ? null : (
        <p className="sicherung__stand">{sicherungsstandSatz(letzteSicherung)}</p>
      )}
      <div>
        <button
          type="button"
          className="knopf"
          ref={sichernRef}
          aria-disabled={laeuft}
          onClick={() => {
            if (!laeuft) {
              sichern();
            }
          }}
        >
          {zustand.art === 'sichert' ? 'Die Sicherung wird geschrieben …' : 'Sicherung speichern …'}
        </button>
      </div>

      <h3 className="sicherung__titel">Sicherung einlesen</h3>
      <p>
        Für den neuen Rechner – oder wenn Sie zu einem früheren Stand zurückwollen. Sie sehen
        zuerst, was in der Datei steht, und entscheiden erst danach zwischen zwei Wegen:{' '}
        <strong>ersetzen</strong> – Ihr ganzer Lernstand tritt zurück und der aus der Datei an seine
        Stelle – oder <strong>ein einzelnes Profil dazunehmen</strong>, wobei alles Vorhandene
        bleibt, wie es ist. Vorher legt die Anwendung in beiden Fällen von selbst eine Sicherung
        Ihres jetzigen Standes an.
      </p>

      {zustand.art === 'rueckfrage' ? (
        <Rueckfrage
          befund={zustand.befund}
          onJa={() => {
            einspielen(zustand.befund.vorgang);
          }}
          onUebernehmen={(profilIndex) => {
            uebernehmen(zustand.befund.vorgang, profilIndex);
          }}
          onNein={() => {
            setzeZustand({ art: 'bereit' });
            setzeFokusZurueck(true);
          }}
        />
      ) : (
        <div>
          <button
            type="button"
            className="knopf"
            ref={einlesenKnopf}
            aria-disabled={laeuft}
            onClick={() => {
              if (!laeuft) {
                pruefen();
              }
            }}
          >
            {zustand.art === 'prueft'
              ? 'Die Datei wird geprüft …'
              : zustand.art === 'spielt-ein'
                ? 'Wird eingelesen …'
                : 'Sicherung auswählen und ansehen …'}
          </button>
        </div>
      )}

      <div role="status" aria-live="polite" className="sicherung__meldung">
        {zustand.art === 'meldung' ? <Hinweis art="hinweis">{zustand.text}</Hinweis> : null}
        {zustand.art === 'fehler' ? <Hinweis art="fehler">{zustand.meldung}</Hinweis> : null}
      </div>

      {/* Nach dem Sichern der häufigste nächste Schritt: die Datei auf einen
          Stick ziehen. Bis 0.22.0 hieß das, den Pfad abzulesen und im
          Explorer selbst hinzunavigieren. */}
      {zustand.art === 'meldung' ? <Dateiknoepfe kennung={zustand.kennung} /> : null}
    </div>
  );
}

/**
 * Die Rückfrage vor dem Ersetzen.
 *
 * Eigener Baustein, damit der Fokus beim Aufbau greift. Im Textfluss statt in
 * einem modalen Dialog: Der Bereich holt den Fokus, fängt ihn aber nicht ein
 * (WCAG 2.1.2), und erfüllt WCAG 3.3.4.
 *
 * Der entscheidende Satz steht in der Beschreibung, die Zahlen stehen als
 * gewöhnlicher Inhalt daneben. Eine Beschreibung von zwölf Zeilen hört sich
 * niemand bis zum Ende an, und das Wichtigste stünde darin an letzter Stelle.
 */
function Rueckfrage({
  befund,
  onJa,
  onUebernehmen,
  onNein,
}: {
  readonly befund: Extract<Pruefergebnis, { art: 'brauchbar' }>;
  readonly onJa: () => void;
  readonly onUebernehmen: (profilIndex: number) => void;
  readonly onNein: () => void;
}): React.JSX.Element {
  const bereichRef = useFokusBeimAufbau<HTMLDivElement>();
  const titelId = useId();
  const verlust = useMemo(
    () => verlustbefund(befund.jetzt, befund.ausDatei),
    [befund.ausDatei, befund.jetzt],
  );
  const wahlId = useId();
  const [profilIndex, setzeProfilIndex] = useState(0);

  return (
    <div
      className="sicherung__rueckfrage"
      ref={bereichRef}
      tabIndex={-1}
      role="group"
      aria-labelledby={titelId}
      aria-describedby={`${titelId}-text`}
    >
      <h4 className="sicherung__rueckfragetitel" id={titelId}>
        Diesen Lernstand durch die Sicherung ersetzen?
      </h4>
      <p id={`${titelId}-text`}>
        Ihr jetziger Lernstand wird durch den aus der Datei ersetzt. Es wird nichts zusammengeführt.
        Rückgängig machen lässt sich das nur über die Sicherung, die die Anwendung unmittelbar
        vorher selbst anlegt.
      </p>

      <dl className="sicherung__gegenueber">
        <dt>In der Datei {befund.dateiname}</dt>
        <dd>{beschreibung(befund.ausDatei)}</dd>
        <dt>Auf diesem Rechner</dt>
        <dd>{beschreibung(befund.jetzt)}</dd>
      </dl>

      {/*
        Die Verlustanzeige.

        Zwei Gesamtzahlen nebeneinander genügen nicht: Wer hier zwei Profile
        mit zusammen 1.000 Antworten hat und eine Datei mit einem Profil und
        1.200 einliest, liest „mehr“ – und verliert trotzdem eines seiner
        beiden Profile vollständig. Verglichen wird deshalb je Profil, über
        den Namen.

        Der unterbrochene Bogen steckt mit darin; er ist das einzige, was sich
        nicht in einer Zahl ausdrücken lässt.
      */}
      {verlust.ohneBefund ? (
        <Hinweis art="hinweis">{OHNE_BEFUND_SATZ}</Hinweis>
      ) : (
        <Hinweis art="fehler" blockinhalt>
          <p>Das geht durch das Einlesen verloren:</p>
          <ul className="sicherung__verlust">
            {verlustsaetze(verlust).map((satz) => (
              <li key={satz}>{satz}</li>
            ))}
          </ul>
          <p>
            Nicht verglichen wurden Prüfungstermin und Kapitelabwahl – beide werden mitersetzt, und
            die Kapitelabwahl verändert Ihren angezeigten Lernstand, ohne dass eine Antwort verloren
            geht.
          </p>
        </Hinweis>
      )}

      {/*
        Der dritte Weg: ein einzelnes Profil dazunehmen, ohne etwas zu
        ersetzen. Er steht bewusst VOR dem Ersetzen und trägt die harmlosere
        Beschriftung – wer nur den Stand vom anderen Gerät braucht, soll hier
        stehenbleiben und nicht bis zur roten Schaltfläche weiterlesen.

        Gewählt wird über den Index in die Liste, die der Anwendungskern
        geliefert hat, nie über eine Profilnummer: Die Nummern der fremden
        Datei kennt der Renderer nicht, und er soll sie nicht kennen.
      */}
      {befund.ausDatei.jeProfil.length > 0 ? (
        <div className="sicherung__uebernehmen">
          <h5 className="sicherung__rueckfragetitel">Oder nur ein Profil dazunehmen</h5>
          <p>
            Ihr jetziger Lernstand bleibt vollständig erhalten; das gewählte Profil kommt als
            weiteres dazu. Zusammengerechnet wird nichts – haben Sie hier und in der Datei dasselbe
            Profil, stehen danach beide nebeneinander.
          </p>
          <div className="sicherung__profilwahl">
            <label htmlFor={wahlId}>Profil aus der Datei</label>
            <select
              id={wahlId}
              value={String(profilIndex)}
              onChange={(ereignis) => {
                setzeProfilIndex(Number(ereignis.target.value));
              }}
            >
              {befund.ausDatei.jeProfil.map((profil, stelle) => (
                <option key={profil.name} value={String(stelle)}>
                  {profil.name} ({profil.antworten}{' '}
                  {profil.antworten === 1 ? 'Antwort' : 'Antworten'})
                </option>
              ))}
            </select>
          </div>
          <button
            type="button"
            className="knopf"
            onClick={() => {
              onUebernehmen(profilIndex);
            }}
          >
            Dieses Profil dazunehmen
          </button>
        </div>
      ) : null}

      <div className="sicherung__aktionen">
        {/* Das Abbrechen steht zuerst und ist die Hauptschaltfläche: Wer aus
            Versehen hier gelandet ist, trifft mit der Eingabetaste das
            Harmlose. */}
        <button type="button" className="knopf knopf--haupt" onClick={onNein}>
          Nein, meinen Lernstand behalten
        </button>
        <button type="button" className="knopf knopf--warnung" onClick={onJa}>
          Ja, alles ersetzen
        </button>
      </div>
    </div>
  );
}

/** Ein Satz, der eine Datenbank beziffert. */
function beschreibung(kennzahlen: Kennzahlen): string {
  const namen =
    kennzahlen.profilnamen.length <= 5
      ? kennzahlen.profilnamen.join(', ')
      : `${kennzahlen.profilnamen.slice(0, 5).join(', ')} und ${zahl(
          kennzahlen.profilnamen.length - 5,
        )} weitere`;

  const teile = [
    `${zahl(kennzahlen.profilnamen.length)} ${kennzahlen.profilnamen.length === 1 ? 'Profil' : 'Profile'}: ${namen}`,
    /* „Antworten“, nicht „beantwortete Fragen“: Es sind Zeilen im Protokoll,
       also auch Wiederholungen derselben Frage. Die Zahl zählt seit 0.19.0
       keine nie aufgeschlagenen Prüfungsfragen mehr mit (siehe
       `kennzahlenLesen`), aber eine Frage kann darin mehrfach stehen. */
    `${zahl(kennzahlen.antworten)} ${kennzahlen.antworten === 1 ? 'Antwort' : 'Antworten'} (mit Wiederholungen)`,
    `${zahl(kennzahlen.gemerkt)} gemerkt`,
  ];
  if (kennzahlen.pruefungslaeufe > 0) {
    teile.push(
      `${zahl(kennzahlen.pruefungslaeufe)} ${kennzahlen.pruefungslaeufe === 1 ? 'Prüfungslauf' : 'Prüfungsläufe'}`,
    );
  }
  if (kennzahlen.letzteAntwort !== null) {
    teile.push(`zuletzt gelernt am ${datum(kennzahlen.letzteAntwort)}`);
  }
  return `${teile.join(' · ')}.`;
}

function zahl(wert: number): string {
  return wert.toLocaleString('de-DE');
}

function datum(iso: string): string {
  const zeit = Date.parse(iso);
  if (Number.isNaN(zeit)) {
    return iso;
  }
  return new Date(zeit).toLocaleDateString('de-DE', {
    day: '2-digit',
    month: '2-digit',
    year: 'numeric',
  });
}
