import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

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

import { THEME_BESCHRIFTUNGEN, type ThemeAufgeloest } from '../src/shared/theme';
import { fensterUeberwachen, konsoleErwartet, konsolenwacheAnhaengen } from './konsolenwache';

/** Projektwurzel (app/), unabhängig vom Arbeitsverzeichnis. */
export const projektWurzel = join(__dirname, '..');

/** Einstiegspunkt des gebauten Main-Prozesses. */
export const hauptEinstieg = join(projektWurzel, 'out', 'main', 'index.js');

/** Quelltextverzeichnis, gegen dessen Alter der Bau geprüft wird. */
const quellWurzel = join(projektWurzel, 'src');

/** Jüngste Änderungszeit unterhalb eines Verzeichnisses, in Millisekunden. */
function juengsteAenderung(verzeichnis: string): number {
  let juengste = 0;
  for (const eintrag of readdirSync(verzeichnis, { withFileTypes: true })) {
    const pfad = join(verzeichnis, eintrag.name);
    const zeit = eintrag.isDirectory() ? juengsteAenderung(pfad) : statSync(pfad).mtimeMs;
    if (zeit > juengste) {
      juengste = zeit;
    }
  }
  return juengste;
}

/**
 * Stellt sicher, dass ein **aktueller** Bau vorliegt – sonst Abbruch.
 *
 * ## Warum das wirft und nicht überspringt
 *
 * Vorher stand hier `buildVorhanden()`, und jede Suite rief damit
 * `test.skip()`. Wer `npx playwright test` ohne vorherigen Bau startete,
 * bekam einen grünen Lauf gemeldet, in dem 59 von 69 Tests übersprungen
 * wurden – darunter sämtliche Barrierefreiheitsprüfungen im echten Fenster.
 * Ein Gate, das darauf aufsetzt, prüft nichts und behauptet das Gegenteil.
 * Ein fehlender Bau ist kein Grund zum Schweigen, sondern ein Fehler.
 *
 * ## Warum zusätzlich das Alter zählt
 *
 * `existsSync` beantwortet nur, ob *irgendein* Bau daliegt. Ein Bau von
 * gestern gegen den Quelltext von heute ist schlimmer als gar keiner: Der
 * Lauf ist grün und misst die falsche Anwendung. Deshalb wird die jüngste
 * Änderung unter `src/` gegen die des gebauten Einstiegspunkts gehalten.
 *
 * Die Toleranz von zwei Sekunden fängt die Dateisystemauflösung und den
 * Umstand ab, dass electron-vite die Ausgaben nicht in derselben
 * Millisekunde schreibt, in der es sie liest.
 */
/**
 * Woran sich der Startbildschirm zweifelsfrei erkennen lässt.
 *
 * **Nicht am Knopf „Weiterlernen".** Den gibt es seit 0.22.0 auch auf der
 * Sitzungsauswertung – dort ist er der kürzere Weg zur nächsten Sitzung. Wer
 * ihn als Merkmal des Startbildschirms nimmt, hält die Auswertung für den
 * Start und sucht dort vergeblich nach der Datensicherung. Genau das ist
 * passiert: zehn E2E-Prüfungen fielen aus, nachdem der Knopf dazugekommen war.
 *
 * Die Überschrift der Ansicht gibt es dagegen genau einmal.
 */
export const STARTTITEL = /^Waffensachkunde/u;

/** Steht der Startbildschirm auf dem Schirm? */
export async function amStart(seite: Page): Promise<boolean> {
  return (await seite.getByRole('heading', { level: 1, name: STARTTITEL }).count()) > 0;
}

