/**
 * Sicherung anlegen und einspielen – der Ablauf mit Dialogen.
 *
 * Die reine Datei- und Datenbankarbeit steht in `sicherung.ts` und ist dort
 * ohne Electron prüfbar. Hier steht, in welcher **Reihenfolge** sie geschieht,
 * und die Reihenfolge ist beim Einspielen der ganze Schutz.
 *
 * ## Warum das Einspielen zwei Kanäle braucht
 *
 * Zwischen „diese Datei ist brauchbar, sie enthält Folgendes“ und „ja, ersetze
 * meinen Lernstand damit“ liegt eine Entscheidung des Nutzers. Ein einziger
 * Kanal müsste sie im Hauptprozess abwarten – und träfe sie damit im selben
 * Aufruf, in dem er schon schreibt. Getrennt ist der Prüfschritt folgenlos:
 * Bis der zweite Kanal gerufen wird, ist nichts angefasst.
 *
 * ## Die gefährlichste Einzelheit
 *
 * Ein verwaistes `lernstand.db-wal` neben einer frisch eingesetzten Datenbank
 * ist der einzige gemessene Weg, in diesem Vorgang wirklich Daten zu
 * vernichten: SQLite spielt das alte Schreibprotokoll auf die neue Datei, der
 * eingespielte Stand ist danach **vollständig fort**, und `integrity_check`
 * meldet „ok“. Kein Dateikopf und keine Prüfsumme schützen davor – nur die
 * Reihenfolge in {@link einspielen}: erst schliessen, dann sicherstellen, dass
 * die Nebendateien wirklich weg sind, und nur dann ersetzen.
 */

