// @vitest-environment node
/**
 * Die Kanonform der Suche, gemessen am echten Katalog.
 *
 * Diese Tests arbeiten bewusst **nicht** mit ausgedachten Beispielen. Eine
 * Faltung, die an zwanzig handverlesenen Wörtern richtig liegt, kann am
 * einundzwanzigsten alles verlieren – und niemand merkt es, weil die Suche
 * weiter antwortet, nur mit weniger. Gemessen wird deshalb über alle
 * Wortformen des amtlichen Katalogs.
 */

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

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

import type { Katalog } from '../src/shared/katalog';
import { aufUrsprung, falten, faltenMitZuordnung, istWortanfang } from '../src/shared/suchtext';

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

const KATALOG = JSON.parse(
  readFileSync(join(wurzel, '..', 'content', 'katalog', 'katalog.json'), 'utf8'),
) as Katalog;

/** Jeder Textblock des Katalogs: Frage, Antwortoptionen, Musterantwort. */
function bloecke(): string[] {
  const aus: string[] = [];
  for (const frage of KATALOG.fragen) {
    aus.push(frage.frage.text);
    for (const option of frage.optionen ?? []) {
      aus.push(option.inhalt.text);
    }
    if (frage.musterantwort) {
      aus.push(frage.musterantwort.text);
    }
  }
  return aus;
}

/** Alle im Katalog vorkommenden Wortformen, ungefaltet. */
function wortformen(): string[] {
  const menge = new Set<string>();
  for (const block of bloecke()) {
    for (const wort of block.split(/[^\p{L}\p{N}]+/u)) {
      if (wort.length > 0) {
        menge.add(wort);
      }
    }
  }
  return [...menge];
}

const BLOECKE = bloecke();
const WORTFORMEN = wortformen();

describe('Kanonform – Grundzusagen', () => {
  it('führt alle Schreibweisen von „Büchse“ zusammen', () => {
    const ziel = falten('Büchse');
    expect(falten('Buechse')).toBe(ziel);
    expect(falten('buchse')).toBe(ziel);
    expect(falten('BÜCHSE')).toBe(ziel);
  });

  it('führt alle Schreibweisen von „Schießstätte“ zusammen', () => {
    const ziel = falten('Schießstätte');
    expect(falten('Schiessstätte')).toBe(ziel);
    expect(falten('Schiesstätte')).toBe(ziel);
    expect(falten('schiesstaette')).toBe(ziel);
  });

  it('faltet ein zerlegt gespeichertes ä wie ein zusammengesetztes', () => {
    /* NFD: „a“ plus kombinierendes Trema. Beide Wege müssen bei „a“ enden,
       sonst hinge das Suchergebnis daran, wie der Text gespeichert wurde. */
    expect(falten('ähnlich')).toBe(falten('ähnlich'));
  });

  it('ist idempotent – über alle Wortformen des Katalogs', () => {
    const brueche = WORTFORMEN.filter((wort) => falten(falten(wort)) !== falten(wort));
    expect(brueche, `Nicht idempotent: ${brueche.slice(0, 10).join(', ')}`).toEqual([]);
  });

  it('ist idempotent – auch über die ungeteilten Textblöcke', () => {
    /*
      Diese Zusicherung sieht wie eine Wiederholung der vorigen aus und ist
      keine. Die Wortformen entstehen durch Zerlegen an Nicht-Buchstaben –
      Bindestriche, Punkte und Klammern verschwinden dabei, und genau dort
      saß der Fehler: Die Regel für den Bindestrich sah auf das
      Ursprungszeichen, die Kleinschreibung geschah aber vorher.
      `falten("Wadcutter-Geschoss")` behielt den Strich, ein zweiter Durchlauf
      entfernte ihn. Über Wortformen war das unsichtbar.
    */
    const brueche = BLOECKE.filter((t) => falten(falten(t)) !== falten(t));
    expect(brueche.length, `Nicht idempotent: ${brueche.slice(0, 3).join(' | ')}`).toBe(0);
  });

  it('deckt mit den Wortformen einen nennenswerten Bestand ab', () => {
    /* Eine Zusicherung über den Test selbst: Bräche das Einlesen, liefen die
       Messungen oben über eine leere Liste und meldeten grün. */
    expect(WORTFORMEN.length).toBeGreaterThan(3000);
    expect(BLOECKE.length).toBeGreaterThan(2000);
  });
});

