// @vitest-environment node
// Der Katalog wird im Main-Prozess von der Platte gelesen – jsdom hätte hier
// nichts zu tun und würde nur Dateizugriffe verkomplizieren.

import { join } from 'node:path';

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

import type { Katalog } from '../src/shared/katalog';

/**
 * `app.getAppPath()` liefert im Entwicklungsmodus das Verzeichnis mit der
 * package.json – unter Vitest ist das genau das Arbeitsverzeichnis.
 */
vi.mock('electron', () => ({
  app: {
    isPackaged: false,
    getAppPath: (): string => process.cwd(),
  },
}));

const {
  bildAufloesen,
  katalogBild,
  katalogLaden,
  katalogValidieren,
  katalogVerzeichnis,
  katalogZuruecksetzen,
} = await import('../src/main/katalog');

// ─── Prüfmuster ─────────────────────────────────────────────────────────────

/** Kleiner, gültiger Katalog. Die Tests verbiegen jeweils genau ein Feld. */
function musterkatalog(): Record<string, unknown> {
  return {
    meta: {
      titel: 'Prüfkatalog',
      herausgeber: 'Bundesverwaltungsamt',
      stand: '2024-12-16',
      quellenangabe: 'Amtlicher Fragenkatalog, Stand 16.12.2024.',
      quelle_url: 'https://www.bva.bund.de/',
      quelldatei_sha256: 'a'.repeat(64),
      fragen_gesamt: 2,
    },
    kapitel: [
      {
        id: 'I',
        titel: 'Waffenrecht',
        abschnitte: [{ id: 'I.1', titel: 'Begriffe des Waffenrechts' }],
      },
      { id: 'II', titel: 'Waffentechnik', abschnitte: [] },
    ],
    bilder: [
      {
        id: 'zeichen-1',
        datei: 'zeichen-1.png',
        breite: 73,
        hoehe: 73,
        alt: 'Rundes Zeichen',
        beschreibung: 'Zulassungszeichen für bauartgeprüfte Waffen.',
      },
    ],
    fragen: [
      {
        id: 'I.1-01',
        amtliche_nummer: '1.01',
        kapitel: 'I',
        abschnitt: 'I.1',
        typ: 'mc',
        seite: 6,
        frage: { text: 'Umgang mit einer Schusswaffe hat…', segmente: [{ t: 'Umgang…' }] },
        bilder: [],
        optionen: [
          { label: 'a', inhalt: { text: 'wer schießt.', segmente: [] }, korrekt: true, bilder: [] },
          { label: 'b', inhalt: { text: 'niemand.', segmente: [] }, korrekt: false, bilder: [] },
        ],
      },
      {
        id: 'II-01',
        amtliche_nummer: '2.01',
        kapitel: 'II',
        abschnitt: null,
        typ: 'freitext',
        seite: 40,
        frage: { text: 'Welches Zeichen ist abgebildet?', segmente: [{ t: 'Welches Zeichen?' }] },
        bilder: ['zeichen-1'],
        musterantwort: {
          text: 'Das PTB-Zeichen.',
          segmente: [{ t: 'Das ' }, { t: 'PTB-Zeichen', h: true }, { t: '.' }],
        },
      },
    ],
  };
}

/** Baut einen Musterkatalog und lässt eine Änderung darauf laufen. */
function verbogen(aendern: (katalog: Record<string, unknown>) => void): Record<string, unknown> {
  const katalog = musterkatalog();
  aendern(katalog);
  return katalog;
}

function fragen(katalog: Record<string, unknown>): Record<string, unknown>[] {
  return katalog['fragen'] as Record<string, unknown>[];
}

function meta(katalog: Record<string, unknown>): Record<string, unknown> {
  return katalog['meta'] as Record<string, unknown>;
}

beforeEach(() => {
  katalogZuruecksetzen();
});

// ─── Validierung ────────────────────────────────────────────────────────────

