/**
 * Die Konsolenwache: hört mit, was die laufende Anwendung meldet.
 *
 * ## Warum es diese Datei gibt
 *
 * Im Kopf von `e2e/electron-hilfe.ts` stand seit jeher der Satz
 * „`ELECTRON_DISABLE_SECURITY_WARNINGS` bleibt bewusst AUS: erscheinen
 * Sicherheitswarnungen in der Konsole, ist das ein echter Befund." Die Absicht
 * war formuliert – die Vorrichtung dazu fehlte. In keiner der Suiten stand ein
 * `page.on('console')`, niemand hörte also zu. Was das kostet, hat der
 * `frame-ancestors`-Fall gezeigt: Chromium schrieb bei **jedem** Programmstart
 * einen Konsolenfehler über eine verworfene CSP-Direktive, und der Testlauf
 * meldete währenddessen grün. Gefunden wurde der Fehler erst, als ein Mensch
 * zufällig hinsah.
 *
 * ## Was gesammelt wird
 *
 * - Konsolenfehler und -warnungen des **Renderers** (`page.on('console')`),
 *   einschließlich der Meldungen, die Chromium selbst schreibt – ignorierte
 *   CSP-Direktiven, blockierte Ressourcen, Electrons Sicherheitswarnungen.
 * - Ausnahmen im Renderer (`page.on('pageerror')`) – und darüber **auch
 *   unbehandelte Zusagen**. Das war die eine Stelle, an der nicht klar war, ob
 *   es einen zweiten Zuhörer braucht; nachgemessen mit einer Wegwerf-Probe:
 *   Ein `throw` im Renderer und ein `Promise.reject(…)` ohne `catch` liefen
 *   beide über `pageerror` auf, keines über `console`. Ein eigener
 *   `unhandledrejection`-Zuhörer im Dokument ist deshalb nicht nötig – er
 *   hätte die Anwendung dafür verändern müssen.
 * - Konsolenfehler und -warnungen des **Hauptprozesses**
 *   (`electronApplication.on('console')`). Der Hauptprozess ist der einzige
 *   Ort, an dem Datenbank-, Datei- und Sicherheitsfehler auffallen;
 *   `src/main/sicherheit.ts` schreibt dort etwa jede blockierte Navigation hin.
 * - Weitere Fenster: `app.on('window')` hängt die Wache an jedes Fenster, das
 *   die Anwendung später öffnet. Nachgemessen an `e2e/druck.spec.ts`: Auch das
 *   **verborgene** Druckfenster aus `src/main/druck.ts` meldet sich hier
 *   (`…/wsk-druck-…/dokument.html`) – gerade dort sieht sonst niemand hin, das
 *   Fenster hat ja keine Anzeige.
 *
 * Gesammelt werden nur `error`, `warning` und `assert`. `log`, `info` und
 * `debug` sind Mitteilungen, keine Befunde; die Anwendung schreibt dort im
 * Regelbetrieb ihren Datenbankstand hin (`console.info` in `src/main/index.ts`).
 *
 * ## Was durchgelassen wird
 *
 * Nichts – außer, ein Test meldet ausdrücklich an, dass er eine Meldung
 * selbst auslöst: {@link konsoleErwartet}. Warum die Ausnahmen dort stehen und
 * nicht in einer globalen Liste, steht bei dieser Funktion, samt dem Ergebnis
 * des ersten Messlaufs.
 *
 * ## Warum ein Fixture und kein `test.afterEach` in dieser Datei
 *
 * Naheliegend wäre, hier schlicht `test.afterEach(...)` aufzurufen – die
 * Suiten importieren diese Datei ohnehin. Das wäre eine Falle: Playwright
 * ordnet einen Hook der Datei zu, die gerade geladen wird, und Node führt ein
 * Modul nur **einmal** aus. Der Hook hinge damit allein an der ersten Suite,
 * die diese Datei importiert; alle übrigen liefen ungeprüft – und niemand
 * sähe es. Deshalb der ausdrückliche Weg über ein automatisches Fixture: Jede
 * Suite, die `test` von hier bezieht, bekommt die Prüfung, ohne sie
 * anzumelden. Dass eine Suite `test` versehentlich wieder direkt von
 * `@playwright/test` bezieht, verhindert eine ESLint-Regel für die
 * Prüfdateien unter `e2e/` (siehe `eslint.config.mjs`).
 */
import { basename } from 'node:path';

import {
  test as basisTest,
  type ConsoleMessage,
  type ElectronApplication,
  type Page,
} from '@playwright/test';

export { expect } from '@playwright/test';