describe('Kanonform – Ziffern bleiben unangetastet', () => {
  /*
    Der Laufkollaps gilt nur für Buchstaben. Die naheliegende Fassung
    /(.)\1+/u fräße auch Ziffern – in einem Korpus aus Joule-, Millimeter-
    und Kaliberangaben wäre das ein stiller Falschtrefferautomat.
  */
  it('hält 10, 100 und 1000 auseinander', () => {
    expect(falten('10')).toBe('10');
    expect(falten('100')).toBe('100');
    expect(falten('1000')).toBe('1000');
  });

  it('hält 2 und 22, 26,5 und 265 auseinander', () => {
    expect(falten('2')).not.toBe(falten('22'));
    expect(falten('26,5')).not.toBe(falten('265'));
  });

  it('lässt Maßangaben unverändert erkennbar', () => {
    expect(falten('7,5 Joule')).toBe('7,5 joule');
    expect(falten('Kaliber 12/70')).toBe('kaliber 12/70');
  });
});

describe('Kanonform – was sie kostet', () => {
  /*
    Die Faltung wirft Unterschiede weg, und einige davon sind echte. Diese
    Liste ist ein FESTWERT, keine Obergrenze: Sie steht hier, damit eine
    spätere „Verbesserung“ auffällt – in beide Richtungen. Wer eine Gruppe
    auflöst, muss sie streichen; wer eine neue erzeugt, muss sie eintragen
    und dabei erklären, warum der Verlust hinnehmbar ist.
  */
  it('erzeugt genau die bekannten Kollisionsgruppen', () => {
    const nachForm = new Map<string, Set<string>>();
    for (const wort of WORTFORMEN) {
      const schluessel = falten(wort);
      const gruppe = nachForm.get(schluessel) ?? new Set<string>();
      gruppe.add(wort.toLowerCase());
      nachForm.set(schluessel, gruppe);
    }

    const gruppen = [...nachForm.values()]
      .filter((g) => g.size > 1)
      .map((g) => [...g].sort().join('/'))
      .sort();

    expect(gruppen).toEqual([
      'das/dass',
      'den/denn',
      'erhalt/erhält',
      'fahrt/fährt',
      'fallen/fällen',
      /* Kein Verlust, sondern ein Gewinn: Der Katalog schreibt an fünf
         Stellen „Geschossfang“ und an einer „Geschoßfang“. Die ß-Faltung
         führt beide zusammen – wer das eine sucht, findet auch das andere. */
      'geschossfang/geschoßfang',
      'hohe/höhe',
      'i/ii',
      'lange/länge',
      'langer/länger',
      'm/mm',
      /* Ebenso: die amtliche Schreibung schwankt zwischen „Mitgliedstaat“
         und „Mitgliedsstaat“. */
      'mitgliedsstaat/mitgliedstaat',
      'mitgliedsstaates/mitgliedstaates',
      'munitionserwerbschein/munitionserwerbsschein',
      'musste/müsste',
      'nahe/nähe',
      'neu/neue',
      'starke/stärke',
      'wen/wenn',
      'zahlen/zählen',
    ]);
  });
});

describe('Kanonform – Bindestriche zwischen Buchstaben', () => {
  /*
    Drei Stellen im amtlichen Katalog tragen einen Strich mitten im Wort, und
    keine davon lässt sich beheben: „er-klärt“ und „orange-farbenen“ stehen so
    im Original, „lever-action“ ist richtig so. Am amtlichen Wortlaut wird
    nicht gearbeitet – also liest die Suche darüber hinweg. Und wer
    „Double-Action-Revolver“ sucht, tippt den Strich mal mit und mal ohne.
  */
  it('findet „erklärt“ auch dort, wo „er-klärt“ steht', () => {
    expect(falten('er-klärt')).toBe(falten('erklärt'));
    expect(falten('orange-farbenen')).toBe(falten('orangefarbenen'));
    expect(falten('lever-action')).toBe(falten('leveraction'));
  });

  it('gilt unabhängig von der Schreibung – sonst wäre die Faltung nicht idempotent', () => {
    /* Genau hier saß der Fehler: „Wadcutter-Geschoss“ hat einen Großbuchstaben
       hinter dem Strich, die gefaltete Form nicht mehr. Sähe die Regel auf die
       Schreibung, lieferten beide Durchläufe Verschiedenes. */
    expect(falten('Wadcutter-Geschoss')).toBe(falten('wadcuttergeschoss'));
    expect(falten('Physikalisch-Technische')).toBe(falten('physikalischtechnische'));
    expect(falten('Double-Action-Revolver')).toBe(falten('doubleactionrevolver'));
  });

  it('lässt den Strich neben einer Ziffer stehen', () => {
    // Damit Fragennummern und technische Bezeichnungen erkennbar bleiben.
    expect(falten('CO2-Waffen')).toContain('-');
    expect(falten('II-45')).toContain('-');
    expect(falten('I.2-150')).toContain('-');
  });

  it('lässt den Ergänzungsstrich vor einem Leerzeichen stehen', () => {
    // „Hieb- und Stoßwaffen“ – der Strich vertritt hier ein ganzes Wort.
    expect(falten('Hieb- und Stoßwaffen')).toContain('- ');
  });

  it('greift auch im echten Bestand', () => {
    const gefaltet = BLOECKE.map((t) => falten(t)).join('\n');
    for (const wort of ['erklart', 'orangefarbenen', 'leveraction', 'wadcutergeschos']) {
      expect(gefaltet, `„${wort}“ nicht auffindbar`).toContain(wort);
    }
  });
});