describe('katalogValidieren', () => {
  it('nimmt einen vollständigen Katalog an', () => {
    const katalog = katalogValidieren(musterkatalog());

    expect(katalog.fragen).toHaveLength(2);
    expect(katalog.meta.fragen_gesamt).toBe(2);
    expect(katalog.fragen[0]?.optionen).toHaveLength(2);
    expect(katalog.fragen[1]?.musterantwort?.segmente[1]).toEqual({ t: 'PTB-Zeichen', h: true });
  });

  it('weist eine abweichende Kopfzahl zurück und nennt beide Zahlen', () => {
    const roh = verbogen((k) => {
      meta(k)['fragen_gesamt'] = 575;
    });

    expect(() => katalogValidieren(roh)).toThrow(/meta\.fragen_gesamt meldet 575/u);
    expect(() => katalogValidieren(roh)).toThrow(/enthalten sind aber 2/u);
  });

  it.each([
    ['titel', 'meta.titel'],
    ['quellenangabe', 'meta.quellenangabe'],
    ['quelldatei_sha256', 'meta.quelldatei_sha256'],
  ])('vermisst das Pflichtfeld meta.%s', (feld, pfad) => {
    const roh = verbogen((k) => {
      // `undefined` und „nicht vorhanden“ müssen gleich behandelt werden.
      meta(k)[feld] = undefined;
    });

    expect(() => katalogValidieren(roh)).toThrow(
      new RegExp(`${pfad.replace('.', '\\.')} fehlt`, 'u'),
    );
  });

  /*
    Zweistufiges Bildkonzept (Prüfplan S4): `alt` bleibt neutral, die
    erklärende `beschreibung` wird nach dem Antworten nachgereicht. Beide
    Felder dürfen fehlen – ein Katalog ohne Beschreibungen bleibt gültig,
    die Oberfläche lässt den Aufklapper dann einfach weg.
  */
  it('reicht die erklärende Bildbeschreibung durch', () => {
    const katalog = katalogValidieren(musterkatalog());

    expect(katalog.bilder[0]?.beschreibung).toBe('Zulassungszeichen für bauartgeprüfte Waffen.');
  });

  it('nimmt ein Bild ohne Beschreibung an und liefert null', () => {
    const roh = verbogen((k) => {
      delete (k['bilder'] as Record<string, unknown>[])[0]!['beschreibung'];
    });

    expect(katalogValidieren(roh).bilder[0]?.beschreibung).toBeNull();
  });

  it('weist eine Beschreibung zurück, die keine Zeichenkette ist', () => {
    const roh = verbogen((k) => {
      (k['bilder'] as Record<string, unknown>[])[0]!['beschreibung'] = 42;
    });

    expect(() => katalogValidieren(roh)).toThrow(
      /bilder\[0\]\.beschreibung ist weder Zeichenkette noch null/u,
    );
  });

  it('weist eine leere Zeichenkette wie ein fehlendes Feld zurück', () => {
    const roh = verbogen((k) => {
      meta(k)['herausgeber'] = '   ';
    });

    expect(() => katalogValidieren(roh)).toThrow(/meta\.herausgeber fehlt/u);
  });

  it('weist einen unbekannten Fragetyp zurück', () => {
    const roh = verbogen((k) => {
      fragen(k)[0]!['typ'] = 'ankreuzen';
    });

    expect(() => katalogValidieren(roh)).toThrow(/typ ist unbekannt: „ankreuzen“/u);
  });

  it('weist eine Multiple-Choice-Frage ohne richtige Option zurück', () => {
    const roh = verbogen((k) => {
      const optionen = fragen(k)[0]!['optionen'] as Record<string, unknown>[];
      for (const option of optionen) {
        option['korrekt'] = false;
      }
    });

    expect(() => katalogValidieren(roh)).toThrow(/keine richtige Antwortoption/u);
  });

  it('weist doppelte Frage-IDs zurück', () => {
    const roh = verbogen((k) => {
      fragen(k)[1]!['id'] = 'I.1-01';
    });

    expect(() => katalogValidieren(roh)).toThrow(/ID „I\.1-01“ mehrfach/u);
  });

  it('weist einen Verweis auf ein unbekanntes Kapitel zurück', () => {
    const roh = verbogen((k) => {
      fragen(k)[0]!['kapitel'] = 'IX';
    });

    expect(() => katalogValidieren(roh)).toThrow(/unbekannte Kapitel „IX“/u);
  });

  it('weist einen Verweis auf ein unbekanntes Bild zurück', () => {
    const roh = verbogen((k) => {
      fragen(k)[1]!['bilder'] = ['zeichen-erfunden'];
    });

    expect(() => katalogValidieren(roh)).toThrow(/unbekannte Bild „zeichen-erfunden“/u);
  });

  it.each([
    ['../../etc/passwd.png'],
    ['assets/zeichen-1.png'],
    ['..\\zeichen-1.png'],
    ['zeichen-1.exe'],
  ])('weist den Dateinamen %s zurück', (datei) => {
    const roh = verbogen((k) => {
      (k['bilder'] as Record<string, unknown>[])[0]!['datei'] = datei;
    });

    expect(() => katalogValidieren(roh)).toThrow(/kein einfacher PNG-Dateiname/u);
  });

  it('weist eine Nutzlast zurück, die gar kein Objekt ist', () => {
    expect(() => katalogValidieren('katalog')).toThrow(/Katalog ist kein Objekt/u);
    expect(() => katalogValidieren(null)).toThrow(/Katalog ist kein Objekt/u);
    expect(() => katalogValidieren([])).toThrow(/Katalog ist kein Objekt/u);
  });
});

