// @vitest-environment node
/**
 * Die Bausteine gedruckter Dokumente.
 *
 * Geprüft wird hier alles, was ohne Electron prüfbar ist: Maskierung,
 * Struktur, Quellenangabe, Farbkontrast. Ob Chromium daraus ein brauchbares
 * PDF macht, misst `e2e/druck.spec.ts` am erzeugten Artefakt – behauptet wird
 * es an keiner Stelle.
 */

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

import {
  abschnitt,
  absatz,
  dokumentBauen,
  fusszeilenVorlage,
  kurzquelle,
  maskiert,
  quellensatz,
  tabelle,
  type Quellenangabe,
} from '../src/shared/druck/dokument';
import { dateiname, lernberichtBauen } from '../src/shared/druck/lernbericht';
import { DRUCK_FARBEN, druckStil } from '../src/shared/druck/stil';
import type { Lernplan } from '../src/shared/lernplan';
import type { Lernuebersicht } from '../src/shared/lernstand';
import type { Pruefungsverlauf } from '../src/shared/pruefung';

const QUELLE: Quellenangabe = {
  amtlich: 'Amtlicher Fragenkatalog für die Waffensachkundeprüfung, Stand 16.12.2024.',
  herausgeber: 'Bundesverwaltungsamt',
  stand: '2024-12-16',
  quelleUrl: 'https://www.bva.bund.de/',
  umfang: { art: 'kein amtlicher wortlaut' },
};

describe('maskiert', () => {
  it('entschärft alle fünf gefährlichen Zeichen', () => {
    expect(maskiert(`<a href="x" class='y'>Müller & Co</a>`)).toBe(
      '&lt;a href=&quot;x&quot; class=&#39;y&#39;&gt;Müller &amp; Co&lt;/a&gt;',
    );
  });

  it('maskiert das kaufmännische Und zuerst', () => {
    /* Andernfalls würde aus `&lt;` am Ende `&amp;lt;` – die häufigste Falle
       bei handgeschriebener Maskierung. */
    expect(maskiert('<')).toBe('&lt;');
    expect(maskiert('&lt;')).toBe('&amp;lt;');
  });
});

