/**
 * Rauchtest gegen die **gepackte** Anwendung.
 *
 * Im gepackten Zustand gilt `app.isPackaged === true`, und damit greift eine
 * andere Pfadauflösung: Katalog und Lizenztexte liegen dann unter
 * `resources/`, nicht im Projektbaum. Genau dieser Unterschied hat schon
 * einmal dazu geführt, dass der Fragenkatalog nicht gefunden wurde – ein
 * Fehler, den kein Test gegen den Quellbaum bemerkt hätte.
 *
 * Läuft nur, wenn zuvor `npm run dist:win` gelaufen ist.
 */
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

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

import { cspRichtlinie } from '../src/shared/csp';
import { paketstand } from '../tools/paketstand.mjs';
import {
  angewandteRichtlinien,
  konsoleBeimLaden,
  metaRichtlinie,
  projektWurzel,
  weiterAmZuschnitt,
} from './electron-hilfe';
import { expect, fensterUeberwachen, konsolenwacheAnhaengen, test } from './konsolenwache';

const EXE = join(projektWurzel, 'release', 'win-unpacked', 'Waffensachkunde Lernsoftware.exe');

// `undefined`, solange der beforeAll-Hook übersprungen wurde.
let app: ElectronApplication | undefined;
let fenster: Page;
let profil: string;

test.beforeAll(async () => {
  /*
    Nicht nur, OB ein Paket dasteht, sondern ob es zum Quelltext passt.

    Ein Paket von gestern gegen den Quelltext von heute ist schlimmer als gar
    keines: Der Lauf ist grün und misst die vorige Fassung. Genau das ist hier
    zweimal passiert – der Wortlaut „0 von 575 Fragen sicher“ nach dem Umbau
    der Reifekennzahl und der mehrdeutige Locator nach der neuen Katalogzeile.
    Beide Male meldete das Gate grün, beide Male fiel es erst beim nächsten
    Paketbau auf. `bauPruefen()` führt dieses Argument für `out/` seit jeher;
    auf das Paket wurde es nie angewandt.

    Übersprungen statt abgebrochen: `npm run gate` baut das Paket nicht mit,
    und ein harter Abbruch zwänge zu einem mehrminütigen `dist:win` vor jedem
    Lauf. Dass dabei etwas ungeprüft bleibt, sagt der Gate-Bericht am Ende –
    aus derselben Quelle, damit Bericht und Lauf nicht auseinanderlaufen.
  */
  const paket = paketstand(projektWurzel);
  test.skip(!paket.vorhanden || paket.veraltet, paket.grund);

  profil = mkdtempSync(join(tmpdir(), 'wsk-gepackt-'));
  app = await electron.launch({ executablePath: EXE, args: [`--user-data-dir=${profil}`] });
  /* Diese Datei startet selbst und geht nicht durch `appStarten` – die
     Konsolenwache muss deshalb hier von Hand angehängt werden. Gerade hier
     lohnt sie: Nur im Paket gelten die ausgelieferten Pfade und die strenge
     Richtlinie, ein Fehler daraus meldet sich sonst erst beim Anwender. */
  konsolenwacheAnhaengen(app);
  fenster = await app.firstWindow();
  fensterUeberwachen(fenster);
  await fenster.waitForLoadState('domcontentloaded');
  await fenster.waitForSelector('h1', { state: 'visible' });

  /* Diese Datei startet das Paket selbst und geht nicht durch `appStarten` –
     die Erststart-Frage nach dem abwählbaren Kapitel steht deshalb auch hier.
     Beantwortet wird sie wie überall mit „mitlernen“. */
  await weiterAmZuschnitt(fenster);
});

test.afterAll(async () => {
  await app?.close();
  try {
    rmSync(profil, { recursive: true, force: true });
  } catch {
    /* Aufräumen ist Kür. */
  }
});

test('startet und zeigt den Startbildschirm', async () => {
  await expect(fenster).toHaveTitle('Waffensachkunde – Lernsoftware');
  await expect(fenster.getByRole('heading', { level: 1 })).toHaveText(
    'Waffensachkunde – Lernsoftware',
  );
});

