/**
 * Erzeugung des Hauptfensters mit gehärteten `webPreferences`.
 */

import { join } from 'node:path';

import { BrowserWindow, dialog, nativeTheme, screen } from 'electron';

import {
  FENSTER_MINDESTBREITE,
  FENSTER_MINDESTHOEHE,
  startlage,
  type Fensterlage,
} from '../shared/fensterlage';
import { startfarbeFuer } from '../shared/theme';
import { anwendenAuf } from './anzeige';
import { einstellungenLesen, einstellungenSchreiben } from './einstellungen';
import { laeuftPruefung, laufVergessen } from './laufwaechter';
import { oberflaechenDatei } from './oberflaeche';
import { protokollieren } from './protokoll';

/**
 * Wie lange der erste Frame auf sich warten lassen darf.
 *
 * Danach wird das Fenster auch ohne ihn gezeigt. Vier Sekunden sind reichlich
 * über dem, was ein gesunder Start braucht — gemessen liegt `ready-to-show`
 * hier deutlich unter einer Sekunde —, und kurz genug, dass niemand vor einem
 * leeren Bildschirm sitzt und sich fragt, ob er zweimal geklickt hat.
 */
const FRIST_MS = 4000;

/**
 * Hintergrundfarbe des Fensters – verhindert ein Aufblitzen beim Start.
 *
 * Maßgeblich ist die **gespeicherte** Wahl aus `einstellungen.json`, nicht
 * das Systemdesign: Bis 0.21.0 fragte diese Stelle nur `nativeTheme`, und wer
 * „Dunkel“ eingestellt hatte, während Windows hell läuft, sah bei jedem Start
 * kurz eine helle Fläche. Der Hauptprozess liest die Datei synchron und weiß
 * die Antwort damit, bevor der erste Bildpunkt fällt – derselbe Weg, den die
 * Anzeigegröße geht.
 *
 * `system` bleibt `system`: Dann entscheidet weiterhin `nativeTheme`, und
 * `themeAufloesen` rechnet das mit denselben Regeln aus wie der Renderer.
 */
function startHintergrund(): string {
  return startfarbeFuer(einstellungenLesen().thema, {
    bevorzugtDunkel: nativeTheme.shouldUseDarkColors,
    erzwungeneFarben: nativeTheme.shouldUseHighContrastColors,
    /* `prefers-contrast: more` meldet Electron nicht getrennt; unter
       erzwungenen Farben ist die Frage ohnehin beantwortet. */
    bevorzugtMehrKontrast: false,
  });
}

/** Wie oft ein gestorbener Renderer selbsttätig neu geladen wird. */
export const NEULADEVERSUCHE = 2;

/**
 * Ab dieser Ruhe gilt ein Absturz als neuer Vorfall statt als Schleife.
 *
 * Ohne diese Frist zählte ein Absturz aus der vorigen Woche noch mit, und
 * das dritte Vorkommnis in einem Jahr bekäme kein Neuladen mehr.
 */
export const SCHLEIFENFENSTER_MS = 60_000;

/** Was nach dem Tod eines Renderer-Prozesses zu tun ist. */
export type Absturzantwort = 'ruhen' | 'neu-laden' | 'aufgeben';

/**
 * Entscheidet über die Antwort auf einen gestorbenen Renderer.
 *
 * Als reine Funktion herausgezogen, weil an ihr das Verhalten hängt und ein
 * echter Absturz sich nicht auf Bestellung herstellen lässt.
 *
 * `clean-exit` und `killed` sind kein Absturz: Das erste ist das gewöhnliche
 * Beenden, das zweite ein Abschuss von außen. Beide neu zu laden hieße, ein
 * geschlossenes Fenster wieder aufzumachen.
 */
export function absturzAntwort(
  grund: string,
  versucheBisher: number,
  seitLetztemAbsturzMs: number,
): Absturzantwort {
  if (grund === 'clean-exit' || grund === 'killed') {
    return 'ruhen';
  }
  const versuche = seitLetztemAbsturzMs > SCHLEIFENFENSTER_MS ? 0 : versucheBisher;
  return versuche < NEULADEVERSUCHE ? 'neu-laden' : 'aufgeben';
}