/** Eine aufgelaufene Meldung samt der Stelle, an der sie auflief. */
interface Befund {
  /** Menschenlesbare Art: „Konsolenfehler", „Ausnahme im Renderer", … */
  readonly art: string;
  /** Renderer oder Hauptprozess – die Suche beginnt an verschiedenen Orten. */
  readonly herkunft: string;
  /** Der Wortlaut der Meldung. */
  readonly text: string;
  /** Datei, Zeile und Spalte, soweit die Meldung sie mitbringt. */
  readonly ort: string;
  /** Welcher Test lief, als die Meldung kam – die „Ansicht" des Befunds. */
  readonly abschnitt: string;
}

/** Eine angemeldete Ausnahme: Diese Meldung ist in diesem Test gewollt. */
interface Erwartung {
  readonly muster: RegExp;
  readonly grund: string;
}

/** Was aufgelaufen ist. Lebt im Arbeitsprozess, nicht in einer einzelnen Suite. */
const aufgelaufen: Befund[] = [];

/**
 * Gilt vom Aufruf bis zum Ende des laufenden Tests; das Fixture räumt danach.
 * Eine Anmeldung in `beforeAll`/`beforeEach` gilt also für den Test mit, der
 * darauf folgt.
 */
let erwartungen: Erwartung[] = [];

/**
 * Meldet eine Meldung an, die **dieser Test absichtlich auslöst**.
 *
 * ## Warum das die Ausnahmeliste ist – und keine globale
 *
 * Gemessen im ersten vollen Lauf – 178 Prüfungen, ohne jede Ausnahme. Die
 * Wache fing genau zwei Sorten Meldungen auf, und **beide löst die Prüfung
 * selbst aus**; keine einzige stammte aus dem Regelbetrieb der Anwendung.
 *
 * 1. „Loading the image 'https://gibt-es-nicht.invalid/pixel.png' violates …
 *    img-src" – der absichtliche CSP-Verstoß, mit dem
 *    `angewandteRichtlinien()` die geltenden Richtlinien sichtbar macht
 *    (`e2e/electron-hilfe.ts`). Je Test zweimal, weil zwei Richtlinien gelten;
 *    aufgelaufen in `anwendung.spec.ts` und in `gepackt.spec.ts`.
 * 2. „[sicherheit] Navigation blockiert: file:///C:/Windows/win.ini" aus dem
 *    Hauptprozess – der Beweis, dass die Sperre in
 *    `e2e/navigationssperre.spec.ts` greift.
 *
 * Eine **globale** Liste wäre hier der falsche Ort gewesen, und zwar aus einem
 * Grund, der die ganze Wache betrifft: Ein globales Muster auf „violates the
 * following Content Security Policy directive" hätte die Wache genau für die
 * Fehlerklasse blind gemacht, für die es sie gibt. Dasselbe gilt für
 * „[sicherheit] …": Blockierte Navigationen sind anderswo ein Alarm, nicht
 * Rauschen. Eine Ausnahme gehört deshalb dorthin, wo die Meldung erzeugt
 * wird, und sie endet mit dem Test, der sie erzeugt.
 *
 * Aus demselben Grund verlangt diese Funktion `grund` als Pflichtargument:
 * Steht der Satz nicht da, gibt es die Ausnahme nicht. Bei einem Befund nennt
 * der Bericht alle angemeldeten Ausnahmen samt Begründung – wer ihn liest,
 * sieht, wofür die Wache in diesem Test taub war.
 *
 * Für Rauschen, das **jeder** Test hätte und das die Anwendung nachweislich
 * nicht verursacht, gibt es hier bewusst nichts: Der Messlauf hat kein
 * einziges solches Beispiel geliefert. Wer eines findet, entscheidet dann mit
 * dem Fall in der Hand, ob eine globale Liste nötig ist – statt sie auf
 * Vorrat anzulegen, wo sie nur einlädt, Befunde hineinzuschieben.
 */
export function konsoleErwartet(muster: RegExp, grund: string): void {
  if (erwartungen.some((e) => e.muster.source === muster.source)) {
    return;
  }
  erwartungen.push({ muster, grund });
}

/** Der Wortlaut, mit dem Meldungen aus der Startphase beschriftet werden. */
const STARTPHASE = 'Start der Anwendung (beforeAll), noch vor dem ersten Test der Datei';

/**
 * Die Stelle, der eine ankommende Meldung zugeschrieben wird.
 *
 * Meldungen kommen asynchron; welcher Test gerade lief, weiß nur der Lauf
 * selbst. Der Marker wird an drei Stellen gestellt: vom Fixture vor jedem
 * Test, vom Fixture nach jedem Test (Aufräumphase) und beim Start einer
 * Anwendung. Der dritte Punkt ist kein Beiwerk – ohne ihn trüge ausgerechnet
 * die Startphase, in der der CSP-Fehler auflief, noch die Beschriftung des
 * letzten Tests der **vorigen** Datei.
 */
