// @vitest-environment node
/**
 * Das Unterstützungsangebot: die Adresse, der Weg nach außen, sein Ort.
 *
 * Drei Dinge sind hier zu sichern, und sie hängen nicht zusammen:
 *
 * 1. **Die Form der Adresse.** Sie ist heute leer und wird später von Hand
 *    eingetragen. Die Prüfungen dürfen deshalb nicht vom heutigen Wert
 *    abhängen – ein Test, der auf „leer“ besteht, machte aus der zugesagten
 *    Einzeiler-Änderung einen roten Lauf.
 * 2. **Der Weg nach außen.** `shell.openExternal` reicht an das
 *    Betriebssystem weiter; was dort ankommt, ist nicht mehr einzufangen.
 *    Geöffnet werden darf deshalb ausschließlich die eingetragene Adresse,
 *    und nichts sonst.
 * 3. **Der Ort des Angebots.** Es steht in „Über diese Software“ und
 *    ausdrücklich nirgends sonst. Ein Einblenden mitten in der Lernsitzung
 *    wäre genau das Muster, das dieses Projekt bei Streaks und Tagesziel-Ring
 *    abgelehnt hat (`docs/entscheidung-motivation.md`). Ein Verhaltenstest
 *    könnte das nicht beweisen – er könnte nur an einer Stelle nachsehen, an
 *    der gerade niemand etwas eingebaut hat. Geprüft wird deshalb der
 *    Quelltext.
 */

import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
  UNTERSTUETZUNG_KNOPF,
  UNTERSTUETZUNG_URL,
  unterstuetzungsziel,
} from '../src/shared/unterstuetzung';
/* Nur für die Typangabe von `importOriginal` weiter unten – als benannter
   Namensraum, weil `import()`-Typen projektweit untersagt sind. */
import type * as Unterstuetzungsmodul from '../src/shared/unterstuetzung';

const wurzel = join(fileURLToPath(new URL('..', import.meta.url)), '..');

/** Eine erfundene, aber formgerechte Adresse – die echte steht noch nicht fest. */
const PROBE = 'https://beispiel.example/unterstuetzen';

// ─── 1. Die Form der Adresse ────────────────────────────────────────────