export function hauptfensterErstellen(): BrowserWindow {
  /*
    Mindestbreite: 360 statt der früheren 720.

    720 Pixel waren die zweite Hälfte des Reflow-Problems (WCAG 1.4.10). Das
    Kriterium fragt, ob sich der Inhalt bei 320 CSS-Pixeln ohne seitliches
    Rollen benutzen lässt. Mit 720 Pixeln Mindestbreite und einer Zoomleiter
    bis 200 Prozent war der schmalste erreichbare Bereich 360 CSS-Pixel – der
    Prüffall ließ sich nicht einmal herstellen.

    Die Zoomleiter reicht jetzt bis 400 Prozent und stellt den Fall auf einem
    gewöhnlichen Fenster her (1280 / 4 = 320). Die Mindestbreite fällt
    trotzdem: Wer das Fenster neben ein anderes stellt oder mit einer
    Bildschirmlupe arbeitet, will es schmal ziehen können. 360 und nicht 320,
    weil `minWidth` die Fenster- und nicht die Inhaltsbreite ist – der Rahmen
    kostet unter Windows ein gutes Dutzend Pixel, und bei 100 Prozent Anzeige
    sollen 320 CSS-Pixel Inhalt übrig bleiben.

    Dass das Layout bei 320 Pixeln trägt, ist gemessen: einspaltig, kein
    seitliches Rollen, nichts abgeschnitten (`e2e/anzeigegroesse.spec.ts`).
  */
  /*
    Größe und Lage der letzten Sitzung.

    Gegen die vorhandenen Bildschirme geprüft (`shared/fensterlage.ts`): Eine
    Lage auf einem abgesteckten zweiten Monitor ergäbe ein Fenster, das
    aufgeht und unsichtbar bleibt – und ohne Fenster gibt es keinen Weg, die
    Einstellung zurückzusetzen.
  */
  const lage = startlage(
    einstellungenLesen().fenster,
    screen.getAllDisplays().map((anzeige) => anzeige.workArea),
  );

  const fenster = new BrowserWindow({
    width: lage.breite,
    height: lage.hoehe,
    ...(lage.x === undefined || lage.y === undefined ? {} : { x: lage.x, y: lage.y }),
    minWidth: FENSTER_MINDESTBREITE,
    minHeight: FENSTER_MINDESTHOEHE,
    show: false,
    backgroundColor: startHintergrund(),
    title: 'Waffensachkunde – Lernsoftware',
    autoHideMenuBar: false,
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),

      // ── Sicherheitsgrundlagen (nicht verhandelbar) ──────────────────
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true,

      // ── Ergänzende Härtung ──────────────────────────────────────────
      nodeIntegrationInWorker: false,
      nodeIntegrationInSubFrames: false,
      webSecurity: true,
      allowRunningInsecureContent: false,
      experimentalFeatures: false,
      webviewTag: false,

      // Die Rechtschreibprüfung lädt Wörterbücher aus dem Netz nach – die
      // Anwendung soll vollständig offline funktionieren.
      spellcheck: false,
    },
  });

  /* Die gespeicherte Anzeigegröße muss anliegen, BEVOR gezeichnet wird –
     sonst blitzt die Anwendung beim Start in der falschen Größe auf. Der
     Zoomfaktor hängt an den webContents und wird nicht vererbt; jedes neue
     Fenster braucht ihn erneut. */
  fenster.webContents.on('did-finish-load', () => {
    anwendenAuf(fenster.webContents);
    /* Ein frisch geladenes Dokument prüft nicht. Der Renderer meldet seinen
       Zustand danach ohnehin selbst; das hier ist die Absicherung dagegen,
       dass ein Merker ein Neuladen überlebt. */
    laufVergessen(fenster.webContents);
  });

  /*
    Zeigen, sobald der erste Frame steht — und notfalls auch ohne ihn.

    `ready-to-show` ist der richtige Zeitpunkt: Vorher zeigte das Fenster
    einen weißen Blitz. Aber es ist keine Zusage, die immer eingelöst wird.

    **Was am 29.08.2026 passiert ist.** Seit 0.23.0 merkt sich die Anwendung
    Größe und Lage des Fensters. Sobald eine Position gespeichert war und
    beim Start wieder angelegt wurde, starb auf einem Windows-11-Rechner der
    GPU-Prozess (`GPU process exited unexpectedly: exit_code=-1`), danach der
    Renderer. Ohne ersten Frame feuerte `ready-to-show` nie — und das Fenster
    blieb für immer unsichtbar. Nachgemessen: Das Fenster existierte, saß an
    der gespeicherten Stelle und war vollständig geladen; nur zeigte es
    niemand. Vier von vier Versuchen, mit jeder Position, auch mit 0/0. Ohne
    gespeicherte Position trat es nie auf.

    Für den Anwender heißt das: Die Anwendung startet einmal, er verschiebt
    das Fenster, und danach lässt sie sich nie wieder öffnen — es erscheint
    nichts, es meldet nichts, und an die Einstellung kommt er nicht heran,
    weil dafür ein Fenster nötig wäre. Genau der Fall, den der Kommentar über
    `startlage()` befürchtet, nur aus einer anderen Richtung.

    Die Rückfallebene ist deshalb nicht Kosmetik: Ein Fenster ohne ersten
    Frame ist unschön, ein Programm ohne Fenster ist unbenutzbar. Nach
    `did-finish-load` — der Punkt, an dem die Oberfläche geladen ist —
    bekommt der erste Frame noch eine Frist; verstreicht sie, wird trotzdem
    gezeigt und der Vorfall ins Fehlerprotokoll geschrieben.
  */
  let gezeigt = false;
  const zeigen = (grund: 'ready-to-show' | 'notbremse'): void => {
    if (gezeigt || fenster.isDestroyed()) {
      return;
    }
    gezeigt = true;
    anwendenAuf(fenster.webContents);
    /* Maximieren vor dem Zeigen: Andersherum blitzte das Fenster einen
       Augenblick in seiner nicht maximierten Größe auf. */
    if (lage.maximiert === true) {
      fenster.maximize();
    }
    fenster.show();
    if (grund === 'notbremse') {
      const spur = `Fenster ohne ersten Frame gezeigt — ready-to-show blieb ${String(FRIST_MS)} ms aus.`;
      console.warn(`[fenster] ${spur}`);
      protokollieren('fenster', spur);
    }
  };

  fenster.once('ready-to-show', () => {
    zeigen('ready-to-show');
  });

  fenster.webContents.once('did-finish-load', () => {
    setTimeout(() => {
      zeigen('notbremse');
    }, FRIST_MS);
  });

  // Der Renderer darf den Fenstertitel nicht überschreiben.
  fenster.on('page-title-updated', (ereignis) => {
    ereignis.preventDefault();
  });

  /*
    Größe und Lage merken.

    Gemessen wird `getNormalBounds()` und nicht `getBounds()`: Ein maximiertes
    Fenster meldet sonst die Bildschirmgröße, und wer die Maximierung einmal
    aufhebt, säße vor einem Fenster, das den ganzen Schirm füllt, ohne
    maximiert zu sein — die vorige, bewusst gewählte Größe wäre weg.

    Entprellt, weil `resize` und `move` beim Ziehen im Dutzend feuern: Ohne
    Verzögerung schriebe jeder Pixel eine Datei. Und beim Schließen noch
    einmal ohne Verzögerung, damit die letzte Änderung nicht verfällt.
  */
  let schreibuhr: NodeJS.Timeout | null = null;

  const lageMerken = (): void => {
    if (fenster.isDestroyed()) {
      return;
    }
    const bounds = fenster.getNormalBounds();
    const neu: Fensterlage = {
      breite: bounds.width,
      hoehe: bounds.height,
      x: bounds.x,
      y: bounds.y,
      ...(fenster.isMaximized() ? { maximiert: true } : {}),
    };
    try {
      einstellungenSchreiben({ fenster: neu });
    } catch (fehler: unknown) {
      /* Eine nicht gemerkte Fenstergröße ist ein Schönheitsfehler; das
         Beenden daran scheitern zu lassen wäre der schlechtere Handel. */
      console.warn('[fenster] Lage konnte nicht gemerkt werden:', fehler);
    }
  };

  const lageSpaeterMerken = (): void => {
    if (schreibuhr !== null) {
      clearTimeout(schreibuhr);
    }
    schreibuhr = setTimeout(lageMerken, 400);
  };

  fenster.on('resize', lageSpaeterMerken);
  fenster.on('move', lageSpaeterMerken);
  fenster.on('maximize', lageSpaeterMerken);
  fenster.on('unmaximize', lageSpaeterMerken);

  fenster.on('close', () => {
    if (schreibuhr !== null) {
      clearTimeout(schreibuhr);
      schreibuhr = null;
    }
    lageMerken();
  });

  /*
    Mitten in der Prüfung wird nachgefragt.

    Ein Bogen läuft bis zu zwei Stunden. Er ist zwar gesichert und lässt sich
    fortsetzen – aber ein Fenster, das auf Alt+F4 wortlos zugeht, während
    jemand bei Frage 63 sitzt, ist trotzdem ein Schrecken. Die Frage kostet
    einen Tastendruck und nimmt ihn weg.

    Bewusst `showMessageBoxSync`: Der Beschluss muss vorliegen, bevor dieser
    Behandler zurückkehrt. Ein asynchroner Dialog käme zu spät – das Fenster
    wäre längst zu. Native Dialoge liest der Screenreader; die Tastatur
    bedient sie ohnehin.

    `cancelId` zeigt ausdrücklich auf „Weiter prüfen", nicht über die
    Reihenfolge: Escape soll nichts tun, unabhängig davon, wie die Knöpfe
    später einmal angeordnet sind.
  */
  fenster.on('close', (ereignis) => {
    if (!laeuftPruefung(fenster)) {
      return;
    }

    const wahl = dialog.showMessageBoxSync(fenster, {
      type: 'question',
      buttons: ['Weiter prüfen', 'Beenden'],
      defaultId: 0,
      cancelId: 0,
      noLink: true,
      title: 'Prüfungssimulation läuft',
      message: 'Es läuft eine Prüfungssimulation. Wirklich beenden?',
      detail:
        'Ihr Bogen bleibt erhalten. Beim nächsten Start können Sie ihn fortsetzen; ' +
        'die Uhr steht so lange still.',
    });

    if (wahl === 0) {
      ereignis.preventDefault();
    } else {
      laufVergessen(fenster.webContents);
    }
  });

  /*
    Ein gestorbener Renderer hinterließ ein totes, leeres Fenster.

    Der Fehlerauffang der Oberfläche (`Fehlerauffang.tsx`) fängt Ausnahmen im
    Renderbaum und zeigt eine Fehlerseite. Stirbt aber der Renderer-**Prozess**
    selbst – Speichermangel, Grafiktreiber –, kommt er nicht mehr zum Zuge:
    Das Fenster bleibt weiß, ohne Text, ohne Fokus, ohne Ansage. Für jemanden
    am Screenreader ist das nichts, worüber sich etwas sagen ließe. Ein Ausweg
    bestand zwar (das Anwendungsmenü bleibt bedienbar, „Ansicht → Neu laden“),
    aber niemand wusste davon.

    Neuladen ist verlustfrei: Der Prüfungsbogen wird im Sekundentakt gesichert
    und der Lernstand liegt ohnehin in der Datenbank. Deshalb wird es getan,
    statt danach zu fragen – und höchstens {@link NEULADEVERSUCHE} Mal, damit
    aus einem Absturz beim Laden keine Schleife wird, die sich nicht mehr
    beenden lässt.

    Der Merker der laufenden Prüfung fällt in jedem Fall: Sonst hinge das
    Fenster an einer Rückfrage zu einer Prüfung, die es nicht mehr gibt.
  */
  let neuladeVersuche = 0;
  let letzterAbsturz = 0;

  fenster.webContents.on('render-process-gone', (_ereignis, einzelheiten) => {
    laufVergessen(fenster.webContents);
    if (fenster.isDestroyed()) {
      return;
    }

    const jetzt = Date.now();
    const antwort = absturzAntwort(einzelheiten.reason, neuladeVersuche, jetzt - letzterAbsturz);
    if (antwort === 'ruhen') {
      return;
    }

    if (jetzt - letzterAbsturz > SCHLEIFENFENSTER_MS) {
      neuladeVersuche = 0;
    }
    letzterAbsturz = jetzt;
    const spur = `Renderer beendet (${einzelheiten.reason}, Code ${String(einzelheiten.exitCode)}), Antwort: ${antwort}`;
    console.error(`[fenster] ${spur}`);
    protokollieren('fenster', spur);

    if (antwort === 'neu-laden') {
      neuladeVersuche += 1;
      fenster.webContents.reload();
      return;
    }

    /* Nach dem zweiten vergeblichen Versuch nicht stumm bleiben. Ein nativer
       Dialog geht auch ohne lebende Oberfläche auf und wird vorgelesen. */
    dialog.showErrorBox(
      'Waffensachkunde – Lernsoftware',
      [
        'Die Anzeige ist mehrfach hintereinander abgestürzt und wurde nicht erneut geladen.',
        'Ihr Lernstand ist davon nicht betroffen; er liegt als Datei auf Ihrer Festplatte, und ein laufender Prüfungsbogen lässt sich beim nächsten Start fortsetzen.',
        'Bitte beenden Sie das Programm und starten Sie es neu. Bleibt es dabei, melden Sie es bitte.',
      ].join('\n\n'),
    );
  });

  fenster.webContents.on('destroyed', () => {
    laufVergessen(fenster.webContents);
  });

  /* Kein zweiter setWindowOpenHandler an dieser Stelle: `sicherheit.ts`
     setzt ihn bereits für jeden neu entstehenden `webContents`, und der
     zuletzt gesetzte gewinnt. Zwei Fassungen derselben Regel wären eine
     Einladung, nur eine davon zu verschärfen. */

  const devServerUrl = process.env['ELECTRON_RENDERER_URL'];
  if (devServerUrl) {
    void fenster.loadURL(devServerUrl);
  } else {
    void fenster.loadFile(oberflaechenDatei());
  }

  return fenster;
}
