// @vitest-environment node
/**
 * Die Offline-Zusage – als Wache, nicht als Beobachtung.
 *
 * ## Warum es diese Datei gibt
 *
 * „Offline und ohne Konto. Keine Anmeldung, keine Cloud, keine Telemetrie“
 * steht in `README.md`, in „Über diese Software“, im Handbuch und in
 * Abschnitt 2 der Datenschutzerklärung. Es ist die weitreichendste Zusage
 * dieses Programms, und sie war bis Fassung 0.24.1 die einzige große, die
 * **niemand bewachte**: Dass in `app/src` kein `fetch`, kein `net.request`
 * und kein `https.get` steht, war ein von Hand nachgezählter Befund. Beim
 * nächsten Umbau wäre niemand rot geworden — `docs/stand.md` führt genau das
 * unter „Als Nächstes“ (Abschnitt 8.2, Punkt 9), und die
 * Datenschutzerklärung sagt in Abschnitt 2 ausdrücklich „nachgezählt, nicht
 * bewacht“.
 *
 * ## Was hier geprüft wird
 *
 * Gelesen wird, was `git ls-files` unter `app/src` führt — nur das wird
 * ausgeliefert. Gesucht wird nach den Aufrufen, mit denen eine Verbindung
 * überhaupt entstehen kann. Nicht nach Zeichenketten wie „http“: Adressen
 * stehen an mehreren Stellen als **Text** da (die Quellenangabe des
 * Katalogs, die Adresse der Unterstützungsseite, `gesetze-im-internet.de` in
 * Kommentaren), und ein Test, der daran anschlägt, wird nach dem zweiten
 * Fehlalarm abgeschaltet.
 *
 * ## Die zwei erlaubten Wege nach außen
 *
 * Beide liegen in `main/sicherheit.ts`, beide öffnen nur auf ausdrücklichen
 * Klick den **Systembrowser** — die Anwendung selbst verbindet sich dabei
 * nicht. `shell.openExternal` ist deshalb erlaubt, aber nur dort; taucht es
 * anderswo auf, ist das ein neuer Weg nach außen und gehört in die
 * Datenschutzerklärung.
 */

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

import { describe, expect, it } from 'vitest';

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

/**
 * Aufrufe, mit denen eine Verbindung nach außen entsteht.
 *
 * Jeder mit Wortgrenze und öffnender Klammer: `fetch(` trifft, `prefetch(`
 * nicht, und das Wort „fetch“ in einem Kommentar auch nicht.
 */