import { existsSync, copyFileSync, renameSync, readdirSync, statSync, unlinkSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';

import { app, dialog, type BrowserWindow } from 'electron';

import { geschriebenMerken } from './dateizugriff';
import { einstellungenSchreiben } from './einstellungen';
import { laeuftPruefung } from './laufwaechter';
import { lernstandInstanz, lernstandSchliessen, type Lernstand } from './lernstand';
import { profilUebernehmen } from './profil-uebernehmen';
import { pruefungZuruecksetzen } from './pruefung';
import {
  aufraeumen,
  dateiPruefen,
  HOECHSTGROESSE,
  kennzahlenLesen,
  megabyte,
  nebendateienAufraeumen,
  sicherungsDateiname,
  sicherungSchreiben,
  type DatenbankKonstruktor,
} from './sicherung';
import type {
  Einspielergebnis,
  Kennzahlen,
  Pruefergebnis,
  Sicherungsergebnis,
  Uebernahmeergebnis,
} from '../shared/sicherung';
import type { Katalog } from '../shared/katalog';

/** Arbeitskopie der gewählten Datei, während sie geprüft wird. */
const PRUEFKOPIE = 'einspielen-pruefkopie.db';

/** Vorsatz der selbsttätigen Sicherheitskopie vor dem Einspielen. */
const VOR_DEM_EINSPIELEN = 'Lernstand-vor-dem-Einspielen';

/** So viele Sicherheitskopien bleiben liegen. */
const KOPIEN_BEHALTEN = 3;

/** Nach dieser Zeit verfällt eine geprüfte Datei und muss neu gewählt werden. */
const KENNUNG_GUELTIG_MS = 10 * 60 * 1000;

interface OffenerVorgang {
  readonly kennung: string;
  readonly arbeitskopie: string;
  readonly groesse: number;
  readonly mtimeMs: number;
  readonly bis: number;
  /**
   * Die Profilnummern in der Datei, in derselben Reihenfolge wie
   * `Kennzahlen.jeProfil`.
   *
   * Der Renderer wählt über den **Index** in diese Liste, nie über eine
   * Nummer. Dieselbe Überlegung wie beim Pfad: Wer eine Nummer mitbringen
   * darf, darf auch eine andere mitbringen – und in einer fremden Datei
   * führte sie geradewegs in das Profil einer anderen Person.
   */
  readonly profilIds: readonly number[];
}

/*
  Genau ein Vorgang zur Zeit. Der Renderer bekommt nur die Kennung und den
  blossen Dateinamen, nie einen Pfad – dieselbe Überlegung wie beim
  unterbrochenen Prüfungsbogen: Wer einen Pfad mitbringen darf, darf auch
  einen anderen mitbringen.
*/
let offen: OffenerVorgang | null = null;

export interface Umgebung {
  readonly lernstand: () => Lernstand;
  readonly katalog: () => Katalog;
  readonly Datenbank: DatenbankKonstruktor;
  readonly jetzt?: () => Date;
}

function userData(): string {
  return app.getPath('userData');
}

function lernstandPfad(): string {
  return join(userData(), 'lernstand.db');
}

/** Gleicher Pfad? Unter Windows entscheidet die Schreibweise nicht mit. */
function selbeDatei(a: string, b: string): boolean {
  const norm = (pfad: string): string =>
    process.platform === 'win32' ? resolve(pfad).toLowerCase() : resolve(pfad);
  return norm(a) === norm(b);
}

// ── Sicherung anlegen ──────────────────────────────────────────────────

export async function sicherungAnlegen(
  elternfenster: BrowserWindow | null,
  umgebung: Umgebung,
): Promise<Sicherungsergebnis> {
  const jetzt = umgebung.jetzt?.() ?? new Date();
  const vorschlag = join(app.getPath('documents'), sicherungsDateiname(jetzt));

  const optionen: Electron.SaveDialogOptions = {
    title: 'Lernstand sichern',
    defaultPath: vorschlag,
    buttonLabel: 'Sichern',
    filters: [{ name: 'Lernstand-Sicherung', extensions: ['wsklernstand'] }],
    properties: ['showOverwriteConfirmation', 'createDirectory'],
  };
  const auswahl = await (elternfenster === null
    ? dialog.showSaveDialog(optionen)
    : dialog.showSaveDialog(elternfenster, optionen));

  if (auswahl.canceled || auswahl.filePath === '') {
    return { gespeichert: false, pfad: null, bytes: 0 };
  }

  /* Auf die laufende Datenbank zu sichern hiesse, sie mit sich selbst zu
     überschreiben. Der Systemdialog kennt diesen Fall nicht. */
  if (selbeDatei(auswahl.filePath, lernstandPfad())) {
    throw new Error(
      'Dorthin lässt sich nicht sichern: Das ist der laufende Lernstand selbst. ' +
        'Bitte wählen Sie einen anderen Ort.',
    );
  }

  /*
    Die alte Sicherung bleibt liegen, bis die neue vollständig dasteht.

    Bis 0.24.1 stand hier `aufraeumen(auswahl.filePath)` mit der Begründung,
    `VACUUM INTO` weigere sich, ein vorhandenes Ziel zu überschreiben. Die
    Begründung trägt nicht: `sicherungSchreiben` schreibt nach `<ziel>.teil`,
    prüft die fertige Datei und schiebt sie erst dann mit `renameSync` an
    ihren Platz – und `renameSync` ersetzt ein vorhandenes Ziel unteilbar
    (nachgemessen). Das Löschen davor war also nicht nötig, aber zerstörend:
    Scheiterte danach irgendetwas – voller Datenträger, abgezogener
    USB-Stick, eine Gegenprobe, die die frische Datei ablehnt –, stand der
    Nutzer ohne die alte UND ohne die neue Sicherung da. Die Zustimmung im
    Systemdialog gilt dem Überschreiben, nicht dem Verlust von beidem.

    Weggeräumt werden nur die Nebendateien: Ein verwaistes Schreibprotokoll
    neben der neuen Datei ist der einzige gemessene Weg, in diesem Vorgang
    wirklich Daten zu vernichten (siehe Modulkopf von `main/sicherung.ts`).
    Sie gehören nicht zur Sicherung und tragen nichts, was verloren gehen
    könnte.
  */
  nebendateienAufraeumen(auswahl.filePath);

  const bytes = sicherungSchreiben(
    umgebung.lernstand().datenbank,
    auswahl.filePath,
    umgebung.Datenbank,
  );

  /* Vermerken, wann zuletzt gesichert wurde – sonst kann die Karte nicht
     sagen, ob es überhaupt je geschah, und eine Erinnerung wäre geraten
     statt gewusst. Das Vermerken darf den Vorgang nicht zu Fall bringen:
     Die Sicherung liegt zu diesem Zeitpunkt bereits vollständig da. */
  try {
    einstellungenSchreiben({ letzteSicherung: jetzt.toISOString() });
  } catch (fehler: unknown) {
    console.warn('[sicherung] Zeitpunkt konnte nicht vermerkt werden:', fehler);
  }

  return {
    gespeichert: true,
    pfad: auswahl.filePath,
    bytes,
    dateiKennung: geschriebenMerken(auswahl.filePath),
  };
}

// ── Einspielen, erste Hälfte: prüfen ───────────────────────────────────

export async function sicherungPruefen(
  elternfenster: BrowserWindow | null,
  umgebung: Umgebung,
): Promise<Pruefergebnis> {
  restePruefen();

  /* Vor jedem Dialog: Läuft ein Prüfungsbogen, gibt es eine Antwort und keinen
     gesperrten Knopf. */
  if (elternfenster !== null && laeuftPruefung(elternfenster)) {
    return {
      art: 'abgelehnt',
      grund:
        'Es läuft gerade eine Prüfungssimulation. Bitte beenden Sie sie zuerst – ' +
        'ein Wechsel des Lernstands mittendrin verlöre den angefangenen Bogen. ' +
        'Es wurde nichts verändert.',
    };
  }

  const optionen: Electron.OpenDialogOptions = {
    title: 'Sicherung auswählen',
    buttonLabel: 'Ansehen',
    properties: ['openFile'],
    filters: [
      { name: 'Lernstand-Sicherung', extensions: ['wsklernstand'] },
      /* „Alle Dateien“ steht bewusst daneben: Mailprogramme und
         Downloadordner benennen Anhänge um. Über die Brauchbarkeit
         entscheidet der Inhalt, nie die Endung. */
      { name: 'Alle Dateien', extensions: ['*'] },
    ],
  };
  const auswahl = await (elternfenster === null
    ? dialog.showOpenDialog(optionen)
    : dialog.showOpenDialog(elternfenster, optionen));

  const gewaehlt = auswahl.filePaths[0];
  if (auswahl.canceled || gewaehlt === undefined) {
    /* Ein Abbruch ist eine Entscheidung, kein Ereignis – wie beim
       Lernbericht gibt es dazu keine Meldung. */
    return { art: 'abgebrochen' };
  }

  if (selbeDatei(gewaehlt, lernstandPfad())) {
    return {
      art: 'abgelehnt',
      grund: 'Das ist der laufende Lernstand selbst, keine Sicherung. Es wurde nichts verändert.',
    };
  }

  /*
    Ab hier wird ausschliesslich auf einer Arbeitskopie im eigenen Verzeichnis
    gearbeitet. Zwei Gründe: Jedes Öffnen einer WAL-Datenbank legt `-wal` und
    `-shm` daneben und lässt sie liegen – auf dem USB-Stick des Nutzers. Und
    die geprüften Bytes sind dann genau die eingesetzten; die gewählte Datei
    wird nie geöffnet, nur gelesen.
  */
  /*
    Erst die Größe ansehen, dann kopieren.

    Die Prüfkette in `dateiPruefen` lehnt alles über einem halben Gigabyte ab –
    aber sie sieht die **Arbeitskopie**, also erst, nachdem die Datei
    vollständig ins Programmverzeichnis geschrieben wurde. Wer aus Versehen
    einen Film auswählt, wartete bis 0.24.1 auf eine Kopie von mehreren
    Gigabyte, um danach zu lesen, dass die Datei zu groß ist – und die Kopie
    lag anschließend im Benutzerverzeichnis, bis der nächste Prüflauf sie
    überschrieb. Dieselbe Grenze, nur an der richtigen Stelle.
  */
  let quellgroesse: number;
  try {
    quellgroesse = statSync(gewaehlt).size;
  } catch {
    return {
      art: 'abgelehnt',
      grund: 'Diese Datei lässt sich nicht lesen. Es wurde nichts verändert.',
    };
  }
  if (quellgroesse > HOECHSTGROESSE) {
    return {
      art: 'abgelehnt',
      grund:
        `Diese Datei ist ${megabyte(quellgroesse)} groß und kann kein Lernstand sein – ` +
        'ein Lernstand ist wenige Megabyte groß. Es wurde nichts verändert.',
    };
  }

  const arbeitskopie = join(userData(), PRUEFKOPIE);
  aufraeumen(arbeitskopie);
  try {
    copyFileSync(gewaehlt, arbeitskopie);
  } catch {
    return {
      art: 'abgelehnt',
      grund: 'Diese Datei lässt sich nicht lesen. Es wurde nichts verändert.',
    };
  }

  const befund = dateiPruefen(arbeitskopie, umgebung.Datenbank);
  if (befund.art === 'abgelehnt') {
    aufraeumen(arbeitskopie);
    return { art: 'abgelehnt', grund: befund.grund };
  }

  const stand = statSync(arbeitskopie);
  const vorgang: OffenerVorgang = {
    kennung: `${String(Date.now())}-${String(Math.floor(Math.random() * 1e9))}`,
    arbeitskopie,
    groesse: stand.size,
    mtimeMs: stand.mtimeMs,
    bis: Date.now() + KENNUNG_GUELTIG_MS,
    profilIds: befund.profilIds,
  };
  offen = vorgang;

  return {
    art: 'brauchbar',
    vorgang: vorgang.kennung,
    dateiname: basename(gewaehlt),
    ausDatei: befund.kennzahlen,
    jetzt: kennzahlenDesLaufenden(umgebung),
  };
}

// ── Einspielen, zweite Hälfte: vollziehen ──────────────────────────────

/**
 * Ersetzt den Lernstand.
 *
 * **Ab der Sicherheitskopie läuft alles ohne ein einziges `await`.** Der
 * Hauptprozess ist einfädig; solange nichts abgewartet wird, kann sich kein
 * anderer Kanal dazwischenschieben und über `lernstand()` die Datei in genau
 * dem Augenblick wieder öffnen, in dem sie ersetzt wird.
 */
/**
 * Nimmt ein Profil aus der geprüften Datei dazu – ohne zu ersetzen.
 *
 * Dieselben Wachen wie beim Ersetzen: Token einlösen (Wegwerfstück), Grösse
 * und mtime der Arbeitskopie gegenprüfen, laufende Prüfungssimulation
 * abweisen. Der eine Unterschied ist der wichtigste: Es wird **in die lebende
 * Datenbank geschrieben**, nicht getauscht. Ein Rückweg über das
 * Zurücktauschen einer Datei gibt es hier nicht – nur die Sicherheitskopie,
 * die `profilUebernehmen` unter eigenem Vorsatz anlegt.
 *
 * Ohne `await` wie das Ersetzen: Der Hauptprozess ist einfädig, und solange
 * nichts abgewartet wird, kann sich kein zweiter IPC-Kanal dazwischenschieben
 * und in derselben Datenbank schreiben.
 */
export function sicherungUebernehmen(
  vorgangRoh: unknown,
  profilIndexRoh: unknown,
  elternfenster: BrowserWindow | null,
  umgebung: Umgebung,
): Uebernahmeergebnis {
  const merkposten = offen;
  offen = null;

  if (
    merkposten === null ||
    typeof vorgangRoh !== 'string' ||
    vorgangRoh !== merkposten.kennung ||
    Date.now() > merkposten.bis ||
    !existsSync(merkposten.arbeitskopie)
  ) {
    return { art: 'abgelaufen' };
  }

  const stand = statSync(merkposten.arbeitskopie);
  if (stand.size !== merkposten.groesse || stand.mtimeMs !== merkposten.mtimeMs) {
    aufraeumen(merkposten.arbeitskopie);
    return { art: 'abgelaufen' };
  }

  if (elternfenster !== null && laeuftPruefung(elternfenster)) {
    aufraeumen(merkposten.arbeitskopie);
    return {
      art: 'gescheitert',
      grund: 'Es läuft inzwischen eine Prüfungssimulation. Es wurde nichts verändert.',
    };
  }

  /* Der Renderer nennt einen Index in die Liste, die der Hauptprozess selbst
     festgehalten hat – nie eine Profilnummer. */
  if (
    typeof profilIndexRoh !== 'number' ||
    !Number.isInteger(profilIndexRoh) ||
    profilIndexRoh < 0 ||
    profilIndexRoh >= merkposten.profilIds.length
  ) {
    aufraeumen(merkposten.arbeitskopie);
    return { art: 'gescheitert', grund: 'Dieses Profil steht nicht in der Datei.' };
  }

  const quellProfilId = merkposten.profilIds[profilIndexRoh] ?? -1;

  try {
    return profilUebernehmen({
      ziel: umgebung.lernstand().datenbank,
      quelle: merkposten.arbeitskopie,
      quellProfilId,
      ordner: userData(),
      jetzt: umgebung.jetzt?.() ?? new Date(),
      Datenbank: umgebung.Datenbank,
    });
  } finally {
    aufraeumen(merkposten.arbeitskopie);
  }
}

export function sicherungEinspielen(
  vorgang: unknown,
  elternfenster: BrowserWindow | null,
  umgebung: Umgebung,
): Einspielergebnis {
  const merkposten = offen;
  offen = null;

  if (
    merkposten === null ||
    typeof vorgang !== 'string' ||
    vorgang !== merkposten.kennung ||
    Date.now() > merkposten.bis ||
    !existsSync(merkposten.arbeitskopie)
  ) {
    return { art: 'abgelaufen' };
  }

  /* Die Arbeitskopie muss noch dieselbe sein, die geprüft wurde. */
  const stand = statSync(merkposten.arbeitskopie);
  if (stand.size !== merkposten.groesse || stand.mtimeMs !== merkposten.mtimeMs) {
    aufraeumen(merkposten.arbeitskopie);
    return { art: 'abgelaufen' };
  }

  /* Ein zweites Mal: Zwischen Rückfrage und Zustimmung liegt Zeit. */
  if (elternfenster !== null && laeuftPruefung(elternfenster)) {
    aufraeumen(merkposten.arbeitskopie);
    return {
      art: 'gescheitert',
      grund: 'Es läuft inzwischen eine Prüfungssimulation. Es wurde nichts verändert.',
    };
  }

  const ziel = lernstandPfad();
  const jetzt = umgebung.jetzt?.() ?? new Date();

  // ── Sicherheitskopie. Ohne sie wird nicht gesprungen. ────────────────
  let sicherheitskopie: string;
  try {
    sicherheitskopie = freierName(userData(), jetzt);
    sicherungSchreiben(umgebung.lernstand().datenbank, sicherheitskopie, umgebung.Datenbank);
  } catch (fehler) {
    aufraeumen(merkposten.arbeitskopie);
    return {
      art: 'gescheitert',
      grund:
        'Es ließ sich keine Sicherung Ihres jetzigen Lernstands anlegen, deshalb wurde ' +
        `nichts eingelesen. Möglicherweise ist der Speicherplatz erschöpft. (${fehlertext(fehler)}) ` +
        'Es wurde nichts verändert.',
    };
  }

  // ── Schliessen, Nebendateien beseitigen, ersetzen ────────────────────
  lernstandSchliessen();
  pruefungZuruecksetzen();

  /*
    Nach einem sauberen `close()` sind `-wal` und `-shm` weg. Liegen sie doch
    noch da, hält ein fremdes Handle die Datei. Löschen gelingt dann unter
    Windows nicht – und weitermachen wäre der eine Weg, auf dem wirklich
    Daten verschwinden: SQLite spielte das alte Schreibprotokoll auf die neue
    Datenbank, der eingespielte Stand wäre fort, und `integrity_check` meldete
    „ok“.
  */
  for (const anhang of ['-wal', '-shm']) {
    const nebendatei = `${ziel}${anhang}`;
    if (!existsSync(nebendatei)) {
      continue;
    }
    try {
      unlinkSync(nebendatei);
    } catch {
      lernstandInstanz(ziel, umgebung.katalog());
      return {
        art: 'gescheitert',
        grund:
          'Der Lernstand wird gerade von einem anderen Programm benutzt und ließ sich nicht ' +
          'ersetzen. Bitte schließen Sie andere Fenster dieser Anwendung und versuchen Sie es ' +
          `erneut. Ihr Lernstand ist unverändert; eine Sicherung liegt unter ${basename(sicherheitskopie)}.`,
      };
    }
  }

  try {
    renameSync(merkposten.arbeitskopie, ziel);
  } catch (fehler) {
    lernstandInstanz(ziel, umgebung.katalog());
    return {
      art: 'gescheitert',
      grund:
        `Die Datei ließ sich nicht an die Stelle des Lernstands setzen (${fehlertext(fehler)}). ` +
        'Ihr Lernstand ist unverändert.',
    };
  }

  // ── Öffnen, migrieren, Probe ─────────────────────────────────────────
  try {
    const neuer = lernstandInstanz(ziel, umgebung.katalog());
    const profile = neuer.profile();
    const erstes = profile[0];
    if (erstes === undefined) {
      throw new Error('Die eingespielte Datei enthält kein Profil.');
    }
    /* Eine Datei, die sich öffnen lässt, aber bei der ersten echten Abfrage
       umfällt, ist kein Erfolg. Gefragt wird **jedes** Profil: Die Probe soll
       die ganze Datei treffen, nicht ihren ersten Eintrag.

       Und die Zahl gehört über alle Profile gebildet. Bis Fassung 0.24.1 nahm
       sie nur das erste; nach dem Einspielen einer Datei mit drei Profilen
       stand da „3 Profile, 24 verschiedene Fragen bearbeitet“, obwohl es die
       24 des ersten waren. Wer die Zahl mit der Gegenüberstellung von vorhin
       verglich, fand einen Widerspruch, den es nicht gab. */
    const bearbeiteteFragen = profile.reduce(
      (summe, profil) => summe + neuer.uebersicht(profil.id).beantwortet,
      0,
    );
    kopienAufraeumen(userData());

    return {
      art: 'eingespielt',
      profilzahl: profile.length,
      ersteProfilId: erstes.id,
      bearbeiteteFragen,
      sicherheitskopie: basename(sicherheitskopie),
    };
  } catch (fehler) {
    /* Rückweg – und zwar unteilbar wie der Hinweg: erst nach `.teil`, dann
       umbenennen. Ein Rückweg, der auf halbem Weg abbricht, wäre die
       schlimmste aller Lagen. */
    lernstandSchliessen();
    for (const anhang of ['', '-wal', '-shm']) {
      aufraeumen(`${ziel}${anhang}`);
    }
    try {
      copyFileSync(sicherheitskopie, `${ziel}.teil`);
      renameSync(`${ziel}.teil`, ziel);
      lernstandInstanz(ziel, umgebung.katalog());
    } catch {
      /* Auch der Rückweg misslang. Die Sicherheitskopie liegt und wird
         benannt – von Hand ist sie einspielbar. */
    }
    return {
      art: 'gescheitert',
      grund:
        `Die Datei ließ sich nicht öffnen (${fehlertext(fehler)}). Ihr bisheriger Lernstand ` +
        `wurde wiederhergestellt. Eine Sicherung davon liegt unter ${basename(sicherheitskopie)}.`,
    };
  }
}

// ── Hilfen ─────────────────────────────────────────────────────────────

function kennzahlenDesLaufenden(umgebung: Umgebung): Kennzahlen {
  const db = umgebung.lernstand().datenbank;
  const tabellen = new Set(
    db
      .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'")
      .all()
      .map((zeile) => zeile.name),
  );
  const fassung =
    db
      .prepare<[], { version: number | null }>('SELECT MAX(version) AS version FROM schema_version')
      .get()?.version ?? 0;
  /* Dieselbe Funktion wie für die Datei – zwei Zählweisen nebeneinander
     wären zwei Wahrheiten in derselben Rückfrage. */
  return kennzahlenLesen(db, fassung, tabellen);
}

/**
 * Ein freier Name für die Sicherheitskopie.
 *
 * Sekundengenau, und trotzdem mit Zähler: `VACUUM INTO` weigert sich, ein
 * vorhandenes Ziel zu überschreiben, und diese Kopie entsteht ohne Rückfrage.
 * Zweimal in derselben Sekunde ist unwahrscheinlich – aber „unwahrscheinlich“
 * ist bei einer Sicherheitskopie das falsche Wort.
 */
function freierName(ordner: string, jetzt: Date): string {
  const grund = join(ordner, sicherungsDateiname(jetzt, VOR_DEM_EINSPIELEN));
  if (!existsSync(grund)) {
    return grund;
  }
  for (let zaehler = 2; zaehler < 100; zaehler++) {
    const kandidat = grund.replace(/\.wsklernstand$/u, `-${String(zaehler)}.wsklernstand`);
    if (!existsSync(kandidat)) {
      return kandidat;
    }
  }
  throw new Error('Es ließ sich kein freier Name für die Sicherheitskopie finden.');
}

/** Nur die jüngsten Sicherheitskopien bleiben; ausschliesslich dieses Muster. */
function kopienAufraeumen(ordner: string): void {
  try {
    const kopien = readdirSync(ordner)
      .filter((name) => name.startsWith(`${VOR_DEM_EINSPIELEN}-`) && name.endsWith('.wsklernstand'))
      .map((name) => ({ name, zeit: statSync(join(ordner, name)).mtimeMs }))
      .sort((a, b) => b.zeit - a.zeit);
    for (const alt of kopien.slice(KOPIEN_BEHALTEN)) {
      aufraeumen(join(ordner, alt.name));
    }
  } catch {
    /* Aufräumen ist Komfort. Misslingt es, bleiben ein paar Dateien mehr
       liegen – das ist kein Grund, einen erfolgreichen Vorgang zu melden. */
  }
}

/** Reste eines abgebrochenen Vorgangs, bevor ein neuer beginnt. */
function restePruefen(): void {
  offen = null;
  aufraeumen(join(userData(), PRUEFKOPIE));
}

function fehlertext(fehler: unknown): string {
  return fehler instanceof Error ? fehler.message : String(fehler);
}