// ─── Bild-IDs ───────────────────────────────────────────────────────────────

describe('bildAufloesen', () => {
  let katalog: Katalog;

  beforeEach(() => {
    katalog = katalogValidieren(musterkatalog());
  });

  it('findet ein bekanntes Prüfzeichen', () => {
    expect(bildAufloesen(katalog, 'zeichen-1').datei).toBe('zeichen-1.png');
  });

  it.each([['zeichen-2'], ['ZEICHEN-1'], ['zeichen-1.png'], ['zeichen-1 '], ['']])(
    'weist die unbekannte Bild-ID „%s“ zurück',
    (bildId) => {
      expect(() => bildAufloesen(katalog, bildId)).toThrow(/Bild-ID/u);
    },
  );

  it.each([
    ['../assets/zeichen-1.png'],
    ['../../../../windows/win.ini'],
    ['..\\..\\zeichen-1.png'],
    ['/etc/passwd'],
    ['C:\\Windows\\System32\\config\\SAM'],
    ['assets/../assets/zeichen-1.png'],
    ['%2e%2e%2fzeichen-1.png'],
  ])('weist die pfadartige Bild-ID „%s“ zurück', (bildId) => {
    expect(() => bildAufloesen(katalog, bildId)).toThrow(/Unbekannte Bild-ID/u);
  });

  it.each([[null], [undefined], [42], [{ id: 'zeichen-1' }], [['zeichen-1']]])(
    'weist die Bild-ID %o zurück, weil sie keine Zeichenkette ist',
    (bildId) => {
      expect(() => bildAufloesen(katalog, bildId)).toThrow(/Ungültige Bild-ID/u);
    },
  );

  it('nimmt Steuerzeichen nicht in die Fehlermeldung auf', () => {
    // Nullbyte und ANSI-Escape: beides darf nicht unverändert in einer
    // Meldung oder Protokollzeile landen.
    const nullbyte = String.fromCharCode(0);
    const escape = String.fromCharCode(27);
    const bildId = `zeichen${nullbyte}-${escape}[31mrot`;

    let meldung = '';
    try {
      bildAufloesen(katalog, bildId);
    } catch (fehler) {
      meldung = fehler instanceof Error ? fehler.message : String(fehler);
    }

    expect(meldung).toContain('Unbekannte Bild-ID');
    expect(meldung).toContain('zeichen?-?[31mrot');
    expect(meldung).not.toContain(nullbyte);
    expect(meldung).not.toContain(escape);
  });
});

// ─── Auflösung des Ablageortes ──────────────────────────────────────────────

describe('katalogVerzeichnis', () => {
  it('zeigt in der Entwicklung auf content/katalog neben dem app-Verzeichnis', () => {
    expect(katalogVerzeichnis()).toBe(join(process.cwd(), '..', 'content', 'katalog'));
  });

  it('zeigt im gepackten Build auf resources/katalog', async () => {
    const vorher = process.resourcesPath;
    Object.defineProperty(process, 'resourcesPath', {
      value: join('C:', 'Programme', 'Waffensachkunde', 'resources'),
      configurable: true,
    });
    vi.resetModules();
    vi.doMock('electron', () => ({
      app: { isPackaged: true, getAppPath: (): string => process.cwd() },
    }));

    try {
      const modul = await import('../src/main/katalog');
      expect(modul.katalogVerzeichnis()).toBe(join(process.resourcesPath, 'katalog'));
    } finally {
      Object.defineProperty(process, 'resourcesPath', { value: vorher, configurable: true });
      vi.doUnmock('electron');
      vi.resetModules();
    }
  });
});

