// @vitest-environment node
/**
 * Die Dokumentation muss zur Fassung passen.
 *
 * Anlass ist ein wiederkehrendes Muster: Der Stand der Software stand an vier
 * Stellen – im Plan, in der Liesmich, im Prüfplan und in Gesprächen – und
 * lief auseinander. Der Plan führte das fertige Glossar als offen, die
 * Liesmich behauptete, es gebe keine Veröffentlichung, obwohl drei Etiketten
 * gesetzt waren.
 *
 * Geprüft wird deshalb genau der eine Fehler, der wirklich passiert: die
 * Nummer erhöhen und die Dokumentation vergessen. Ob der Inhalt stimmt, kann
 * kein Test wissen – dafür ist die Liste in `docs/veroeffentlichen.md` da.
 */

import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

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

/** Verzeichnis `app/`. */
const app = join(fileURLToPath(new URL('..', import.meta.url)));
/** Projektwurzel, eine Ebene darüber. */
const wurzel = join(app, '..');

function lesen(...teile: string[]): string {
  return readFileSync(join(...teile), 'utf8');
}

const fassung = (JSON.parse(lesen(app, 'package.json')) as { version: string }).version;

describe('Fassungsnummer', () => {
  it('ist eine gültige Nummer nach Semantic Versioning', () => {
    expect(fassung).toMatch(/^\d+\.\d+\.\d+$/u);
  });

  it('steht auch in der Sperrdatei', () => {
    /*
      Die Lücke, die genau einmal zwei Fassungen lang offen stand: Beim Sprung
      auf 0.22.0 blieb `package-lock.json` bei 0.21.0 stehen, und niemandem
      fiel es auf – die Wachen darunter sehen Changelog, stand.md und README
      an, nicht die Sperrdatei.

      Sie ist kein Nebenschauplatz: `npm ci` baut daraus, und ein
      Quelltextarchiv trägt sie mit. Eine Datei, die eine andere Fassung
      behauptet als die Anwendung, ist eine falsche Angabe – auch wenn sie
      nichts kaputt macht.

      Geprüft wird beides: der Wurzeleintrag und der Eintrag des eigenen
      Pakets. npm schreibt beide, wer von Hand ändert, vergisst leicht einen.
    */
    const sperre = JSON.parse(lesen(app, 'package-lock.json')) as {
      version: string;
      packages: Record<string, { version?: string }>;
    };

    expect(sperre.version, 'package-lock.json: Wurzeleintrag').toBe(fassung);
    expect(sperre.packages['']?.version, 'package-lock.json: eigener Paketeintrag').toBe(fassung);
  });
});