describe('Adresse der Unterstützungsseite', () => {
  it('lässt eine https-Adresse gelten und gibt sie unverändert zurück', () => {
    /* Unverändert und nicht als `URL.href` neu zusammengesetzt: Angezeigt und
       geöffnet wird so dieselbe Zeichenkette, und der Vergleich im
       Hauptprozess trifft genau das, was auf dem Bildschirm stand. `href`
       hängte hier ein „/“ an und die Anzeige liefe von der Eingabe weg. */
    expect(unterstuetzungsziel('https://ko-fi.example/name')).toBe('https://ko-fi.example/name');
    expect(unterstuetzungsziel('  https://ko-fi.example/name  ')).toBe(
      'https://ko-fi.example/name',
    );
  });

  it.each([
    ['leer', ''],
    ['nur Leerzeichen', '   '],
    ['ohne Schema', 'ko-fi.example/name'],
    ['unverschlüsselt', 'http://ko-fi.example/name'],
    ['Skript-Schema', 'javascript:alert(1)'],
    ['Postfach-Schema', 'mailto:name@beispiel.example'],
    ['Datei-Schema', 'file:///C:/Windows/win.ini'],
    ['unvollständig', 'https://'],
    ['mit Zugangsdaten', 'https://name:wort@ko-fi.example/'],
    ['Unsinn', 'nichts'],
  ])('behandelt eine Adresse %s wie „nicht gesetzt“', (_fall, eingabe) => {
    /* „Nicht gesetzt“ und nicht „Fehler“: Ein Vertipper soll dieselbe Folge
       haben wie ein leeres Feld – das Angebot entfällt. Ein toter Knopf oder
       eine Fehlermeldung an dieser Stelle wäre für den Lernenden ein Mangel
       der Anwendung, obwohl es einer des Eintrags ist. */
    expect(unterstuetzungsziel(eingabe)).toBeNull();
  });

  it('hält die eingetragene Adresse an die eigene Form', () => {
    /*
      Bewusst KEINE Prüfung auf „heute leer“. Der Eintrag ist eine
      Einzeiler-Änderung und darf keinen roten Lauf auslösen. Geprüft wird
      deshalb die Zusage, die in beiden Zuständen gilt: Was durchkommt, ist
      entweder nichts oder eine https-Adresse – nie etwas dazwischen.
    */
    const ziel = unterstuetzungsziel(UNTERSTUETZUNG_URL);

    if (ziel !== null) {
      expect(ziel).toMatch(/^https:\/\//u);
      // Und die Prüfung ist stabil: eine bereits geprüfte Adresse bleibt gültig.
      expect(unterstuetzungsziel(ziel)).toBe(ziel);
    }
  });
});

// ─── 2. Der Weg nach außen ──────────────────────────────────────────────

describe('Der Weg nach außen', () => {
  /** Steht für `shell.openExternal` und meldet, ob und womit es gerufen wurde. */
  const browserOeffnen = vi.fn<(url: string) => Promise<void>>();

  /**
   * Was der Hauptprozess ins Protokoll geschrieben hat.
   *
   * Aufgefangen statt ausgegeben: Das Protokoll gehört nicht in die
   * Testausgabe, und eine Abweisung, die stillschweigend geschieht, wäre bei
   * der Fehlersuche später nicht auffindbar – deshalb wird sie auch geprüft.
   * Der Wächter entsteht in `beforeEach`, weil `restoreMocks` in der
   * Vitest-Einstellung jeden Spion nach jedem Test zurücknimmt.
   */
  const warnungen: string[] = [];

  beforeEach(() => {
    warnungen.length = 0;
    vi.spyOn(console, 'warn').mockImplementation((...teile: unknown[]) => {
      warnungen.push(teile.map(String).join(' '));
    });
  });

  /**
   * Lädt `main/sicherheit.ts` mit einer gesetzten Adresse neu.
   *
   * Über `doMock` und einen dynamischen Import, weil beide Zustände zu prüfen
   * sind – der von heute (keine Adresse) und der von morgen. Ein Testlauf, der
   * nur den heutigen sähe, ließe genau die Änderung ungeprüft, für die dieses
   * Modul gebaut ist.
   */
  async function aussenweg(adresse: string): Promise<(gewuenscht: unknown) => Promise<boolean>> {
    vi.resetModules();
    browserOeffnen.mockReset();
    browserOeffnen.mockResolvedValue(undefined);

    vi.doMock('electron', () => ({
      /* Nur, was `sicherheit.ts` importiert. Aufgerufen wird an dieser Stelle
         allein `shell.openExternal`; die übrigen sind Platzhalter, damit der
         Import überhaupt gelingt. */
      app: { on: vi.fn() },
      session: { defaultSession: {} },
      shell: { openExternal: browserOeffnen },
    }));
    vi.doMock('../src/shared/unterstuetzung', async (echt) => ({
      ...(await echt<typeof Unterstuetzungsmodul>()),
      UNTERSTUETZUNG_URL: adresse,
    }));

    const { unterstuetzungOeffnen } = await import('../src/main/sicherheit');
    return unterstuetzungOeffnen;
  }

  afterEach(() => {
    vi.doUnmock('electron');
    vi.doUnmock('../src/shared/unterstuetzung');
    vi.resetModules();
  });

  it('öffnet die eingetragene Adresse im Standardbrowser', async () => {
    const oeffnen = await aussenweg(PROBE);

    await expect(oeffnen(PROBE)).resolves.toBe(true);
    expect(browserOeffnen.mock.calls).toEqual([[PROBE]]);
  });

  it('öffnet keine andere Adresse, auch keine harmlos aussehende', async () => {
    /*
      Der Kern der Sache. Die Adresse wandert über die Brücke, damit prüfbar
      bleibt, dass die Oberfläche genau die anfordert, die sie anzeigt –
      maßgeblich ist sie damit nicht. Wer eine Adresse mitbringen darf, darf
      sonst auch eine andere mitbringen.
    */
    const oeffnen = await aussenweg(PROBE);

    for (const fremd of [
      'https://beispiel.example/etwas-anderes',
      'https://boeswillig.example/',
      `${PROBE}/`,
      PROBE.toUpperCase(),
      'file:///C:/Windows/System32/cmd.exe',
    ]) {
      await expect(oeffnen(fremd)).resolves.toBe(false);
    }

    expect(browserOeffnen).not.toHaveBeenCalled();
    expect(warnungen.length).toBeGreaterThan(0);
  });

  it('öffnet nichts, was keine Zeichenkette ist', async () => {
    /* Was über die Brücke kommt, ist an dieser Grenze unbekannt – die
       Typisierung beschreibt, was der Renderer schicken SOLL. */
    const oeffnen = await aussenweg(PROBE);

    for (const unsinn of [undefined, null, 42, { url: PROBE }, [PROBE]]) {
      await expect(oeffnen(unsinn)).resolves.toBe(false);
    }

    expect(browserOeffnen).not.toHaveBeenCalled();
  });

  it('öffnet nichts, solange keine Adresse eingetragen ist', async () => {
    /* Der heutige Zustand. Selbst wenn die Oberfläche fragte – sie zeigt das
       Angebot gar nicht erst –, geschähe nichts. */
    const oeffnen = await aussenweg('');

    await expect(oeffnen(PROBE)).resolves.toBe(false);
    await expect(oeffnen('')).resolves.toBe(false);
    expect(browserOeffnen).not.toHaveBeenCalled();
  });

  it('öffnet nichts, wenn die eingetragene Adresse die Form verfehlt', async () => {
    /* Ein `http://` im Eintrag ist kein halber Treffer, sondern kein Ziel. */
    const oeffnen = await aussenweg('http://beispiel.example/unterstuetzen');

    await expect(oeffnen('http://beispiel.example/unterstuetzen')).resolves.toBe(false);
    expect(browserOeffnen).not.toHaveBeenCalled();
  });

  it('meldet einen gescheiterten Aufruf, statt ihn durchschlagen zu lassen', async () => {
    /* Scheitert der Systemaufruf, bekommt die Oberfläche `false` und zeigt die
       Adresse zum Abschreiben. Eine geworfene Ausnahme über die Brücke wäre
       für den Lernenden dieselbe Lage mit schlechterem Text. */
    const oeffnen = await aussenweg(PROBE);
    browserOeffnen.mockRejectedValueOnce(new Error('Kein Standardbrowser eingerichtet'));

    await expect(oeffnen(PROBE)).resolves.toBe(false);
    expect(warnungen.join('\n')).toMatch(/Standardbrowser/u);
  });
});

// ─── 3. Der Ort des Angebots ────────────────────────────────────────────

describe('Wo das Angebot stehen darf', () => {
  /** Der ausgelieferte Quelltext, oder `null` ohne Git. */
  function quelldateien(): readonly { readonly pfad: string; readonly inhalt: string }[] | null {
    let dateien: string[];
    try {
      dateien = execFileSync('git', ['ls-files', 'app/src'], {
        cwd: wurzel,
        encoding: 'utf8',
        stdio: ['ignore', 'pipe', 'ignore'],
      })
        .split('\n')
        .map((zeile) => zeile.trim())
        .filter((zeile) => zeile.endsWith('.tsx'));
    } catch {
      return null;
    }
    return dateien.map((pfad) => ({ pfad, inhalt: readFileSync(join(wurzel, pfad), 'utf8') }));
  }

  it('steht in genau einer Ansicht – und das ist „Über diese Software“', () => {
    const dateien = quelldateien();
    if (dateien === null) {
      /* Kein Git – dann ist nicht feststellbar, was ausgeliefert würde. */
      return;
    }

    expect(dateien.length).toBeGreaterThan(20);

    const verwender = dateien
      .filter(
        (datei) =>
          datei.inhalt.includes('<Unterstuetzung') &&
          !datei.pfad.endsWith('components/ueber/Unterstuetzung.tsx'),
      )
      .map((datei) => datei.pfad);

    expect(
      verwender,
      'Das Unterstützungsangebot gehört an genau eine ruhige Stelle. Ein zweiter ' +
        'Einbau – im Startbildschirm, in der Lernsitzung, in der Auswertung – ' +
        'wäre das Muster, das docs/entscheidung-motivation.md ablehnt.',
    ).toEqual(['app/src/renderer/src/components/ueber/UeberSoftware.tsx']);
  });
});

// ─── Was der Knopf verspricht ───────────────────────────────────────────

describe('Beschriftung des Knopfes', () => {
  it('kündigt an, dass das Fenster verlassen wird', () => {
    /* WCAG 3.2: Ein Verweis, der unangekündigt den Browser startet, ist eine
       Überraschung. Die Beschriftung sagt es – und sie ist zugleich der ganze
       zugängliche Name, es gibt kein `aria-label` daneben (WCAG 2.5.3). */
    expect(UNTERSTUETZUNG_KNOPF).toMatch(/Browser/u);
    expect(UNTERSTUETZUNG_KNOPF).toMatch(/öffnen/u);
  });
});