export function bauPruefen(): void {
  if (!existsSync(hauptEinstieg)) {
    throw new Error(
      'Kein Bau in out/ gefunden. Diese Tests prüfen die gebaute Anwendung – ' +
        'zuerst `npm run build` ausführen (oder `npm run test:e2e`, das baut selbst).',
    );
  }

  const gebautAm = statSync(hauptEinstieg).mtimeMs;
  const quelltextVon = juengsteAenderung(quellWurzel);
  const TOLERANZ_MS = 2000;

  if (quelltextVon > gebautAm + TOLERANZ_MS) {
    const alterSekunden = Math.round((quelltextVon - gebautAm) / 1000);
    throw new Error(
      `Der Bau in out/ ist älter als der Quelltext (um ${String(alterSekunden)} s). ` +
        'Diese Tests würden die vorige Fassung prüfen und grün melden. ' +
        'Zuerst `npm run build` ausführen (oder `npm run test:e2e`, das baut selbst).',
    );
  }
}

/** `process.env` enthält optionale Werte – Playwright erwartet reine Strings. */
function umgebung(): Record<string, string> {
  const bereinigt: Record<string, string> = {};
  for (const [schluessel, wert] of Object.entries(process.env)) {
    if (typeof wert === 'string') {
      bereinigt[schluessel] = wert;
    }
  }
  bereinigt['NODE_ENV'] = 'production';
  return bereinigt;
}

export interface GestarteteApp {
  readonly app: ElectronApplication;
  readonly fenster: Page;
}

/**
 * Startet die gebaute Anwendung mit einem frischen Nutzerprofil.
 *
 * Das eigene `--user-data-dir` ist wichtig: die App speichert die
 * Theme-Auswahl in `userData`. Ohne Isolation würde ein Lauf den nächsten
 * beeinflussen und die Tests wären reihenfolgeabhängig.
 *
 * `ELECTRON_DISABLE_SECURITY_WARNINGS` bleibt bewusst AUS: erscheinen
 * Sicherheitswarnungen in der Konsole, ist das ein echter Befund. Gehört wird
 * die Konsole von `e2e/konsolenwache.ts` – jahrelang stand hier nur der Satz,
 * und niemand hörte zu.
 */
export async function appStarten(): Promise<GestarteteApp> {
  const gestartet = await appStartenOhneZuschnitt();
  await weiterAmZuschnitt(gestartet.fenster);
  return gestartet;
}

/**
 * Wie {@link appStarten}, aber ohne die Erststart-Frage zu beantworten.
 *
 * Für die beiden Suiten, in denen die Frage selbst der Gegenstand ist:
 * `e2e/erststart.spec.ts` prüft ihr Verhalten, `barrierefreiheit-ansichten.spec.ts`
 * misst sie mit axe. Beide brauchen sie stehend – und beide bekommen dafür ein
 * eigenes, frisches Profilverzeichnis.
 */
export async function appStartenOhneZuschnitt(): Promise<GestarteteApp> {
  const profil = mkdtempSync(join(tmpdir(), 'wsk-e2e-'));

  const app = await electron.launch({
    args: [hauptEinstieg, `--user-data-dir=${profil}`],
    env: umgebung(),
  });

  /* Unmittelbar nach dem Start und vor `firstWindow()`: Der erste Ladevorgang
     ist die lauteste Phase – dort schrieb Chromium den CSP-Fehler –, und wer
     erst danach zuhört, hat ihn verpasst. */
  konsolenwacheAnhaengen(app);

  app.on('close', () => {
    try {
      rmSync(profil, { recursive: true, force: true });
    } catch {
      // Aufräumen ist Kür – ein verwaistes Temp-Verzeichnis darf den
      // Testlauf nicht zum Scheitern bringen.
    }
  });

  const fenster = await app.firstWindow();
  /* Zweiter Weg zum selben Fenster – `app.on('window')` feuert je nach
     Zeitverlauf schon vorher. Doppelt angemeldet wird nichts, das verhindert
     `fensterUeberwachen` selbst. */
  fensterUeberwachen(fenster);
  await fenster.waitForLoadState('domcontentloaded');

  // Warten, bis React gerendert hat.
  await fenster.waitForSelector('h1', { state: 'visible' });

  return { app, fenster };
}