const NETZAUFRUFE: readonly (readonly [RegExp, string])[] = Object.freeze([
  [/(?<![\w.])fetch\s*\(/u, 'fetch('],
  [/\bnet\.(?:request|fetch)\s*\(/u, 'net.request('],
  [/\bhttps?\.(?:get|request)\s*\(/u, 'https.get('],
  [/\bnew\s+XMLHttpRequest\b/u, 'new XMLHttpRequest'],
  [/\bnew\s+WebSocket\b/u, 'new WebSocket'],
  [/\bnew\s+EventSource\b/u, 'new EventSource'],
  [/\bnavigator\.sendBeacon\s*\(/u, 'navigator.sendBeacon('],
  [/\bimport\s*\(\s*['"`]https?:/u, 'dynamischer Import einer Adresse'],
]);

/** Der eine Baustein, der den Systembrowser anstoßen darf. */
const AUSSENWEG = 'src/main/sicherheit.ts';

interface Quelldatei {
  readonly pfad: string;
  readonly inhalt: string;
}

/** Die von Git geführten Quelldateien der Anwendung, oder `null` ohne Git. */
function quelldateien(): readonly Quelldatei[] | null {
  let liste: string[];
  try {
    liste = execFileSync('git', ['ls-files', 'app/src'], {
      cwd: wurzel,
      encoding: 'utf8',
      stdio: ['ignore', 'pipe', 'ignore'],
    })
      .split('\n')
      .map((zeile) => zeile.trim())
      .filter((zeile) => zeile.endsWith('.ts') || zeile.endsWith('.tsx'));
  } catch {
    return null;
  }
  return liste.map((pfad) => ({ pfad, inhalt: readFileSync(join(wurzel, pfad), 'utf8') }));
}

describe('Die Offline-Zusage', () => {
  const dateien = quelldateien();

  it('findet überhaupt Quelldateien', () => {
    /* Ohne diese Gegenprobe wären die Prüfungen unten immer grün und damit
       wertlos – dieselbe Überlegung wie in `reuse-abdeckung.test.ts`. */
    expect(
      dateien,
      'Ohne Git lässt sich der ausgelieferte Quelltext nicht bestimmen.',
    ).not.toBeNull();
    expect(dateien?.length ?? 0).toBeGreaterThan(150);
  });

  it('enthält keinen einzigen Netzaufruf', () => {
    const befunde: string[] = [];
    for (const { pfad, inhalt } of dateien ?? []) {
      for (const [muster, name] of NETZAUFRUFE) {
        if (muster.test(inhalt)) {
          befunde.push(`${pfad}: ${name}`);
        }
      }
    }

    expect(
      befunde,
      'Diese Anwendung sagt zu, offline zu arbeiten – in README, in „Über diese ' +
        'Software“, im Handbuch und in der Datenschutzerklärung. Ein neuer Weg ' +
        'nach außen bricht diese Zusage und gehört in alle vier Texte.',
    ).toEqual([]);
  });

  it('stößt den Systembrowser nur aus der einen dafür vorgesehenen Stelle an', () => {
    /* Nur `openExternal`: `shell.openPath` und `shell.showItemInFolder`
       öffnen eine **lokale** Datei im zuständigen Programm und führen nicht
       nach außen. Sie stehen in `main/dateizugriff.ts` und sind dort mit
       Grund. Gesucht wird der Aufruf, nicht die Erwähnung – der Name kommt
       auch in Kommentaren vor, die genau das erklären. */
    const anderswo = (dateien ?? [])
      .filter(
        ({ pfad, inhalt }) =>
          !pfad.endsWith(AUSSENWEG) && /\bshell\.openExternal\s*\(/u.test(inhalt),
      )
      .map(({ pfad }) => pfad);

    expect(
      anderswo,
      `Der Weg nach außen gehört in ${AUSSENWEG}: Dort wird die Adresse mit der ` +
        'einen eingetragenen verglichen, und dort steht die Begründung.',
    ).toEqual([]);
  });

  /*
    Die Zusage muss auch richtig formuliert sein.

    Bis Fassung 0.24.2 stand in „Über diese Software“ – ausgerechnet im
    Absatz, der um Unterstützung bittet – der Satz „sie sammelt keine Daten“.
    Das ist unrichtig: Die Anwendung legt einen Lernstand an, protokolliert
    jede Antwort mit Zeitpunkt, Dauer und Freitext, schreibt drei
    Selbstsicherungen und merkt sich die Fensterlage. Abschnitt 3 der
    Datenschutzerklärung sagt es selbst — „Beim Benutzen der Anwendung
    entstehen Daten“ — und nennt sie in Abschnitt 4 ausdrücklich
    personenbezogen im Sinne von Art. 4 Nr. 1 DSGVO.

    Richtig ist „überträgt“, und sechs andere Stellen sagten es schon so.
    Die eine falsche wog am schwersten: Wer um Geld bittet, wird beim Wort
    genommen.
  */
  it('verspricht nirgends, keine Daten zu sammeln – nur, keine zu übertragen', () => {
    const befunde: string[] = [];
    for (const { pfad, inhalt } of dateien ?? []) {
      for (const treffer of inhalt.matchAll(/sammelt\s+kein/gu)) {
        befunde.push(`${pfad}: ${treffer[0]}`);
      }
    }

    expect(
      befunde,
      'Die Anwendung sammelt sehr wohl Daten – sie überträgt sie nur nicht. ' +
        'Die Datenschutzerklärung sagt das in Abschnitt 3 ausdrücklich.',
    ).toEqual([]);
  });

  it('erkennt die falsche Zusage, wenn eine dasteht', () => {
    /* Gegenprobe: ohne sie bliebe unbemerkt, wenn das Muster nie mehr trifft. */
    expect(/sammelt\s+kein/u.test('sie sammelt keine Daten')).toBe(true);
    expect(/sammelt\s+kein/u.test('sie überträgt keine Daten')).toBe(false);
  });

  it('lädt keine Schrift und kein Stilblatt aus dem Netz', () => {
    /* Eine eingebundene Webschrift wäre eine Verbindung, die niemand als
       solche liest – und sie stünde in keinem der vier Texte. */
    const befunde = (dateien ?? [])
      .filter(({ inhalt }) => /@import\s+url\(\s*['"]?https?:/u.test(inhalt))
      .map(({ pfad }) => pfad);

    expect(befunde).toEqual([]);
  });
});
