// @vitest-environment node
// Reiner Dateisystem-Test – jsdom wird hier nicht gebraucht und würde
// `import.meta.url` auf eine http-URL setzen.

/**
 * Der Altersvergleich für das gepackte Paket.
 *
 * Er entscheidet, ob die Prüfungen aus `e2e/gepackt.spec.ts` laufen.
 * Sagt er fälschlich „aktuell“, prüfen sie die vorige Fassung und melden
 * grün – genau das ist in diesem Projekt zweimal geschehen.
 *
 * Ein drittes Mal wäre um ein Haar dazugekommen: Der Vergleich sah nur
 * `app/src` an, die ausgelieferten **Inhalte** liegen aber unter `content/`
 * und kommen über `extraResources` ins Paket. Eine geänderte Erklärung ließ
 * das Paket „so jung wie der Quelltext“ aussehen. Die Fälle unten halten
 * beide Hälften fest.
 */

import { mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

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

import {
  ausgelieferteQuellen,
  bauressourcenAusBauplan,
  musterAusBauplan,
  paketstand,
} from '../tools/paketstand.mjs';

/** Das echte `app/`-Verzeichnis dieses Projekts. */
const echteAppWurzel = fileURLToPath(new URL('..', import.meta.url));

/*
  Ein Prüfbaum, der dem echten in der Form gleicht: `app/` neben `content/`,
  weil `extraResources` mit `../content/...` genau über diese Grenze greift.
  Läge `content/` unterhalb von `app/`, prüfte der Test eine Anordnung, die
  es im Projekt nicht gibt.
*/
let wurzel: string;
let appWurzel: string;

/** Bauplan in der Form der echten Datei – der Ausschnitt, der etwas liefert. */
const BAUPLAN = `appId: de.willerding.waffensachkunde

directories:
  output: release
  buildResources: build-resources

# Nur die gebauten Bundles paketieren.
files:
  - out/**/*
  - package.json
  - '!**/*.map'
  - '!**/{test,tests,__tests__,e2e}/**'

extraResources:
  - from: ../content/katalog
    to: katalog
    filter:
      - '**/*'
  - from: ../content/erklaerungen.json
    to: erklaerungen.json

asar: true
`;

/** Setzt das Änderungsdatum einer Datei auf jetzt plus Versatz. */
function datieren(pfad: string, sekundenVersatz: number): void {
  const zeit = Date.now() / 1000 + sekundenVersatz;
  utimesSync(pfad, zeit, zeit);
}

/** Schreibt eine Datei samt Elternverzeichnissen und datiert sie. */
function schreiben(pfad: string, inhalt: string, sekundenVersatz: number): void {
  mkdirSync(join(pfad, '..'), { recursive: true });
  writeFileSync(pfad, inhalt, 'utf8');
  datieren(pfad, sekundenVersatz);
}

/** Legt eine Quelldatei unter `app/src/` an. */
function quelle(sekundenVersatz: number): void {
  schreiben(join(appWurzel, 'src', 'datei.ts'), 'const a = 1;\n', sekundenVersatz);
}

/** Legt einen ausgelieferten Inhalt unter `content/` an. */
function inhalt(sekundenVersatz: number): void {
  schreiben(join(wurzel, 'content', 'katalog', 'katalog.json'), '{}\n', sekundenVersatz);
  schreiben(join(wurzel, 'content', 'erklaerungen.json'), '{}\n', sekundenVersatz);
}

/** Legt ein gepacktes Paket an und setzt sein Änderungsdatum. */
function paket(sekundenVersatz: number): void {
  const verzeichnis = join(appWurzel, 'release', 'win-unpacked');
  mkdirSync(verzeichnis, { recursive: true });
  const pfad = join(verzeichnis, 'Waffensachkunde Lernsoftware.exe');
  writeFileSync(pfad, 'MZ', 'utf8');
  datieren(pfad, sekundenVersatz);
}

beforeEach(() => {
  wurzel = mkdtempSync(join(tmpdir(), 'wsk-paketstand-'));
  appWurzel = join(wurzel, 'app');
  mkdirSync(join(appWurzel, 'src'), { recursive: true });
  // Alles, was der Bauplan nennt, muss dastehen – sonst wirft die Auflösung.
  schreiben(join(appWurzel, 'electron-builder.yml'), BAUPLAN, -7200);
  schreiben(join(appWurzel, 'package.json'), '{"version":"0.0.0"}\n', -7200);
  // Das Verzeichnis mit dem Programmsymbol. Es zählt zum Programm, weil das
  // Symbol in die gebaute exe eingebaut wird.
  schreiben(join(appWurzel, 'build-resources', 'icon.ico'), 'ICO', -7200);
  quelle(-7200);
  inhalt(-7200);
});

afterEach(() => {
  rmSync(wurzel, { recursive: true, force: true });
});

describe('paketstand', () => {
  it('meldet ein fehlendes Paket als nicht vorhanden', () => {
    const stand = paketstand(appWurzel);

    expect(stand.vorhanden).toBe(false);
    expect(stand.veraltet).toBe(false);
    expect(stand.grund).toContain('dist:win');
  });

  it('meldet ein Paket als veraltet, das älter ist als der Quelltext', () => {
    /* Der Fall, der zweimal einen Fehler verdeckt hat: Das Paket liegt da,
       ist aber von vor der Änderung. */
    paket(-3600);
    quelle(0);

    const stand = paketstand(appWurzel);

    expect(stand.vorhanden).toBe(true);
    expect(stand.veraltet).toBe(true);
    expect(stand.alterSekunden).toBeGreaterThan(3000);
    expect(stand.grund).toContain('älter als das Programm');
  });

  it('meldet ein Paket als veraltet, dessen Inhalte weitergewandert sind', () => {
    /*
      Die Lücke, um die es hier geht. Der Quelltext bleibt alt, geändert wird
      allein `content/` – also das, was über `extraResources` ins Paket geht.
      Die vorige Fassung sah nur `app/src` an und hätte hier „so jung wie der
      Quelltext“ gemeldet; die Prüfungen aus `e2e/gepackt.spec.ts` hätten
      die vorige Fassung des Katalogs gemessen und grün gemeldet.
    */
    paket(-3600);
    inhalt(0);

    const stand = paketstand(appWurzel);

    expect(stand.veraltet).toBe(true);
    expect(stand.ursache).toBe('inhalte');
    expect(stand.grund).toContain('älter als die mitgelieferten Inhalte');
  });

  it('benennt die Datei, die den Ausschlag gibt', () => {
    /* „Irgendetwas ist neuer“ zwingt zum Suchen. Der Name der Datei macht aus
       der Meldung eine nachprüfbare Aussage – und sagt zugleich, ob neu zu
       bauen oder nur neu zu packen ist. */
    paket(-3600);
    schreiben(join(wurzel, 'content', 'erklaerungen.json'), '{"a":1}\n', 0);

    const stand = paketstand(appWurzel);

    expect(stand.juengsteQuelle).toBe('content/erklaerungen.json');
    expect(stand.grund).toContain('content/erklaerungen.json');
  });

  it('nennt beides, wenn Quelltext und Inhalte weitergewandert sind', () => {
    paket(-3600);
    quelle(-60);
    inhalt(0);

    const stand = paketstand(appWurzel);

    expect(stand.ursache).toBe('beides');
    expect(stand.grund).toContain('Programm und mitgelieferte Inhalte');
  });

  it('lässt ein Paket gelten, das jünger ist als Quelltext und Inhalte', () => {
    quelle(-3600);
    inhalt(-3600);
    paket(0);

    const stand = paketstand(appWurzel);

    expect(stand.veraltet).toBe(false);
    expect(stand.alterSekunden).toBe(0);
    expect(stand.ursache).toBeNull();
    expect(stand.grund).toContain('Inhalte');
  });

  it('verzeiht eine Sekunde Unterschied', () => {
    /* Ein Paketbau schreibt seine Ausgaben nicht in derselben Millisekunde,
       in der er die Quellen liest. Ohne Toleranz schlüge die Prüfung
       unmittelbar nach einem erfolgreichen Bau an. */
    paket(-1);
    quelle(0);
    inhalt(0);

    expect(paketstand(appWurzel).veraltet).toBe(false);
  });

  it('sieht auch in Unterverzeichnisse des Quelltextes', () => {
    /* Die meisten Änderungen liegen nicht direkt unter src/, sondern tief
       darunter. Ein Vergleich nur der obersten Ebene ginge fast immer gut
       aus – und wäre damit wertlos. */
    paket(-3600);
    schreiben(join(appWurzel, 'src', 'renderer', 'components', 'Tief.tsx'), 'export {};\n', 0);

    expect(paketstand(appWurzel).veraltet).toBe(true);
  });

  it('sieht auch in Unterverzeichnisse der Inhalte', () => {
    /* Dasselbe Argument für die andere Hälfte: Die Prüfzeichen liegen unter
       `content/katalog/assets/`, nicht daneben. */
    paket(-3600);
    schreiben(join(wurzel, 'content', 'katalog', 'assets', 'zeichen.png'), 'PNG', 0);

    expect(paketstand(appWurzel).veraltet).toBe(true);
  });

  it('lässt `out/` selbst außer Betracht', () => {
    /*
      `files:` nennt `out/**\/*`, das ist aber das Erzeugnis von
      `electron-vite build` aus `src/`. `npm run gate` baut es in jedem Lauf
      neu. Zählte sein Alter mit, sähe das Paket unmittelbar nach jedem Gate
      veraltet aus – und die Prüfungen übersprängen sich für immer.
    */
    paket(-3600);
    schreiben(join(appWurzel, 'out', 'main', 'index.js'), 'console.log(1);\n', 0);

    expect(paketstand(appWurzel).veraltet).toBe(false);
  });

  it('wirft, wenn der Bauplan auf etwas verweist, das es nicht gibt', () => {
    /* Der stille Fehlschlag wäre der schlimmere: Ein Bauplan, den der
       Vergleich nicht auflösen kann, hieße wieder „geprüft“ ohne Prüfung. */
    paket(0);
    schreiben(
      join(appWurzel, 'electron-builder.yml'),
      `${BAUPLAN}\nextraResources:\n  - from: ../content/gibtesnicht.json\n    to: x.json\n`,
      -7200,
    );

    expect(() => paketstand(appWurzel)).toThrow(/gibtesnicht\.json/u);
  });

  it('wirft ohne Bauplan', () => {
    paket(0);
    rmSync(join(appWurzel, 'electron-builder.yml'));

    expect(() => paketstand(appWurzel)).toThrow(/Bauplan/u);
  });
});

describe('bauressourcenAusBauplan', () => {
  /*
    Das Verzeichnis mit dem Programmsymbol steht in keiner der beiden Listen
    des Bauplans, sondern unter `directories`. Es zählt trotzdem: Das Symbol
    wird in die exe eingebaut. Wer es ändert, ohne neu zu bauen, trägt das
    alte Bild weiter – und ohne diese Auflösung meldete der Vergleich dazu
    „so jung wie der Quelltext“.
  */
  it('findet das Verzeichnis unter directories', () => {
    expect(bauressourcenAusBauplan(BAUPLAN)).toBe('build-resources');
  });

  it('meldet null, wenn keines eingetragen ist', () => {
    expect(bauressourcenAusBauplan('appId: x\nfiles:\n  - out/**/*\n')).toBeNull();
  });

  it('greift nicht auf einen gleichnamigen Schlüssel außerhalb des Blocks', () => {
    /* Ein blankes Suchen nach „buildResources“ fände auch einen Eintrag, der
       woanders steht – und bewachte dann ein Verzeichnis, aus dem gar nichts
       gebaut wird. */
    const fremd = 'appx:\n  buildResources: irgendwo\ndirectories:\n  output: release\n';
    expect(bauressourcenAusBauplan(fremd)).toBeNull();
  });

  it('überliest Kommentare', () => {
    const mitKommentar =
      'directories:\n  # buildResources: alt-und-falsch\n  buildResources: build-resources\n';
    expect(bauressourcenAusBauplan(mitKommentar)).toBe('build-resources');
  });
});

describe('musterAusBauplan', () => {
  it('trennt ausgeliefertes Programm von ausgelieferten Inhalten', () => {
    const muster = musterAusBauplan(BAUPLAN);

    expect(muster.programm).toEqual(['out/**/*', 'package.json']);
    expect(muster.inhalte).toEqual(['../content/katalog', '../content/erklaerungen.json']);
  });

  it('übergeht Ausschlussmuster', () => {
    // Ein Muster mit führendem Ausrufezeichen liefert nichts aus; sein Alter
    // zu messen wäre sinnlos, und der Pfad ließe sich gar nicht auflösen.
    expect(musterAusBauplan(BAUPLAN).programm).not.toContain('!**/*.map');
  });

  it('wirft bei einer Schreibweise, die es nicht versteht', () => {
    /*
      Der Kern dieser Wache: Käme im Bauplan eine Form dazu, die der
      Ausschnitt still überliest, prüfte der Altersvergleich wieder weniger,
      als er behauptet – und niemand merkte es. Lieber laut scheitern.
    */
    const fremd = 'extraResources:\n  ? seltsam\n';

    expect(() => musterAusBauplan(fremd)).toThrow(/unverstandene Zeile/u);
  });

  it('liest den echten Bauplan dieses Projekts vollständig', () => {
    /*
      Die eigentliche Absicherung gegen Auseinanderlaufen: nicht ein Abbild
      des Bauplans, sondern der Bauplan selbst. Kommt eine Ressource dazu und
      versteht der Ausschnitt sie nicht, fällt dieser Fall um – nicht erst der
      nächste Paketbau.

      Die erwarteten Werte stehen hier ausgeschrieben, damit ein stilles
      Verschwinden auffällt: Ein Vergleich gegen „irgendetwas Nichtleeres“
      wäre auch dann grün, wenn der Katalog aus dem Paket fiele.
    */
    const muster = musterAusBauplan(
      readFileSync(join(echteAppWurzel, 'electron-builder.yml'), 'utf8'),
    );

    expect(muster.programm).toEqual(['out/**/*', 'package.json']);
    expect(muster.inhalte).toEqual([
      '../content/katalog',
      '../content/erklaerungen.json',
      '../content/glossar.json',
      '../content/normtexte.json',
      '../content/themen.json',
      '../LICENSE.de.txt',
      '../content/drittlizenzen.json',
      /* Seit 0.25.0: Die Datenschutzerklärung liegt in der Anwendung. Wer
         sie ändert, entwertet damit den Beleg der Paketprüfungen – genau
         das soll diese Liste sicherstellen. */
      '../content/datenschutz.json',
    ]);
  });
});

describe('ausgelieferteQuellen gegen den echten Projektbaum', () => {
  it('löst jeden Pfad des echten Bauplans auf einen vorhandenen Ort auf', () => {
    /*
      Ohne diesen Fall bliebe die Herleitung Theorie. Er darf nicht werfen –
      ein Wurf hieße, dass Bauplan und Auflösung auseinanderliegen.

      Bewusst über `ausgelieferteQuellen()` und nicht über `paketstand()`:
      Letzteres kehrt ohne gebautes Paket sofort um und käme an der Auflösung
      vorbei. Der Test prüfte dann an jedem Arbeitsplatz ohne `release/`
      nichts und meldete trotzdem grün – dieselbe Unwahrheit, gegen die diese
      ganze Datei angelegt ist.
    */
    const quellen = ausgelieferteQuellen(echteAppWurzel);

    // `out/**/*` zählt als `src/`: das Erzeugnis wird über seine Quelle datiert.
    expect(quellen.quelltext).toEqual([
      resolve(echteAppWurzel, 'src'),
      resolve(echteAppWurzel, 'package.json'),
      // Der Bauplan selbst: Er bestimmt nicht nur, was ausgeliefert wird,
      // sondern auch wie – Ziele, Dateilisten, ASAR-Entpackung, die Angaben
      // des Store-Pakets. Bis 0.24.1 sah die Wache jeden Pfad an, den er
      // nennt, nur nicht ihn.
      resolve(echteAppWurzel, 'electron-builder.yml'),
      // Das Programmsymbol steckt in der gebauten exe, steht aber in keiner
      // der beiden Listen des Bauplans - es kommt aus directories.buildResources.
      resolve(echteAppWurzel, 'build-resources'),
    ]);
    // Der Inhalt, den `e2e/gepackt.spec.ts` im Paket misst.
    expect(quellen.inhalte).toEqual([
      resolve(echteAppWurzel, '..', 'content', 'katalog'),
      resolve(echteAppWurzel, '..', 'content', 'erklaerungen.json'),
      resolve(echteAppWurzel, '..', 'content', 'glossar.json'),
      resolve(echteAppWurzel, '..', 'content', 'normtexte.json'),
      resolve(echteAppWurzel, '..', 'content', 'themen.json'),
      resolve(echteAppWurzel, '..', 'LICENSE.de.txt'),
      resolve(echteAppWurzel, '..', 'content', 'drittlizenzen.json'),
      resolve(echteAppWurzel, '..', 'content', 'datenschutz.json'),
    ]);
  });
});