test('findet den Fragenkatalog unter resources/', async () => {
  /*
    Der schärfste Test des gepackten Zustands: Steht die Gesamtzahl da, wurde
    katalog.json tatsächlich gefunden und geladen.

    Aus `aria-valuemax` statt aus dem Text. Der Satz der Ampel wechselt seine
    Form je nach Stand – bei leerem Stand nennt er die Belegregel statt einer
    Zahl –, die Obergrenze des Balkens ist dagegen immer der Umfang des
    Katalogs. Die vorige Fassung hing am Wortlaut „0 von 575 Fragen sicher“
    und wurde beim Umbau der Kennzahl übersehen: Diese Datei läuft nur, wenn
    `release/win-unpacked/` existiert, und war deshalb bei jedem Lauf ohne
    gebautes Paket schlicht übersprungen.
  */
  await expect(
    fenster.getByRole('progressbar', { name: 'Prüfungsreife insgesamt' }),
  ).toHaveAttribute('aria-valuemax', '575', { timeout: 15_000 });
});

test('findet die Erklaerungen unter resources/', async () => {
  /* Die Erklaerungen liegen als eigene Datei neben dem Katalog. Ihr Pfad
     wird im gepackten Zustand anders aufgeloest als im Quellbaum – genau
     diese Unterscheidung hatte beim Katalog schon einmal versagt. */
  const anzahl = await fenster.evaluate(async () => {
    const api = (globalThis as Record<string, unknown>)['lernApp'] as
      { erklaerungenLaden?: () => Promise<{ zuFrage: Record<string, unknown> }> } | undefined;
    if (!api?.erklaerungenLaden) {
      return -1;
    }
    const daten = await api.erklaerungenLaden();
    return Object.keys(daten.zuFrage).length;
  });

  /* Nicht nur „mehr als null": Der Lader faellt bei jedem Formfehler still
     auf einen leeren Bestand zurueck, und genau das ist einmal passiert –
     450 Angaben standen als Zahl statt als Zeichenkette in der Datei, das
     Paket haette ohne eine einzige Erklaerung ausgeliefert. Geprueft wird
     deshalb gegen die Zahl der Fragen im Katalog. */
  expect(anzahl).toBe(575);
});

test('findet das Glossar unter resources/', async () => {
  /* Dieselbe Falle wie bei den Erklaerungen: Der Lader faellt bei jedem
     Formfehler still auf einen leeren Bestand zurueck. Ein Glossar, das
     ausgeliefert wird und leer ankommt, faellt sonst niemandem auf. */
  const anzahl = await fenster.evaluate(async () => {
    const api = (globalThis as Record<string, unknown>)['lernApp'] as
      { glossarLaden?: () => Promise<{ eintraege: unknown[] }> } | undefined;
    if (!api?.glossarLaden) {
      return -1;
    }
    const daten = await api.glossarLaden();
    return daten.eintraege.length;
  });

  // Der Bestand waechst; geprueft wird, dass er ueberhaupt ankommt.
  expect(anzahl).toBeGreaterThan(50);
});

test('findet die Normtexte unter resources/', async () => {
  /* Seit 0.23.0 liegt der Wortlaut der zitierten Normen bei. Dieselbe Falle
     wie bei Katalog, Erklaerungen und Glossar: Der Lader faellt bei jedem
     Formfehler still auf einen leeren Bestand zurueck – und die Fundstellen
     saehen dann wieder aus wie vor 0.23.0, naemlich wie reine Zitate ohne
     Sprungziel. Das faellt in einem gepackten Paket niemandem auf, weil die
     Anwendung weiterlaeuft. */
  const gesetze = await fenster.evaluate(async () => {
    const api = (globalThis as Record<string, unknown>)['lernApp'] as
      { normtexteLaden?: () => Promise<{ gesetze: Record<string, unknown> }> } | undefined;
    if (!api?.normtexteLaden) {
      return -1;
    }
    const daten = await api.normtexteLaden();
    return Object.keys(daten.gesetze).length;
  });

  // Sieben Gesetze werden zitiert; weniger hiesse, dass etwas fehlt.
  expect(gesetze).toBe(7);
});