describe('CHANGELOG.md', () => {
  const pfad = join(wurzel, 'CHANGELOG.md');

  it('ist vorhanden', () => {
    expect(existsSync(pfad)).toBe(true);
  });

  it('führt die Fassung aus der package.json', () => {
    /*
      Der eigentliche Wächter. Wer die Nummer erhöht und den Verlauf
      vergisst, bekommt hier eine Meldung, die sagt, was zu tun ist – nicht
      erst ein Anwender, der wissen will, was sich geändert hat.
    */
    const text = lesen(pfad);
    expect(
      text.includes(`## [${fassung}]`),
      `CHANGELOG.md hat keinen Abschnitt "## [${fassung}]". ` +
        'Beim Anheben der Fassungsnummer den Abschnitt [Unveröffentlicht] ' +
        'umbenennen und einen neuen leeren darüber anlegen ' +
        '(siehe docs/veroeffentlichen.md).',
    ).toBe(true);
  });

  it('hält einen Abschnitt für Unveröffentlichtes offen', () => {
    /* Ohne ihn hat die nächste Änderung keinen Platz und landet unter der
       zuletzt veröffentlichten Nummer – dort ist sie falsch. */
    expect(lesen(pfad)).toContain('## [Unveröffentlicht]');
  });

  it('nennt zu jeder Fassung ein Datum', () => {
    const text = lesen(pfad);
    const ueberschriften = [...text.matchAll(/^## \[(?!Unveröffentlicht)([^\]]+)\](.*)$/gmu)];

    expect(ueberschriften.length).toBeGreaterThan(0);
    for (const [, nummer, rest] of ueberschriften) {
      expect(rest, `Der Fassung ${String(nummer)} fehlt das Datum.`).toMatch(
        /—\s*\d{4}-\d{2}-\d{2}/u,
      );
    }
  });
});

describe('docs/stand.md', () => {
  const pfad = join(wurzel, 'docs', 'stand.md');

  it('ist vorhanden', () => {
    expect(existsSync(pfad)).toBe(true);
  });

  it('gilt für die Fassung aus der package.json', () => {
    /*
      Ein Standdokument, das eine ältere Fassung nennt, ist schlimmer als
      keines: Es sagt mit der Autorität eines Dokuments etwas Falsches.
    */
    const text = lesen(pfad);
    expect(
      text.includes(`Fassung ${fassung}`),
      `docs/stand.md nennt nicht "Fassung ${fassung}". ` +
        'Beim Anheben der Fassungsnummer den Kopf des Dokuments nachführen ' +
        'und die Tabellen durchsehen (siehe docs/veroeffentlichen.md).',
    ).toBe(true);
  });

  it('verweist auf die übrigen Dokumente, statt sie zu wiederholen', () => {
    /* Der Umsetzungsstand soll an genau einer Stelle stehen. Die Wegweiser
       oben im Dokument sind das, was Leser dorthin führt. */
    const text = lesen(pfad);
    for (const ziel of ['PLAN.md', 'CHANGELOG.md', 'veroeffentlichen.md']) {
      expect(text, `docs/stand.md verweist nicht auf ${ziel}.`).toContain(ziel);
    }
  });
});

describe('README.md', () => {
  const pfad = join(wurzel, 'README.md');

  it('ist vorhanden', () => {
    expect(existsSync(pfad)).toBe(true);
  });

  it('nennt die Fassung aus der package.json', () => {
    /*
      Diese Wache fehlte, und sie hat gefehlt: Das README führte „Fassung
      0.9.0“, während die Anwendung bei 0.22.0 stand – dreizehn Nummern
      Rückstand, sichtbar in der ersten Bildschirmseite, die ein Besucher
      eines öffentlichen Archivs zu sehen bekäme. CHANGELOG und stand.md
      waren seit je bewacht, das Aushängeschild nicht.
    */
    const text = lesen(pfad);
    expect(
      text.includes(`Fassung ${fassung}`),
      `README.md nennt nicht "Fassung ${fassung}". ` +
        'Beim Anheben der Fassungsnummer den Standblock oben nachführen ' +
        '(siehe docs/veroeffentlichen.md).',
    ).toBe(true);
  });

  it('nennt einen Rückmeldeweg', () => {
    /* Wer eine Barriere findet, muss sie melden können, ohne die Anwendung
       erst zu installieren. Ein Archiv ohne Kontakt ist eine Sackgasse. */
    const text = lesen(pfad);
    expect(text).toContain('Olaf@olaf-willerding.de');
  });

  it('behauptet keine macOS-Fassung', () => {
    /* Es existiert kein macOS-Paket, und nichts davon lief je auf einem Mac
       (docs/stand.md, Abschnitt 5). docs/store-eintrag.md 9.1 führt „Auch für
       macOS“ deshalb unter „Nicht belegt – deshalb nicht behauptet“; für das
       README gilt derselbe Maßstab. Erlaubt bleibt, die Fassung als geplant
       oder als fehlend zu benennen – verboten ist die Behauptung, sie laufe. */
    const text = lesen(pfad);
    expect(text).not.toMatch(/läuft[^.]*\bmacOS\b/iu);
    expect(text).not.toMatch(/\bWindows und macOS\b/u);
  });
});

describe('docs/veroeffentlichen.md', () => {
  it('ist vorhanden und nennt die Schritte, die der Test nicht prüfen kann', () => {
    const text = lesen(wurzel, 'docs', 'veroeffentlichen.md');

    expect(text).toContain('CHANGELOG.md');
    expect(text).toContain('stand.md');
    // Ohne gebautes Paket sagt ein grüner E2E-Lauf nichts über das Paket.
    expect(text).toContain('dist:win');
  });
});

/*
  Der Updatebericht.

  Er ist **erzeugt** (`python tools/updatebericht.py`) und nicht von Hand
  gepflegt — der Mittelteil stammt Wort für Wort aus dem Changelog-Abschnitt
  der Fassung. Genau deshalb braucht er eine Wache: Ein erzeugtes Dokument,
  das niemand neu erzeugt, ist stiller falsch als ein handgeschriebenes, weil
  niemand mehr hinsieht.
*/
describe('docs/updatebericht.md', () => {
  const pfad = join(wurzel, 'docs', 'updatebericht.md');

  /**
   * Entfernt die Verweisziele, behält den sichtbaren Text.
   *
   * Der Bericht liegt in `docs/`, der Changelog im Wurzelverzeichnis; das
   * Werkzeug rechnet die relativen Ziele deshalb um. Verglichen wird der
   * Wortlaut, nicht der Pfad — sonst prüfte dieser Test die Umrechnung statt
   * der Aktualität.
   */
  function ohneVerweisziele(text: string): string {
    return text.replace(/\]\([^)]*\)/gu, ']()');
  }

  /** Der Changelog-Abschnitt einer Fassung, ohne Überschrift und Trennlinie. */
  function changelogAbschnitt(nummer: string): string {
    const text = lesen(wurzel, 'CHANGELOG.md');
    const kopf = new RegExp(`^## \\[${nummer.replace(/\./gu, '\\.')}\\]`, 'mu');
    const start = text.search(kopf);
    if (start === -1) {
      return '';
    }
    const rest = text.slice(start);
    const naechste = rest.slice(1).search(/^## \[/mu);
    const roh = naechste === -1 ? rest : rest.slice(0, naechste + 1);
    return roh
      .split('\n')
      .slice(1)
      .join('\n')
      .replace(/\n---\s*$/u, '')
      .trim();
  }

  it('ist vorhanden', () => {
    expect(existsSync(pfad), 'docs/updatebericht.md fehlt.').toBe(true);
  });

  it('gilt für die Fassung aus der package.json', () => {
    expect(
      lesen(pfad).includes(`Updatebericht zur Fassung ${fassung}`),
      `docs/updatebericht.md gilt nicht für Fassung ${fassung}. ` +
        'Neu erzeugen mit: python tools/updatebericht.py',
    ).toBe(true);
  });

  it('gibt den Changelog-Abschnitt der Fassung unverändert wieder', () => {
    /* Die eigentliche Zusage. Ohne sie stünde im Bericht irgendwann etwas
       anderes als im Änderungsverlauf — und niemand wüsste, welches von
       beiden gilt. */
    const bericht = lesen(pfad);
    const anfang = bericht.indexOf('## Was sich geändert hat');
    const ende = bericht.indexOf('## Wie Sie aktualisieren');

    expect(anfang, 'Der Bericht hat keinen Abschnitt „Was sich geändert hat".').toBeGreaterThan(-1);
    expect(ende, 'Der Bericht hat keinen Abschnitt „Wie Sie aktualisieren".').toBeGreaterThan(-1);

    const imBericht = bericht
      .slice(anfang + '## Was sich geändert hat'.length, ende)
      .replace(/\n---\s*$/u, '')
      .trim();

    expect(
      ohneVerweisziele(imBericht),
      'docs/updatebericht.md gibt den Changelog-Abschnitt nicht mehr wieder. ' +
        'Neu erzeugen mit: python tools/updatebericht.py',
    ).toBe(ohneVerweisziele(changelogAbschnitt(fassung)));
  });

  it('führt keinen toten Verweis', () => {
    /* Ein erzeugtes Dokument bekommt seine Verweise umgerechnet. Rechnet die
       Umrechnung falsch, merkt es beim Lesen niemand — beim Klicken schon. */
    const bericht = lesen(pfad);
    const tot = [...bericht.matchAll(/\]\(([^)#]+)\)/gu)]
      .map((treffer) => treffer[1] ?? '')
      .filter((ziel) => !/^(https?:|mailto:)/u.test(ziel))
      .filter((ziel) => !existsSync(join(wurzel, 'docs', ziel)));

    expect([...new Set(tot)]).toEqual([]);
  });

  it('nennt die stehenden Vorbehalte', () => {
    /* Sie sind der Grund, warum der Bericht mehr ist als eine Kopie des
       Changelogs: Wer die Datei in die Hand bekommt, muss ohne Nachfrage
       wissen, was die Fassung nicht leistet. */
    const text = lesen(pfad);
    for (const zusage of ['nicht signiert', 'macOS', 'Prüfungsausschuss', 'Ihr Lernstand bleibt']) {
      expect(text, `docs/updatebericht.md nennt „${zusage}" nicht.`).toContain(zusage);
    }
  });
});

/*
  Die öffentlichen Versionshinweise.

  Jede Fassung braucht einen Text, der veröffentlicht werden darf – auch eine
  Unterfassung, die nur einen Fehler behebt. Er steht im Changelog unter
  `### Für die Öffentlichkeit`, dem Gegenstück zu `### Für die Werkbank`, und
  `tools/store_notiz.py` macht daraus `docs/store-notiz.md`.

  Warum ein Test und nicht nur ein Haken auf einer Liste: Der Text entsteht am
  Ende einer Fassung, wenn alles andere fertig ist und niemand mehr Lust hat.
  Genau dort wird er vergessen. Ein Haken erinnert daran, ein roter Test hält
  an.

  Arbeitsteilung mit dem Werkzeug: Hier steht, was **strukturell** stimmen
  muss – der Block ist da, er steht auch in der erzeugten Datei, und er trägt
  nichts offensichtlich Internes. Die vollständige Liste der
  Öffentlichkeitsregeln führt `tools/store_notiz.py`; sie hier zu wiederholen
  hieße, zwei Listen auseinanderlaufen zu lassen. Wer eine Regel dort verletzt,
  bekommt kein Dokument mehr erzeugt – und fällt spätestens über die Prüfung
  auf, dass die erzeugte Datei nicht mehr zum Changelog passt.
*/
describe('docs/store-notiz.md', () => {
  const werkzeug = lesen(wurzel, 'tools', 'store_notiz.py');

  /**
   * Holt eine Festlegung aus dem Werkzeug, statt sie zu wiederholen.
   *
   * Die Feldgrenze und der Name des Abschnitts sind Zahlen und Zeichenketten,
   * die an genau einer Stelle stehen sollen. Stünden sie hier ein zweites Mal,
   * wäre die nächste Änderung eine halbe.
   */
  function ausWerkzeug(name: string, muster: RegExp): string {
    const treffer = muster.exec(werkzeug);
    expect(treffer?.[1], `tools/store_notiz.py: ${name} nicht gefunden.`).toBeDefined();
    return treffer?.[1] ?? '';
  }

  const ueberschrift = ausWerkzeug('OEFFENTLICH', /^OEFFENTLICH = '(.+)'$/mu);
  const feldgrenze = Number(ausWerkzeug('FELDGRENZE', /^FELDGRENZE = (\d+)$/mu));

  /** Eine Fassung des Changelogs mit ihrem öffentlichen Block. */
  interface Fassungsabschnitt {
    nummer: string;
    datum: string;
    /** `null`, wenn der Abschnitt fehlt. */
    block: string[] | null;
    /** Alle `###`-Überschriften der Fassung. */
    unterabschnitte: string[];
  }

  function changelogFassungen(): Fassungsabschnitt[] {
    const zeilen = lesen(wurzel, 'CHANGELOG.md').split('\n');
    const kopf = /^## \[([^\]]+)\](?:\s*—\s*(\S+))?\s*$/u;

    const koepfe: { i: number; nummer: string; datum: string }[] = [];
    zeilen.forEach((zeile, i) => {
      const treffer = kopf.exec(zeile);
      if (treffer) {
        koepfe.push({ i, nummer: treffer[1] ?? '', datum: treffer[2] ?? '' });
      }
    });

    // Hinter der ältesten Fassung steht ein Kommentar, der zu keiner gehört.
    let schluss = zeilen.length;
    for (let i = (koepfe.at(-1)?.i ?? 0) + 1; i < zeilen.length; i += 1) {
      if (zeilen[i]?.startsWith('<!--')) {
        schluss = i;
        break;
      }
    }

    return koepfe.map((eintrag, k) => {
      const ende = koepfe[k + 1]?.i ?? schluss;
      const inhalt = zeilen.slice(eintrag.i + 1, ende);
      return {
        nummer: eintrag.nummer,
        datum: eintrag.datum,
        unterabschnitte: inhalt.filter((z) => z.startsWith('### ')),
        block: blockLesen(inhalt),
      };
    });
  }

  function blockLesen(inhalt: string[]): string[] | null {
    const start = inhalt.findIndex((z) => z.trim() === ueberschrift);
    if (start === -1) {
      return null;
    }
    let ende = inhalt.length;
    for (let i = start + 1; i < inhalt.length; i += 1) {
      if (inhalt[i]?.startsWith('### ') || inhalt[i]?.startsWith('## ')) {
        ende = i;
        break;
      }
    }
    const block = inhalt.slice(start + 1, ende);
    while (block.length > 0 && !(block[0] ?? '').trim()) {
      block.shift();
    }
    while (block.length > 0 && !(block.at(-1) ?? '').trim()) {
      block.pop();
    }
    return block;
  }

  /** Der Block als reiner Text – so, wie ihn eine Eingabemaske zählt. */
  function reintext(block: string[]): string {
    const absaetze: string[] = [];
    let laufend = '';
    for (const zeile of block) {
      const blank = zeile.trim();
      if (!blank) {
        continue;
      }
      if (blank.startsWith('- ')) {
        if (laufend) {
          absaetze.push(laufend);
        }
        laufend = `- ${blank.slice(2)}`;
      } else {
        laufend = laufend ? `${laufend} ${blank}` : blank;
      }
    }
    if (laufend) {
      absaetze.push(laufend);
    }
    return absaetze
      .join('\n')
      .replace(/\*\*([^*]+)\*\*/gu, '$1')
      .replace(/(?<![*\w])\*([^*]+)\*(?![*\w])/gu, '$1')
      .normalize('NFC');
  }

  const fassungen = changelogFassungen();

  it('findet überhaupt Fassungen im Changelog', () => {
    /* Wäre der Kopf-Ausdruck falsch, liefen alle folgenden Prüfungen über eine
       leere Liste und wären grün, ohne etwas geprüft zu haben. */
    expect(fassungen.length, 'CHANGELOG.md: keine Fassungsüberschrift erkannt.').toBeGreaterThan(
      10,
    );
    expect(ueberschrift).toBe('### Für die Öffentlichkeit');
    expect(feldgrenze).toBeGreaterThan(0);
  });

  it('gibt jeder Fassung einen öffentlichen Text', () => {
    /* Die eigentliche Zusage: Es gibt keine Fassung ohne. Ausgenommen ist ein
       leerer Abschnitt `[Unveröffentlicht]` – solange dort nichts steht, gibt
       es auch nichts zu veröffentlichen. Sobald eine Rubrik auftaucht, gilt
       die Pflicht. */
    const ohne = fassungen
      .filter((f) => f.block === null)
      .filter((f) => !(f.nummer === 'Unveröffentlicht' && f.unterabschnitte.length === 0))
      .map((f) => f.nummer);

    expect(
      ohne,
      `Diesen Fassungen fehlt „${ueberschrift}" in CHANGELOG.md. ` +
        'Ohne diesen Abschnitt gibt es keinen Text, der veröffentlicht werden darf.',
    ).toEqual([]);
  });

  it('hält jeden öffentlichen Text im Rahmen des Feldes', () => {
    /* Der Text geht in ein Feld mit fester Grenze. Zu lang heißt: abgeschnitten
       – und abgeschnitten heißt, dass der letzte Punkt mitten im Satz endet. */
    const zuLang = fassungen
      .filter((f) => f.block !== null)
      .map((f) => ({ nummer: f.nummer, zeichen: reintext(f.block ?? []).length }))
      .filter((f) => f.zeichen > feldgrenze);

    expect(zuLang, `Erlaubt sind ${String(feldgrenze)} Zeichen.`).toEqual([]);
  });

  it('lässt nichts Internes in einen öffentlichen Text', () => {
    /* Nur die auffälligsten Muster – die vollständige Liste führt
       `tools/store_notiz.py`. Hier stehen die drei, die beim schnellen
       Schreiben wirklich passieren: ein Dateiname, ein Verweis auf ein anderes
       Dokument, eine Auszeichnung als Quelltext. */
    const auffaellig: { muster: RegExp; was: string }[] = [
      { muster: /`/u, was: 'Auszeichnung als Quelltext' },
      { muster: /\]\(/u, was: 'Verweis auf ein Dokument' },
      {
        muster: /\b[\w-]+\.(?:json|ts|tsx|js|mjs|md|py|yml|html|exe|db|png)\b/u,
        was: 'Dateiname',
      },
    ];

    const treffer: string[] = [];
    for (const f of fassungen) {
      if (f.block === null) {
        continue;
      }
      const text = f.block.join('\n');
      for (const { muster, was } of auffaellig) {
        const gefunden = muster.exec(text);
        if (gefunden) {
          treffer.push(`${f.nummer}: ${was} – „${gefunden[0]}"`);
        }
      }
    }

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

  it('ist vorhanden und gibt jeden Block unverändert wieder', () => {
    /* Ohne diese Prüfung stünde im veröffentlichten Dokument irgendwann etwas
       anderes als im Changelog – und niemand wüsste, welches von beiden gilt.
       Sie fängt zugleich den Fall ab, dass jemand eine Öffentlichkeitsregel
       verletzt hat: Das Werkzeug erzeugt dann nichts mehr, und die Datei bleibt
       zurück. */
    const pfad = join(wurzel, 'docs', 'store-notiz.md');
    expect(existsSync(pfad), 'docs/store-notiz.md fehlt.').toBe(true);

    const notiz = lesen(pfad);
    const fehlend: string[] = [];
    for (const f of fassungen) {
      if (f.block === null || f.nummer === 'Unveröffentlicht') {
        /* Was noch keine Fassung hat, wird nicht veröffentlicht – der Text
           dafür muss trotzdem geschrieben sein, und die Prüfungen oben sehen
           ihn sich an. */
        continue;
      }
      const kopf = f.datum ? `### ${f.nummer} — ${f.datum}` : `### ${f.nummer}`;
      if (!notiz.includes(`${kopf}\n\n${f.block.join('\n')}`)) {
        fehlend.push(f.nummer);
      }
    }

    expect(
      fehlend,
      'docs/store-notiz.md gibt diese Fassungen nicht mehr so wieder wie CHANGELOG.md. ' +
        'Neu erzeugen mit: python tools/store_notiz.py',
    ).toEqual([]);
  });

  it('hält den Text der laufenden Fassung zum Einfügen bereit', () => {
    /* Wer eine Einreichung macht, soll den Text kopieren können, statt ihn aus
       dem Changelog zusammenzusuchen – samt der Zahl, die die Eingabemaske
       gleich selbst zählen wird. */
    const notiz = lesen(wurzel, 'docs', 'store-notiz.md');
    const aktuell = fassungen.find((f) => f.nummer === fassung);

    expect(
      aktuell?.block,
      `CHANGELOG.md hat keinen öffentlichen Text für ${fassung}.`,
    ).toBeTruthy();
    expect(notiz).toContain(`## Zum Einfügen: Fassung ${fassung}`);
    expect(notiz).toContain(reintext(aktuell?.block ?? []));
  });
});