/**
 * Beantwortet die Erststart-Frage, falls sie steht.
 *
 * Jeder Lauf beginnt mit einem frischen Profilverzeichnis – die Frage steht
 * also vor jedem Test. Beantwortet wird sie mit „mitlernen“, dem
 * vollständigen Katalog: Genau davon gehen alle übrigen Prüfungen aus, und
 * eine Antwort hier ist ehrlicher, als die Frage im Programm zu
 * unterdrücken.
 */
export async function weiterAmZuschnitt(fenster: Page): Promise<void> {
  const weiter = fenster.getByRole('button', { name: 'Weiter', exact: true });
  if ((await weiter.count()) === 0 || !(await weiter.first().isVisible())) {
    return;
  }

  await weiter.first().click();
  await fenster.waitForSelector('h1', { state: 'visible' });

  /*
    Neu laden, statt nur den Fokus zurückzunehmen.

    Die Anwendung setzt ihn nach dem Beantworten auf die Einstiegsüberschrift
    des Startbildschirms – richtig so. Für die übrigen Prüfungen ist das aber
    ein Zustand, den ein Start ohne Frage nicht hätte: Der Sprunglink-Test
    erwartet den ersten Tabstopp eines frisch geladenen Fensters.

    `blur()` genügt dafür nicht – nachgemessen landet der nächste Tabulator
    danach auf „Weiterlernen“ und nicht auf dem Sprunglink: Chromium merkt
    sich die Stelle, an der die Tabulatorreihenfolge weitergeht, und ein
    blosses Abmelden des Fokus setzt sie nicht zurück. Das Neuladen gibt ein
    wirklich frisches Dokument – und ist zugleich der Zustand, den jeder
    zweite Programmstart hat: Die Antwort steht in den Einstellungen, die
    Frage kommt nicht wieder.

    Wie der Fokus unmittelbar nach der Frage läuft, prüft
    `e2e/erststart.spec.ts`.
  */
  await fenster.reload();
  await fenster.waitForSelector('h1', { state: 'visible' });
}

/** Der Wortlaut, der im `<meta>`-Element des geladenen Dokuments steht. */
export async function metaRichtlinie(seite: Page): Promise<string> {
  const inhalt = await seite
    .locator('meta[http-equiv="Content-Security-Policy"]')
    .getAttribute('content');
  expect(inhalt, 'Das Dokument trägt kein CSP-<meta>-Element.').toBeTruthy();
  return inhalt ?? '';
}

/**
 * Liest die Richtlinien aus, die der Renderer **tatsächlich anwendet**.
 *
 * Steht hier und nicht in einer einzelnen Suite, weil zwei Suiten dieselbe
 * Messung brauchen: `anwendung.spec.ts` gegen den Bau in `out/`,
 * `gepackt.spec.ts` gegen das ausgelieferte Paket. Erst die zweite beantwortet
 * die Frage, um die es geht – im Paket ist `app.isPackaged` wahr, und erst
 * dann liefert der Hauptprozess die strenge Fassung in den HTTP-Kopf.
 *
 * ## Wie gemessen wird
 *
 * Gelten mehrere Richtlinien nebeneinander – hier die aus dem `<meta>`-Element
 * und die aus dem HTTP-Kopf –, meldet ein einziger Verstoß je Richtlinie ein
 * `securitypolicyviolation`-Ereignis, und jedes trägt in `originalPolicy` den
 * Wortlaut genau der Richtlinie, an der es gescheitert ist. Das ist der
 * einzige Weg, aus dem Dokument heraus zu erfahren, was wirklich gilt, statt
 * was irgendwo geschrieben steht.
 *
 * Ausgelöst wird der Verstoß mit einem Bild von einer Adresse, die
 * `img-src 'self' data:` nicht deckt. Netzverkehr entsteht dabei keiner: Die
 * CSP bricht den Ladeversuch ab, bevor er beginnt – und `.invalid` ist laut
 * RFC 2606 ohnehin dauerhaft unauflösbar.
 */