test('findet die Themengliederung unter resources/', async () => {
  /* Ohne sie stuende in den Kapiteln II bis IV nur der Knopf fuer das ganze
     Kapitel – also der Zustand vor 0.23.0. Auch das laeuft still weiter. */
  const fragen = await fenster.evaluate(async () => {
    const api = (globalThis as Record<string, unknown>)['lernApp'] as
      { themenLaden?: () => Promise<{ gruppen: { fragen: string[] }[] }> } | undefined;
    if (!api?.themenLaden) {
      return -1;
    }
    const daten = await api.themenLaden();
    return daten.gruppen.reduce((summe, gruppe) => summe + gruppe.fragen.length, 0);
  });

  /* Gegen die Zahl der Fragen ohne amtlichen Abschnitt und nicht gegen „mehr
     als null": Die Gliederung ist nur brauchbar, wenn sie vollstaendig ist. */
  expect(fragen).toBe(230);
});

test('meldet eine einsatzbereite Datenbank', async () => {
  await expect(fenster.getByText(/SQLite \d+\.\d+/u)).toBeVisible();
});

test('wendet im Paket beide Richtlinien an – die strenge auch im HTTP-Kopf', async () => {
  /*
    Erst hier ist die Frage wirklich beantwortet.

    `app.isPackaged` ist nur im Paket wahr, und erst dann liefert der
    Hauptprozess die Produktionsfassung in den Kopf; der Lauf gegen `out/`
    misst dort die Dev-Fassung. Und erst hier steht fest, dass der Kopf-Weg
    beim Laden über `file://` aus dem Paket heraus überhaupt ankommt – genau
    das hatte `src/shared/csp.ts` jahrelang anders behauptet.

    Geprüft wird deshalb der Wortlaut BEIDER tatsächlich angewandter
    Richtlinien gegen die eine Quelle der Wahrheit.
  */
  const meta = await metaRichtlinie(fenster);
  expect(meta).toBe(cspRichtlinie('production', 'meta'));

  const angewandt = await angewandteRichtlinien(fenster);
  expect(angewandt.slice().sort()).toEqual(
    [cspRichtlinie('production', 'meta'), cspRichtlinie('production', 'kopf')].sort(),
  );

  /* Der Punkt der ganzen Übung: Die Direktive kommt über den Kopf an, wo sie
     gilt – und nicht mehr über das `<meta>`-Element, wo Chromium sie verwirft. */
  const ausDemKopf = angewandt.filter((r) => r !== meta);
  expect(ausDemKopf[0]).toContain("frame-ancestors 'none'");
  expect(meta).not.toContain('frame-ancestors');
});

test('startet ohne Konsolenfehler über die Richtlinie', async () => {
  /* Der ignorierte `frame-ancestors`-Eintrag im `<meta>` schrieb bei jedem
     Start einen Konsolenfehler. Im ausgelieferten Programm sieht den niemand –
     grund genug, ihn hier festzuhalten, statt ihn im Paket mitzuliefern. */
  const meldungen = await konsoleBeimLaden(fenster);

  const zurRichtlinie = meldungen.filter((m) => /content security policy/iu.test(m));
  expect(
    zurRichtlinie,
    `Konsolenausgabe beim Laden:\n${meldungen.join('\n') || '(keine)'}`,
  ).toEqual([]);
});

test('zeigt die Anwendungsversion, nicht die von Electron', async () => {
  /* Die Nummer kommt aus der package.json, nicht aus dem Test: Eine fest
     eingetragene Version bräche diesen Test bei jedem Versionswechsel – und
     zwar mit einer Meldung, die den eigentlichen Punkt verfehlt. */
  const paket = JSON.parse(readFileSync(join(projektWurzel, 'package.json'), 'utf8')) as {
    version: string;
  };
  /* Gezielt der Wert NEBEN „Programmversion“: Die Liste nennt die
     Electron-Version an anderer Stelle völlig zu Recht, ein Blick auf die
     ganze Liste ginge also am Punkt vorbei. */
  const wert = fenster.locator('.statusliste dt:has-text("Programmversion") + dd');

  await expect(wert).toHaveText(paket.version);
});