let abschnitt = STARTPHASE;

/** Fenster, an denen die Wache schon hängt – gegen doppelte Anmeldung. */
const beobachtet = new WeakSet<Page>();

/** Lange Meldungen werden gekürzt; der Bericht soll lesbar bleiben. */
function gekuerzt(text: string, grenze = 600): string {
  const eine = text.replace(/\s+/gu, ' ').trim();
  return eine.length > grenze ? `${eine.slice(0, grenze)} … (gekürzt)` : eine;
}

/*
  Aufgefangen wird zunächst alles; aussortiert wird erst beim Abräumen.

  Der Reihenfolge wegen: Eine Konsolenmeldung kommt über die Leitung, während
  der Test schon weiterläuft. Wer beim Eintreffen filtern wollte, müsste
  verlangen, dass `konsoleErwartet` vor der Meldung angemeldet ist – und hätte
  eine Wache, die je nach Zeitverlauf mal rot und mal grün ist. Am Testende
  steht dagegen beides fest.
*/
function vermerken(befund: Befund): void {
  aufgelaufen.push(befund);
}

/** Deutsche Benennung der Playwright-Meldungsarten. */
function artName(typ: string): string | undefined {
  if (typ === 'error') {
    return 'Konsolenfehler';
  }
  if (typ === 'warning') {
    return 'Konsolenwarnung';
  }
  if (typ === 'assert') {
    return 'fehlgeschlagene console.assert';
  }
  return undefined;
}

function konsolenmeldung(meldung: ConsoleMessage, herkunft: string): void {
  const art = artName(meldung.type());
  if (art === undefined) {
    return;
  }

  const stelle = meldung.location();
  const ort =
    stelle.url === ''
      ? '(ohne Ortsangabe)'
      : `${stelle.url}:${String(stelle.lineNumber)}:${String(stelle.columnNumber)}`;

  vermerken({ art, herkunft, text: gekuerzt(meldung.text()), ort, abschnitt });
}

/**
 * Hängt die Wache an ein Fenster.
 *
 * Getrennt von {@link konsolenwacheAnhaengen}, weil `app.on('window')` und
 * `app.firstWindow()` dasselbe Fenster liefern können – die Wache darf dann
 * nicht doppelt zählen.
 */
export function fensterUeberwachen(seite: Page): void {
  if (beobachtet.has(seite)) {
    return;
  }
  beobachtet.add(seite);

  seite.on('console', (meldung) => {
    konsolenmeldung(meldung, 'Renderer');
  });

  seite.on('pageerror', (fehler) => {
    /* Die erste Zeile des Stapels sagt, wo es geknallt hat – ohne sie ist eine
       Ausnahme im gebündelten Renderer-Code kaum zu finden. */
    const stapel = (fehler.stack ?? '').split('\n').slice(1, 2).join('').trim();
    vermerken({
      art: 'Ausnahme im Renderer',
      herkunft: 'Renderer',
      text: gekuerzt(`${fehler.name}: ${fehler.message}`),
      ort: stapel === '' ? '(ohne Stapelangabe)' : stapel,
      abschnitt,
    });
  });
}

/**
 * Hängt die Wache an eine gestartete Anwendung.
 *
 * Wird von `appStartenOhneZuschnitt` aufgerufen und deckt damit alle Suiten
 * ab. `e2e/gepackt.spec.ts` startet das Paket selbst und ruft beides einzeln
 * auf – dieselbe Wache, anderer Startweg.
 */
export function konsolenwacheAnhaengen(app: ElectronApplication): void {
  /* Jede Suite startet ihre Anwendung im `beforeAll`, also zwischen zwei
     Tests. Ohne diese Zeile bekämen die Meldungen des Starts die Beschriftung
     des letzten Tests der vorigen Datei – und die Suche begänne am falschen
     Ende. Welche Datei es ist, sagt ohnehin der fallende Test. */
  abschnitt = STARTPHASE;

  app.on('console', (meldung) => {
    konsolenmeldung(meldung, 'Hauptprozess');
  });

  app.on('window', (seite) => {
    fensterUeberwachen(seite);
  });
}

/**
 * Nimmt die Befunde heraus, für die niemand geradesteht – und leert den Speicher.
 *
 * Geleert wird **vollständig**, einschließlich der angemeldeten Meldungen:
 * Sonst schleppte der nächste Test sie mit und bekäme einen Befund für etwas,
 * das der vorige ausgelöst hat.
 */
function entnehmen(): Befund[] {
  const alle = aufgelaufen.splice(0, aufgelaufen.length);
  return alle.filter((b) => !erwartungen.some((e) => e.muster.test(b.text)));
}