describe('dokumentBauen', () => {
  const dokument = dokumentBauen({
    titel: 'Testbericht',
    augenbraue: 'Profil "Max & Moritz"',
    quelle: QUELLE,
    schriftgroesse: 'normal',
    abschnitte: [abschnitt('Erster Teil', [absatz('Ein Satz.')])],
  });

  it('nennt die Sprache am Wurzelelement', () => {
    expect(dokument).toContain('<html lang="de">');
  });

  it('hat genau eine Hauptüberschrift', () => {
    expect(dokument.match(/<h1[ >]/gu) ?? []).toHaveLength(1);
  });

  it('führt keine Sprünge in der Überschriftenfolge', () => {
    const ebenen = [...dokument.matchAll(/<h([1-6])[ >]/gu)].map((t) => Number(t[1]));

    expect(ebenen[0]).toBe(1);
    let vorige = 1;
    for (const ebene of ebenen) {
      expect(ebene).toBeLessThanOrEqual(vorige + 1);
      vorige = ebene;
    }
  });

  it('maskiert auch die Kopfzeilen', () => {
    expect(dokument).toContain('Profil &quot;Max &amp; Moritz&quot;');
    expect(dokument).not.toContain('Profil "Max & Moritz"');
  });

  it('enthält kein Skript und keine ladbare Adresse', () => {
    expect(dokument).not.toMatch(/<script/iu);
    expect(dokument).not.toMatch(/(?:src|href)\s*=\s*["']https?:/iu);
  });

  it('bringt eine eigene, strenge Inhaltsrichtlinie mit', () => {
    expect(dokument).toContain("default-src 'none'");
    expect(dokument).toContain("style-src 'unsafe-inline'");
  });

  describe('Quellenangabe', () => {
    it('steht im Dokument, nicht nur in den Metadaten', () => {
      expect(dokument).toContain('Bundesverwaltungsamt');
      expect(dokument).toContain('Stand: 2024-12-16');
      expect(dokument).toContain('https://www.bva.bund.de/');
    });

    it('nennt auch die eigene Lizenz der redaktionellen Teile', () => {
      expect(dokument).toContain('EUPL-1.2');
      expect(dokument).toContain('Olaf Willerding');
    });

    it('sagt, ob amtlicher Wortlaut enthalten ist', () => {
      expect(dokument).toContain('enthält keinen Wortlaut des amtlichen Fragenkatalogs');
    });

    /*
      Bis Fassung 0.18.0 stand hier unbedingt „Dieses Dokument gibt nur einen
      Teil des amtlichen Fragenkatalogs wieder“. Für eine Fragenliste über den
      ganzen Katalog war das nachweislich falsch – 575 von 575 Fragen sind
      kein Teil. Der Satz wird deshalb gezählt, nicht behauptet, und diese
      Prüfungen halten jede Zählung fest.
    */
    it('nennt bei einer Vollwiedergabe „alle“ und nicht „einen Teil“', () => {
      expect(
        quellensatz({
          art: 'wiedergabe',
          fragen: 575,
          fragenGesamt: 575,
          loesungen: 'keine',
          optionen: 'alle',
        }),
      ).toContain('alle 575 Fragen');
      expect(
        quellensatz({
          art: 'wiedergabe',
          fragen: 575,
          fragenGesamt: 575,
          loesungen: 'keine',
          optionen: 'alle',
        }),
      ).not.toContain('der 575 Fragen');
    });

    it('zählt bei einer Teilwiedergabe die Fragen', () => {
      expect(
        quellensatz({
          art: 'wiedergabe',
          fragen: 42,
          fragenGesamt: 575,
          loesungen: 'alle',
          optionen: 'alle',
        }),
      ).toContain('42 der 575 Fragen');
    });

    it('kennt den Singular – ein einziger Fehler ist erreichbar', () => {
      const satz = quellensatz({
        art: 'wiedergabe',
        fragen: 1,
        fragenGesamt: 575,
        loesungen: 'alle',
        optionen: 'alle',
      });

      expect(satz).toContain('1 der 575 Fragen');
      expect(satz).not.toContain('1 Fragen des');
    });

    it('sagt ausdrücklich, wenn die Lösungskennzeichnung fehlt', () => {
      expect(
        quellensatz({
          art: 'wiedergabe',
          fragen: 90,
          fragenGesamt: 575,
          loesungen: 'keine',
          optionen: 'alle',
        }),
      ).toContain('die amtliche Kennzeichnung fehlt hier vollständig');
    });

    it('sagt ausdrücklich, wenn Antwortmöglichkeiten weggelassen wurden', () => {
      expect(
        quellensatz({
          art: 'wiedergabe',
          fragen: 90,
          fragenGesamt: 575,
          loesungen: 'alle',
          optionen: 'nur die richtigen',
        }),
      ).toContain('die übrigen fehlen');
    });

    /*
      Für einen blinden Leser ersetzt der Alternativtext das amtliche Zeichen.
      Er ist eigener Inhalt dieser Software – wer ihn hört, muss das erfahren.
    */
    it('weist die Bildbeschreibungen als eigenen Inhalt aus', () => {
      expect(
        quellensatz({
          art: 'wiedergabe',
          fragen: 5,
          fragenGesamt: 575,
          loesungen: 'alle',
          optionen: 'alle',
        }),
      ).toContain('stammen nicht aus dem amtlichen Fragenkatalog');
    });

    it('lässt sich nicht weglassen', () => {
      /* Es gibt keinen Schalter dafür – geprüft wird der einzige Weg, auf dem
         sie fehlen könnte: eine leere Angabe im Katalog. */
      expect(() =>
        dokumentBauen({
          titel: 'T',
          augenbraue: 'A',
          quelle: { ...QUELLE, amtlich: '   ' },
          schriftgroesse: 'normal',
          abschnitte: [],
        }),
      ).toThrow(/Quellenangabe/u);
    });

    it('steht als Kurzform auch in der Fußzeile jeder Seite', () => {
      const fuss = fusszeilenVorlage(QUELLE);

      expect(fuss).toContain('Bundesverwaltungsamt');
      expect(fuss).toContain('2024-12-16');
      expect(fuss).toContain('class="pageNumber"');
      expect(fuss).toContain('class="totalPages"');
    });

    it('maskiert auch die Kurzform', () => {
      const fuss = fusszeilenVorlage({ ...QUELLE, herausgeber: 'A & B <GmbH>' });

      expect(fuss).toContain('A &amp; B &lt;GmbH&gt;');
      expect(kurzquelle({ ...QUELLE, herausgeber: 'A & B' })).toContain('A & B');
    });
  });
});

describe('tabelle', () => {
  const html = tabelle(
    'Werte',
    ['Bereich', 'Anzahl'],
    [[{ text: 'Kapitel I' }, { text: '42', zahl: true }]],
  );

  it('trägt eine Beschriftung', () => {
    expect(html).toContain('<caption>Werte</caption>');
  });

  it('weist jeder Kopfzelle ihre Richtung zu', () => {
    /* Ohne `scope` weiß ein Screenreader beim Vorlesen einer Zelle nicht,
       wozu sie gehört – und im getaggten PDF fehlt dieselbe Zuordnung. */
    expect(html).toContain('<th scope="col">Bereich</th>');
    expect(html).toContain('<th scope="row">Kapitel I</th>');
  });

  it('maskiert den Zellinhalt', () => {
    const boese = tabelle('T', ['A'], [[{ text: '<script>alert(1)</script>' }]]);

    expect(boese).not.toContain('<script>');
    expect(boese).toContain('&lt;script&gt;');
  });
});

describe('Farben des Ausdrucks', () => {
  /** Relative Leuchtdichte nach WCAG 2.x. */
  function leuchtdichte(hex: string): number {
    const kanal = (n: number): number => {
      const s = n / 255;
      return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
    };
    const r = Number.parseInt(hex.slice(1, 3), 16);
    const g = Number.parseInt(hex.slice(3, 5), 16);
    const b = Number.parseInt(hex.slice(5, 7), 16);
    return 0.2126 * kanal(r) + 0.7152 * kanal(g) + 0.0722 * kanal(b);
  }

  function kontrast(vorne: string, hinten: string): number {
    const a = leuchtdichte(vorne);
    const b = leuchtdichte(hinten);
    return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
  }

  /*
    Der Kommentar in stil.ts nennt Zahlen. Hier werden sie nachgerechnet,
    damit sie Angaben bleiben und nicht zu Behauptungen verkommen. Papier ist
    weiß – Hintergrundfarben druckt kaum ein Gerät.
  */
  it.each([
    ['text', DRUCK_FARBEN.text, 7],
    ['leise', DRUCK_FARBEN.leise, 7],
    ['erfolg', DRUCK_FARBEN.erfolg, 7],
    ['fehler', DRUCK_FARBEN.fehler, 7],
    ['akzent', DRUCK_FARBEN.akzent, 7],
  ])('hält %s über %s zu 1 gegen weißes Papier', (_name, farbe, mindestens) => {
    expect(kontrast(farbe, '#ffffff')).toBeGreaterThanOrEqual(mindestens);
  });

  it('hält den Text auch auf der hellen Tabellenkopffläche über 7 zu 1', () => {
    expect(kontrast(DRUCK_FARBEN.text, DRUCK_FARBEN.flaecheLeise)).toBeGreaterThanOrEqual(7);
  });

  it('verlässt sich nicht auf den Abstand zwischen Grün und Rot', () => {
    /*
      Im Graustufendruck fallen die beiden fast zusammen. Genau deshalb steht
      neben jeder farbigen Angabe ein Wort – der Test hält den Befund fest,
      damit niemand die Farbe später zum alleinigen Träger macht.
    */
    expect(kontrast(DRUCK_FARBEN.erfolg, DRUCK_FARBEN.fehler)).toBeLessThan(2);
  });
});

describe('druckStil', () => {
  it('setzt keine Seitengeometrie – die kommt aus den Optionen', () => {
    /* Zwei Quellen für dieselbe Größe wären eine Fehlerquelle, und sobald
       `@page` im Spiel ist, übergeht Electron die Option `landscape`. */
    expect(druckStil('normal')).not.toContain('@page');
  });

  it('vergrößert im Großdruck auch Zeilenlänge und Überschriften', () => {
    const normal = druckStil('normal');
    const gross = druckStil('gross');

    expect(normal).toContain('font-size: 12pt');
    expect(gross).toContain('font-size: 17pt');
    expect(normal).toContain('max-width: 78ch');
    expect(gross).toContain('max-width: 52ch');
  });

  it('wiederholt den Tabellenkopf auf Folgeseiten', () => {
    expect(druckStil('normal')).toContain('thead { display: table-header-group; }');
  });
});

// ─── Der Lernbericht ────────────────────────────────────────────────────

const UEBERSICHT: Lernuebersicht = {
  fragenGesamt: 575,
  beantwortet: 120,
  belegt: 48,
  reifegrad: 48 / 575,
  stufe: 'zurueck',
  deckelnd: [],
  faellig: 12,
  gemerkt: 5,
  offen: 0,
  fehler: 0,
  heuteRichtig: 8,
  heuteFalsch: 2,
  heuteBearbeitet: 0,
  tageSeitLetzterAntwort: null,
  bereiche: [
    {
      id: 'I',
      titel: 'Waffenrecht & Vorschriften',
      fragenGesamt: 300,
      beantwortet: 100,
      belegt: 40,
      reifegrad: 40 / 300,
      stufe: 'zurueck',
    },
  ],
};

const PLAN: Lernplan = {
  termin: '2026-10-01',
  tageBisTermin: 41,
  gesamtFragen: 575,
  nieBeantwortet: 455,
  faellig: 12,
  zielquote: 0.9,
  prognoseHeute: 0.42,
  prognoseAmTermin: 0.31,
  pensum: { neu: 12, wiederholung: 8, gesamt: 20, minuten: 9 },
  machbarkeit: 'machbar',
  sekundenProFrage: 25,
};

const VERLAUF: Pruefungsverlauf[] = [
  {
    id: 3,
    profilId: 'standard',
    profilName: 'Standard',
    zeitpunkt: '2026-08-20T10:00:00.000Z',
    gesamt: 80,
    richtig: 70,
    quote: 0.875,
    urteil: 'bestanden',
    dauerMs: 71 * 60_000,
    zeitmodus: 'normal',
    unbeantwortet: 0,
    zeitAbgelaufen: false,
    bereiche: null,
    bestehensQuote: 0.8,
  },
];

describe('lernberichtBauen', () => {
  const bericht = lernberichtBauen({
    uebersicht: UEBERSICHT,
    plan: PLAN,
    verlauf: VERLAUF,
    profilName: 'Max & Moritz',
    quelle: QUELLE,
    erstelltAm: '2026-08-21T06:00:00.000Z',
    schriftgroesse: 'normal',
  });

  it('nennt die vier Abschnitte', () => {
    for (const titel of [
      'Ihr Stand insgesamt',
      'Nach Bereichen',
      'Ihr Lernplan',
      'Prüfungssimulationen',
    ]) {
      expect(bericht).toContain(titel);
    }
  });

  it('gibt die Zahlen des Lernstands wieder', () => {
    expect(bericht).toContain('48 von 575 Fragen sitzen belegt.');
    expect(bericht).toContain('8 %');
  });

  it('übernimmt die Bereichsbezeichnung des Katalogs unverändert und maskiert', () => {
    /* Änderungsverbot: Der amtliche Titel darf nicht umformuliert werden –
       maskieren ist keine Änderung des Wortlauts, sondern seine korrekte
       Darstellung. */
    expect(bericht).toContain('I – Waffenrecht &amp; Vorschriften');
  });

  it('sagt bei der Prognose zum Termin dazu, worauf sie beruht', () => {
    expect(bericht).toContain('Ohne weiteres Lernen');
  });

  it('nennt das Urteil eines Laufs als Wort', () => {
    expect(bericht).toContain('Bestanden');
  });

  it('nennt zu jedem Lauf die Bearbeitungsdauer', () => {
    /* Auch auf dem Papier: Zeitnot ist ein eigenes Durchfallrisiko und an der
       Trefferquote nicht abzulesen. In vollen Minuten – zum Vergleich
       mehrerer Läufe untereinander sind Sekunden Rauschen. */
    expect(bericht).toContain('>Dauer</th>');
    expect(bericht).toContain('71 Minuten');
  });

  it('kommt ohne Lernplan aus', () => {
    const ohne = lernberichtBauen({
      uebersicht: UEBERSICHT,
      plan: null,
      verlauf: [],
      profilName: 'A',
      quelle: QUELLE,
      erstelltAm: '2026-08-21T06:00:00.000Z',
      schriftgroesse: 'normal',
    });

    expect(ohne).toContain('konnte für diesen Bericht nicht ermittelt werden');
    expect(ohne).toContain('noch keine Prüfungssimulation');
  });

  it('enthält keine Frage und keine Antwort im Wortlaut', () => {
    /* Der Bericht soll den Stand zeigen, nicht den Katalog ersetzen.

       Geprüft wird der Inhalt ohne das eingebettete Stylesheet: Dessen
       Kommentare erklären Regeln für die anderen Dokumente und nennen dabei
       Wörter wie „Antwortmöglichkeiten“. Sie stehen in jedem Dokument, auch
       in diesem – gemeint ist hier aber, was der Leser sieht. */
    const inhalt = bericht.replace(/<style>[\s\S]*?<\/style>/u, '');

    expect(inhalt).not.toMatch(/Antwortm[oö]glichkeit/u);
    expect(inhalt).not.toContain('Musterantwort');
  });
});

describe('dateiname', () => {
  it('macht aus Namen und Datum einen brauchbaren Dateinamen', () => {
    expect(dateiname('Max Mustermann', '2026-08-21T06:00:00.000Z')).toBe(
      'Lernbericht-Max-Mustermann-2026-08-21.pdf',
    );
  });

  it('entfernt Zeichen, mit denen Dateisysteme nichts anfangen', () => {
    const name = dateiname('Müller/Meier: "Test" \\ *?', '2026-08-21T06:00:00.000Z');

    expect(name).toMatch(/^Lernbericht-[A-Za-z0-9-]+-2026-08-21\.pdf$/u);
    expect(name).not.toMatch(/[/\\:*?"<>|]/u);
  });

  it('kommt mit einem leeren Namen und einem unbrauchbaren Datum zurecht', () => {
    expect(dateiname('', 'unsinn')).toBe('Lernbericht-ohne-datum.pdf');
  });
});