export async function angewandteRichtlinien(seite: Page): Promise<string[]> {
  /*
    Der Verstoß wird absichtlich ausgelöst – und Chromium schreibt für jede
    geltende Richtlinie einen Konsolenfehler darüber. Ohne diese Anmeldung
    fiele die Konsolenwache über die eigene Messung her.

    Angemeldet wird das genaue Bild, nicht „irgendein CSP-Verstoß": Ein
    weiter gefasstes Muster machte die Wache blind für echte Verstöße – also
    für die Fehlerklasse, derentwegen es sie gibt.
  */
  konsoleErwartet(
    /Loading the image 'https:\/\/gibt-es-nicht\.invalid\/pixel\.png' violates/u,
    'Der Bildaufruf ist die Messung selbst: Nur ein echter Verstoß verrät, welche ' +
      'Richtlinien gelten. Je geltender Richtlinie meldet Chromium ihn einmal.',
  );

  return seite.evaluate(async () => {
    const gesammelt: string[] = [];
    const zuhoerer = (ereignis: SecurityPolicyViolationEvent): void => {
      gesammelt.push(ereignis.originalPolicy);
    };
    document.addEventListener('securitypolicyviolation', zuhoerer);

    const bild = new Image();
    bild.src = 'https://gibt-es-nicht.invalid/pixel.png';

    // Die Meldung kommt asynchron; eine Sekunde ist reichlich bemessen.
    await new Promise((fertig) => setTimeout(fertig, 1000));
    document.removeEventListener('securitypolicyviolation', zuhoerer);
    return gesammelt;
  });
}

/**
 * Sammelt die Konsolenausgabe eines Ladevorgangs.
 *
 * Der erste Ladevorgang ist vorbei, bevor ein Zuhörer hängen kann; gemessen
 * wird deshalb beim Neuladen. Das ist derselbe Vorgang wie ein Programmstart
 * und hinterlässt ein frisches Dokument.
 */
export async function konsoleBeimLaden(seite: Page): Promise<string[]> {
  const meldungen: string[] = [];
  const zuhoerer = (m: ConsoleMessage): void => {
    meldungen.push(`${m.type()}: ${m.text()}`);
  };
  seite.on('console', zuhoerer);

  try {
    await seite.reload();
    await seite.waitForSelector('h1', { state: 'visible' });
  } finally {
    seite.off('console', zuhoerer);
  }

  return meldungen;
}

/**
 * Wartet, bis alle laufenden CSS-Übergänge und Animationen abgeschlossen sind.
 *
 * Ohne das misst axe-core die Farben mitten im Theme-Übergang (die
 * Bedienelemente blenden über 140 ms um) und meldet sporadisch
 * Kontrastfehler, die es im Ruhezustand gar nicht gibt.
 */
export async function animationenAbwarten(seite: Page): Promise<void> {
  await seite.evaluate(async () => {
    const laufende = document.getAnimations();
    await Promise.all(laufende.map(async (a) => a.finished.catch(() => undefined)));
  });
}

/**
 * Farbschema über den Umschalter auf dem Startbildschirm setzen.
 *
 * Steht hier und nicht in einer einzelnen Suite, weil zwei Suiten je
 * Farbschema messen und beide dieselbe Bewegung brauchen: klicken, auf das
 * Attribut am Wurzelelement warten, die Übergänge auslaufen lassen. Das letzte
 * Warten ist Pflicht – ohne es misst axe die Farben mitten im Umblenden
 * (140 ms, `--uebergang-dauer`) und meldet Kontrastfehler, die es im
 * Ruhezustand nicht gibt.
 *
 * Der Umschalter steht ausschließlich auf dem Startbildschirm; wer aus einer
 * anderen Ansicht kommt, muss vorher dorthin zurück.
 */
export async function themaSetzen(seite: Page, thema: ThemeAufgeloest): Promise<void> {
  await seite.getByRole('radio', { name: THEME_BESCHRIFTUNGEN[thema], exact: true }).click();
  await expect(seite.locator('html')).toHaveAttribute('data-thema', thema);
  await animationenAbwarten(seite);
}