/**
 * Der Bericht.
 *
 * Sagt, **was** auflief und **wo** – eine Wache, die nur „es gab Meldungen"
 * meldet, verschiebt die Arbeit bloß auf den nächsten Lauf. Und sie sagt,
 * wofür sie in diesem Test taub war: Wer einen Befund vor sich hat, soll
 * nicht raten müssen, ob nebenan noch etwas durchgelassen wurde.
 */
function bericht(befunde: readonly Befund[]): string {
  const zeilen = befunde.map(
    (b, i) =>
      `${String(i + 1)}. ${b.art} (${b.herkunft}) in „${b.abschnitt}"\n` +
      `   Meldung: ${b.text}\n` +
      `   Ort:     ${b.ort}`,
  );

  const angemeldet =
    erwartungen.length === 0
      ? ''
      : '\n\nAbsichtlich durchgelassen wurde außerdem:\n' +
        erwartungen.map((e) => `   ${String(e.muster)} – ${e.grund}`).join('\n');

  return (
    `Die Konsolenwache hat ${String(befunde.length)} Meldung(en) aufgefangen. ` +
    'Die laufende Anwendung darf beim Testlauf weder Fehler noch Warnungen schreiben.\n\n' +
    zeilen.join('\n\n') +
    angemeldet +
    '\n\nEntweder die Ursache beheben – oder, wenn die Prüfung die Meldung absichtlich ' +
    'auslöst, sie mit `konsoleErwartet(muster, grund)` aus e2e/konsolenwache.ts anmelden.'
  );
}

/**
 * `test` mit angehängter Konsolenwache – die Einstiegsstelle für alle Suiten.
 *
 * Zwei automatische Fixtures, weil Meldungen in zwei Phasen kommen:
 *
 * - `konsolenwache` (je Test) schreibt vor dem Test den Marker und prüft nach
 *   ihm. Was während `beforeAll` auflief – Anwendungsstart, erster
 *   Ladevorgang, genau die Phase des CSP-Fehlers –, meldet damit der erste
 *   Test der Datei.
 * - `konsolenwacheAbschluss` (je Arbeitsprozess) fegt am Ende nach. Ohne das
 *   fiele unter den Tisch, was während `afterAll` auflief, also beim
 *   Schließen der Anwendung.
 *
 * Beide Fixtures liefern `undefined` und nicht das sonst übliche `void`:
 * Sie stellen keinen Wert bereit, sondern hängen nur eine Prüfung an – und
 * `void` als Typargument verbietet `@typescript-eslint/no-invalid-void-type`
 * zu Recht.
 */
export const test = basisTest.extend<
  { konsolenwache: undefined },
  { konsolenwacheAbschluss: undefined }
>({
  konsolenwache: [
    // eslint-disable-next-line no-empty-pattern -- Playwright liest die Abhängigkeiten eines Fixtures aus dem destrukturierten ersten Argument und bricht ohne es ab; diese Wache braucht kein anderes Fixture.
    async ({}, benutzen, info) => {
      const teile = info.titlePath.filter((t) => t.length > 0);
      const datei = basename(info.file);
      if (!teile.some((t) => t.includes(datei))) {
        teile.unshift(datei);
      }
      abschnitt = teile.join(' › ');

      await benutzen(undefined);

      /* Erst den Marker weiterstellen, dann prüfen: Was nach diesem Punkt noch
         kommt, gehört zur Aufräumphase und nicht mehr zum Test. */
      const gerade = abschnitt;
      abschnitt = `Aufräumphase nach „${gerade}"`;

      try {
        const befunde = entnehmen();
        if (befunde.length > 0) {
          throw new Error(bericht(befunde));
        }
      } finally {
        /*
          Die Anmeldungen werden am Ende geräumt, nicht am Anfang.

          Am Anfang zu räumen wäre die naheliegendere Zeile und eine Falle:
          Ein `konsoleErwartet` in einem `beforeAll` wäre gelöscht, bevor der
          erste Test überhaupt beginnt – die angemeldete Meldung liefe dann
          doch auf, und niemand verstünde warum. So gilt eine Anmeldung vom
          Aufruf bis zum Ende des laufenden Tests, gleich wo sie steht.

          Im `finally`, weil die Prüfung darüber wirft: Bliebe die Anmeldung
          stehen, wäre die Wache im nächsten Test taub.
        */
        erwartungen = [];
      }
    },
    { auto: true },
  ],

  konsolenwacheAbschluss: [
    // eslint-disable-next-line no-empty-pattern -- siehe oben: Playwright verlangt die Destrukturierung als erstes Argument.
    async ({}, benutzen) => {
      await benutzen(undefined);

      const befunde = entnehmen();
      if (befunde.length > 0) {
        throw new Error(bericht(befunde));
      }
    },
    { scope: 'worker', auto: true },
  ],
});