// ─── Der tatsächlich ausgelieferte Katalog ──────────────────────────────────

describe('ausgelieferter Fragenkatalog', () => {
  it('besteht die Validierung und enthält die angekündigte Fragenzahl', () => {
    const katalog = katalogLaden();

    expect(katalog.fragen).toHaveLength(katalog.meta.fragen_gesamt);
    expect(katalog.meta.fragen_gesamt).toBe(575);
    expect(katalog.kapitel.map((k) => k.id)).toEqual(['I', 'II', 'III', 'IV']);
    expect(katalog.bilder).toHaveLength(17);
  });

  /*
    Der Prüfplan (Szenario S4) verlangt zwei Stufen je Zeichen: einen
    neutralen Alt-Text und eine erklärende Beschreibung, die nach dem
    Antworten nachgereicht wird. Bis Fassung 0.20.0 existierte nur die erste
    Stufe – Sehende bekamen über die Erklärung mehr Kontext zum Zeichen als
    Screenreader-Nutzer über das Bild. Alle 17 Zeichen sind belegt
    (content/alttexte.json, hergeleitet aus den Erklärungstexten).
  */
  it('führt zu jedem Prüfzeichen beide Stufen: Alt-Text und Beschreibung', () => {
    const { bilder } = katalogLaden();

    expect(bilder.filter((b) => (b.alt ?? '').trim().length > 0)).toHaveLength(17);
    expect(bilder.filter((b) => (b.beschreibung ?? '').trim().length > 0)).toHaveLength(17);
  });

  /*
    Frage 3.05 fragt, welche Kennzeichen *nicht* auf einer
    waffenbesitzkartenpflichtigen Schusswaffe zu finden sind. BKA-Raute und
    PTB-Trapez gehören zusammen zur richtigen Antwort c). Bis Fassung 0.19.2
    zerschnitt der Katalogeinleser das Doppelzeichen und hängte die BKA-Raute
    an b) – die falsche Antwort. Ein Zeichen an der falschen Antwort ist in
    einer Prüfungssoftware kein Schönheitsfehler, deshalb steht die Zuordnung
    hier fest.
  */
  it('führt das Doppelzeichen der Frage 3.05 ungeteilt an der richtigen Antwort', () => {
    const frage = katalogLaden().fragen.find((f) => f.id === 'I.3-05');
    const optionen = frage?.optionen ?? [];

    expect(optionen.map((o) => [o.label, o.korrekt, o.bilder.length])).toEqual([
      ['a', true, 1],
      ['b', false, 2],
      ['c', true, 1],
    ]);
    expect(frage?.warnungen ?? []).toEqual([]);
  });

  it('vermerkt keine Antwortmöglichkeit ohne Text und ohne Bild', () => {
    /* Der Einleser rät nicht mehr, sondern meldet. Bleibt hier etwas übrig,
       ist eine Antwort im Katalog leer – und niemand sähe es sonst. */
    const leer = katalogLaden().fragen.flatMap((f) => f.warnungen ?? []);

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

  it('wird nur einmal von der Platte gelesen', () => {
    expect(katalogLaden()).toBe(katalogLaden());
  });

  it('liefert Prüfzeichen als Data-URL mit gültiger PNG-Signatur', () => {
    const katalog = katalogLaden();
    const bildId = katalog.bilder[0]!.id;

    const datenUrl = katalogBild(bildId);

    expect(datenUrl.startsWith('data:image/png;base64,')).toBe(true);
    const roh = Buffer.from(datenUrl.slice('data:image/png;base64,'.length), 'base64');
    expect([...roh.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
  });

  it('gibt dieselbe Data-URL aus dem Zwischenspeicher zurück', () => {
    const bildId = katalogLaden().bilder[0]!.id;

    expect(katalogBild(bildId)).toBe(katalogBild(bildId));
  });

  it('liest keine Datei, wenn die Bild-ID unbekannt oder pfadartig ist', () => {
    katalogLaden();

    expect(() => katalogBild('../katalog.json')).toThrow(/Unbekannte Bild-ID/u);
    expect(() => katalogBild('zeichen-gibt-es-nicht')).toThrow(/Unbekannte Bild-ID/u);
  });
});