test('nennt den Baustand mit Commit-Kürzel', async () => {
  /* Die Versionsnummer allein benennt keinen Stand: Zwischen zwei
     Veröffentlichungen entstehen viele Bauten mit derselben Nummer. */
  const liste = fenster.locator('.statusliste');

  await expect(liste).toContainText('Baustand');
  /* Eigene Klasse statt "das code-Element in der Liste": Seit dort auch der
     Quellort des Fragenkatalogs steht, gibt es zwei. Die Mehrdeutigkeit fiel
     erst beim naechsten Bau auf, weil diese Datei nur gegen ein gebautes
     Paket laeuft - genau der Fall, vor dem docs/veroeffentlichen.md warnt. */
  await expect(fenster.locator('.statusliste__baukennung')).toHaveText(/^[0-9a-f]{7,}\+?$/u);
});

test('lädt die Lizenztexte aus resources/', async () => {
  await fenster
    .getByRole('button', { name: /Lizenzen und Herkunft anzeigen/i })
    .first()
    .click();

  await expect(fenster.getByRole('heading', { name: /Über diese Software/i })).toBeVisible();
  // EUPL-Volltext und Fremdkomponenten kommen aus extraResources.
  await expect(fenster.getByText(/European Union Public Licence/i).first()).toBeVisible();
  await expect(fenster.getByText(/better-sqlite3/i).first()).toBeVisible();
});

/*
  Die Datenschutzerklärung war bis Fassung 0.24.2 in der Anwendung nirgends
  erreichbar: `docs/` kommt nicht ins Paket, und der einzige Weg nach außen
  führt zur Unterstützungsseite. Seit 0.25.0 liegt `content/datenschutz.json`
  über `extraResources` unter `resources/` — und ausschließlich diese Prüfung
  belegt, dass sie dort auch ankommt. Ein Lader, der die Datei nicht findet,
  zeigt eine Begründung statt der Erklärung; im Quellbaum fiele das nie auf,
  weil er sie dort aufwärts sucht und immer findet.
*/
test('lädt die Datenschutzerklärung aus resources/', async () => {
  /* Kein Klick auf „Lizenzen und Herkunft anzeigen“: Die Prüfungen dieser
     Datei teilen sich eine laufende Anwendung, und die vorige lässt „Über
     diese Software“ offen. Der Knopf steht dort nicht mehr. */
  await expect(fenster.getByRole('heading', { name: 'Datenschutz', level: 2 })).toBeVisible();

  const schalter = fenster.getByRole('button', { name: /Datenschutzerklärung lesen/i });
  await expect(schalter).toBeVisible();
  await schalter.click();

  /* Ein Satz aus Abschnitt 3, der die Aussage der Erklärung trägt – und
     zugleich die Stelle, an der die Oberfläche bis 0.24.2 das Gegenteil
     behauptete („sie sammelt keine Daten“). */
  await expect(fenster.getByText(/entstehen Daten/i).first()).toBeVisible();
});

test('startet eine Lernsitzung mit echten Katalogfragen', async () => {
  await fenster
    /* „Zum Start“ steht seit der Zurueckleiste zweimal in dieser Ansicht:
       oben zur Orientierung, unten hinter dem langen Inhalt. Beide fuehren
       an dieselbe Stelle. */
    .getByRole('button', { name: /Zum Start/i })
    .first()
    .click();
  await fenster
    .getByRole('button', { name: /Weiterlernen/i })
    .first()
    .click();

  // Eine amtliche Fragennummer belegt, dass echte Daten geladen wurden.
  await expect(fenster.getByRole('heading', { name: /Frage \d/u })).toBeVisible({
    timeout: 15_000,
  });
});