describe('Positionszuordnung', () => {
  it('stimmt mit der schnellen Faltung überein – über alle Textblöcke', () => {
    /* Zwei Umsetzungen derselben Regel liefen unweigerlich auseinander, und
       der Schaden wäre still: richtig gefunden, falsch markiert. */
    const abweichungen = BLOECKE.filter((t) => faltenMitZuordnung(t).gefaltet !== falten(t));
    expect(abweichungen.length).toBe(0);
  });

  it('trifft die Fundstelle im Ursprungstext – über alle Fragen', () => {
    /*
      Die eigentliche Zusicherung: Wird eine Fundstelle im gefalteten Text
      auf den Ursprung zurückgerechnet, muss der herausgeschnittene
      Originalabschnitt dasselbe gefaltete Wort ergeben. Gemessen wird über
      alle 575 Fragen mit einer Batterie von Suchwörtern – ausdrücklich auch
      an Fragen mit Umlauten, denn dort ändert die Faltung die Längen.
    */
    const woerter = [
      'schiessen',
      'buechse',
      'waffe',
      'erwerben',
      'gross',
      'fuehren',
      'zulaessig',
      'schusswaffe',
      'munition',
      'aufbewahrung',
      'notwehr',
      'geschoss',
      'patrone',
      'sportschuetze',
      'jaeger',
      'behoerde',
      'gefaehrlich',
      'draussen',
      'massnahme',
      'ueberlassen',
    ].map((w) => falten(w));

    let geprueft = 0;
    const fehler: string[] = [];

    for (const frage of KATALOG.fragen) {
      const roh = frage.frage.text;
      const faltung = faltenMitZuordnung(roh);
      for (const wort of woerter) {
        const stelle = faltung.gefaltet.indexOf(wort);
        if (stelle === -1) {
          continue;
        }
        const spanne = aufUrsprung(faltung, stelle, stelle + wort.length);
        if (spanne === null) {
          fehler.push(`${frage.id}: keine Spanne für „${wort}“`);
          continue;
        }
        const ausschnitt = roh.slice(spanne.von, spanne.bis);
        geprueft++;
        if (!falten(ausschnitt).includes(wort)) {
          fehler.push(`${frage.id}: „${wort}“ → „${ausschnitt}“ (${falten(ausschnitt)})`);
        }
      }
    }

    expect(fehler, fehler.slice(0, 5).join('\n')).toEqual([]);
    // Ohne Mindestzahl bewiese ein leeres Ergebnis nichts.
    expect(geprueft).toBeGreaterThan(200);
  });

  it('verweigert die Auskunft bei unplausiblen Spannen', () => {
    const f = faltenMitZuordnung('Waffe');
    expect(aufUrsprung(f, 0, 0)).toBeNull();
    expect(aufUrsprung(f, -1, 2)).toBeNull();
    expect(aufUrsprung(f, 0, 99)).toBeNull();
  });
});

describe('Wortanfang', () => {
  it('erkennt den Anfang am Zeichenrand und nach Trennzeichen', () => {
    const t = falten('Sport und Transport, § 3');
    expect(istWortanfang(t, 0)).toBe(true);
    expect(istWortanfang(t, t.indexOf('transport'))).toBe(true);
    // „port“ in „transport“ ist kein Wortanfang – das trennt die Rangklassen.
    expect(istWortanfang(t, t.indexOf('transport') + 5)).toBe(false);
  });
});
