/**
 * Die Kanonform für die Suche – deutsche Schreibweisen auf einen Nenner.
 *
 * ## Warum das nötig ist und keine Bibliothek es abnimmt
 *
 * Wer „Büchse“ sucht, tippt je nach Tastatur und Gewohnheit `Büchse`,
 * `Buechse` oder `Buchse`; wer „Schießstätte“ sucht, schreibt `Schiessstätte`
 * oder `Schiesstaette`. Alle diese Formen müssen dasselbe finden. Nachgemessen
 * am amtlichen Katalog: 91,7 Prozent der Fragen enthalten Umlaute oder ß.
 *
 * SQLite FTS5 kann das nicht leisten – und zwar keiner seiner Tokenizer:
 * `unicode61` faltet Diakritika, aber kein ß (`schiessen` → null Treffer,
 * `schießen` → 52); `trigram` faltet überhaupt nichts. Ein ICU-Tokenizer ist
 * im mitgelieferten Prebuild nicht enthalten. Die Faltung muss also ohnehin
 * von Hand geschrieben werden – und wenn sie geschrieben ist, trägt eine
 * Volltextmaschine nichts mehr bei. Die Begründung im Ganzen steht in
 * `docs/entscheidung-volltextsuche.md`.
 *
 * ## Die Reihenfolge der Schritte ist nicht beliebig
 *
 * 1. Kleinschreibung.
 * 2. ä→ae, ö→oe, ü→ue, ß→ss. **Vor** dem Laufkollaps: sonst faltete
 *    „Schießstätte“ (drei s nach der Ersetzung) anders als die Eingabe
 *    „Schiesstätte“ (zwei s), und die Funktion wäre nicht idempotent.
 * 3. Kombinierende Zeichen entfallen – damit trifft ein zerlegt
 *    gespeichertes „ä“ (a + U+0308) dasselbe Ergebnis wie das
 *    zusammengesetzte. Beide enden bei `a`.
 * 4. Laufkollaps: mehrfach gleiche Buchstaben werden zu einem.
 * 5. ae→a, oe→o, ue→u. Erst hier, damit „Buechse“ und „Büchse“ zusammenfallen.
 *
 * ## Der Laufkollaps gilt nur für Buchstaben – und das ist wichtig
 *
 * Die naheliegende Fassung `/(.)\1+/u` frisst auch Ziffern. In einem Korpus
 * aus Joule-, Millimeter- und Kaliberangaben wäre das ein stiller
 * Falschtrefferautomat: 10 = 100 = 1000, 2 = 22, 7 = 77. Nachgemessen: mit
 * der Buchstabenfassung liefert „100“ acht Fragen, „1000“ fünf und „10“
 * achtundzwanzig – sauber getrennt.
 *
 * ## Was die Faltung kostet
 *
 * Sie wirft Unterschiede weg, und einige davon sind echte. Über alle 3.648
 * Wortformen des Katalogs gemessen entstehen **20 Kollisionsgruppen** –
 * das/dass, wen/wenn, zählen/zahlen, höhe/hohe und so fort. Wer „zahlen“
 * sucht, sieht auch „zählen“. Das ist der Preis dafür, dass „schiessen“
 * überhaupt etwas findet, und er ist bewusst bezahlt. Die Liste steht als
 * Festwert im Test, damit eine spätere „Verbesserung“ auffällt.
 */

/** Zeichen, die als Wortbestandteil gelten – nach der Faltung nur noch ASCII. */
const WORTZEICHEN = /[a-z0-9]/u;

interface Zeichen {
  readonly ch: string;
  /** Erster beitragender Index im Ursprungstext. */
  readonly von: number;
  /** Letzter beitragender Index im Ursprungstext. */
  readonly bis: number;
}

/** Was ein einzelnes Ursprungszeichen zur Kanonform beiträgt. */
function ersetzung(ch: string): string {
  switch (ch) {
    case 'ä':
      return 'ae';
    case 'ö':
      return 'oe';
    case 'ü':
      return 'ue';
    case 'ß':
    case 'ẞ':
      return 'ss';
    default:
      return ch;
  }
}

const KOMBINIEREND = /\p{M}/u;

const BUCHSTABE = /[a-z]/u;

/**
 * Ein Buchstabe – **gleich welcher Schreibung**.
 *
 * Die Groß-/Kleinschreibung hier zu prüfen wäre ein Fehler, und zwar ein
 * stiller: Die Faltung schreibt zuerst alles klein, die Regel sähe aber auf
 * das Ursprungszeichen. `falten("Wadcutter-Geschoss")` behielte den Strich
 * (»r« vor großem »G«), `falten` derselben bereits gefalteten Zeichenkette
 * entfernte ihn – die Kanonform hinge davon ab, wie oft man sie anwendet.
 * Nachgemessen war sie das eine Zeit lang, und der Selbstfindungstest hat es
 * gefunden: 24 von 575 Fragen fanden sich mit ihrem eigenen längsten Wort
 * nicht mehr.
 */
const BUCHSTABE_BELIEBIG = /\p{L}/u;

/** Mehrfach gleiche Buchstaben zu einem – Ziffern bleiben unberührt. */
function kollabieren(quelle: readonly Zeichen[]): Zeichen[] {
  const aus: Zeichen[] = [];
  for (const z of quelle) {
    const vorher = aus[aus.length - 1];
    if (vorher?.ch === z.ch && BUCHSTABE.test(z.ch)) {
      aus[aus.length - 1] = { ch: vorher.ch, von: vorher.von, bis: z.bis };
      continue;
    }
    aus.push(z);
  }
  return aus;
}

/** ae→a, oe→o, ue→u – erst hier fallen „Buechse“ und „Büchse“ zusammen. */
function vokaleFalten(quelle: readonly Zeichen[]): Zeichen[] {
  const aus: Zeichen[] = [];
  for (let i = 0; i < quelle.length; i++) {
    const a = quelle[i];
    const b = quelle[i + 1];
    if (a === undefined) {
      continue;
    }
    if (b?.ch === 'e' && (a.ch === 'a' || a.ch === 'o' || a.ch === 'u')) {
      aus.push({ ch: a.ch, von: a.von, bis: b.bis });
      i++;
      continue;
    }
    aus.push(a);
  }
  return aus;
}

/**
 * Der gemeinsame Kern von {@link falten} und {@link faltenMitZuordnung}.
 *
 * Beide gehen bewusst durch dieselbe Schleife. Zwei getrennte Umsetzungen –
 * eine schnelle für den Index, eine buchführende für die Hervorhebung –
 * liefen unweigerlich auseinander, und der Schaden wäre still: Die Suche
 * fände richtig und markierte falsch.
 */
function kern(roh: string): Zeichen[] {
  // Schritt 1 bis 3: je Ursprungszeichen, Index bleibt zuordenbar.
  const eins: Zeichen[] = [];
  for (let i = 0; i < roh.length; i++) {
    const ch = roh[i] ?? '';
    if (KOMBINIEREND.test(ch)) {
      // Ein zerlegt gespeicherter Umlaut: der Grundbuchstabe steht schon da.
      continue;
    }
    if (
      ch === '-' &&
      BUCHSTABE_BELIEBIG.test(roh[i - 1] ?? '') &&
      BUCHSTABE_BELIEBIG.test(roh[i + 1] ?? '')
    ) {
      /*
        Ein Bindestrich zwischen zwei Buchstaben zählt für die Suche nicht.

        Zwei Gründe. Erstens der amtliche Bestand: „er-klärt“ in Frage
        2.123 b und „orange-farbenen“ in IV-52 stehen so im Original – in
        derselben Frage 2.123 schreibt Antwort a) „erklärt“ –, und
        „lever-action“ in 1.28 ist ein englisches Kompositum und richtig so.
        Am amtlichen Wortlaut wird nicht gearbeitet; also liest die Suche
        darüber hinweg. Zweitens die Eingabe: Wer „Double-Action-Revolver“
        sucht, tippt den Strich mal mit und mal ohne.

        Unberührt bleibt der Ergänzungsstrich vor einem Leerzeichen („Hieb-
        und Stoßwaffen“) – dort vertritt er ein ganzes Wort – und der Strich
        neben einer Ziffer („CO2-Waffen“, „II-45“, „I.2-150“), damit
        Fragennummern erkennbar bleiben.
      */
      continue;
    }
    for (const aus of ersetzung(ch.toLowerCase())) {
      eins.push({ ch: aus, von: i, bis: i });
    }
  }

  /*
    Schritt 4 und 5 laufen bis zum Stillstand, höchstens vier Runden.

    Einzeln angewandt wären sie nicht idempotent, und das ist keine graue
    Theorie: „ää“ wird zu „aeae“, daraus macht die Vokalfaltung „aa“, und
    erst ein zweiter Laufkollaps macht daraus „a“ – wozu `falten("a")` schon
    beim ersten Durchgang käme. Eine Kanonform, die von der Zahl der
    Anwendungen abhängt, ist keine.
  */
  let liste = eins;
  for (let runde = 0; runde < 4; runde++) {
    const vorher = liste.length;
    liste = kollabieren(liste);
    liste = vokaleFalten(liste);
    if (liste.length === vorher) {
      break;
    }
  }

  // Schritt 6: Leerraum vereinheitlichen und außen abschneiden.
  const vier: Zeichen[] = [];
  for (const z of liste) {
    if (/\s/u.test(z.ch)) {
      const vorher = vier[vier.length - 1];
      if (vorher === undefined) {
        continue; // führender Leerraum
      }
      if (vorher.ch === ' ') {
        vier[vier.length - 1] = { ch: ' ', von: vorher.von, bis: z.bis };
        continue;
      }
      vier.push({ ch: ' ', von: z.von, bis: z.bis });
      continue;
    }
    vier.push(z);
  }
  while (vier.length > 0 && vier[vier.length - 1]?.ch === ' ') {
    vier.pop();
  }

  return vier;
}

/**
 * Bringt einen Text auf die Kanonform.
 *
 * Idempotent: `falten(falten(x)) === falten(x)` für jede der 3.648 Wortformen
 * des Katalogs – der Test misst das, statt es zu behaupten.
 */
export function falten(roh: string): string {
  let aus = '';
  for (const z of kern(roh)) {
    aus += z.ch;
  }
  return aus;
}

/** Die Kanonform samt Rückweg auf die Stellen im Ursprungstext. */
export interface Faltung {
  readonly gefaltet: string;
  /** Je gefaltetem Zeichen der erste beitragende Ursprungsindex. */
  readonly von: readonly number[];
  /** Je gefaltetem Zeichen der letzte beitragende Ursprungsindex. */
  readonly bis: readonly number[];
}

/**
 * Faltung mit Buchführung über die Herkunft jedes Zeichens.
 *
 * Ohne sie markierte die Trefferhervorhebung daneben: Die Faltung ändert
 * Längen – aus „ß“ wird „s“, aus „ü“ wird „u“ –, und 91,7 Prozent der Fragen
 * sind betroffen. Eine Fundstelle `[i, j)` im gefalteten Text wird über
 * `[von[i], bis[j-1] + 1)` zur Spanne im Ursprungstext.
 */
export function faltenMitZuordnung(roh: string): Faltung {
  const zeichen = kern(roh);
  const von = new Array<number>(zeichen.length);
  const bis = new Array<number>(zeichen.length);
  let gefaltet = '';
  for (let i = 0; i < zeichen.length; i++) {
    const z = zeichen[i];
    if (z === undefined) {
      continue;
    }
    gefaltet += z.ch;
    von[i] = z.von;
    bis[i] = z.bis;
  }
  return { gefaltet, von, bis };
}

/**
 * Rechnet eine Fundstelle im gefalteten Text auf den Ursprungstext zurück.
 *
 * Liefert `null`, wenn die Spanne unplausibel ist. Dieses Sicherheitsventil
 * ist Absicht: Lieber gar keine Markierung als eine falsche – eine falsch
 * markierte Stelle behauptet, das gesuchte Wort stehe dort, wo es nicht steht.
 */
export function aufUrsprung(
  faltung: Faltung,
  anfang: number,
  ende: number,
): { readonly von: number; readonly bis: number } | null {
  if (anfang < 0 || ende <= anfang || ende > faltung.gefaltet.length) {
    return null;
  }
  const a = faltung.von[anfang];
  const b = faltung.bis[ende - 1];
  if (a === undefined || b === undefined || b < a) {
    return null;
  }
  return { von: a, bis: b + 1 };
}

/**
 * Steht an dieser Stelle des gefalteten Textes ein Wortanfang?
 *
 * Nach der Faltung gibt es weder Umlaute noch ß, ein Blick auf `[a-z0-9]`
 * genügt also. Der Wortanfang ist kein Filter, sondern ein Rangkriterium:
 * „Sport“ soll vor „Transport“ stehen, aber „Transport“ soll gefunden werden.
 */
export function istWortanfang(gefaltet: string, stelle: number): boolean {
  if (stelle <= 0) {
    return true;
  }
  return !WORTZEICHEN.test(gefaltet[stelle - 1] ?? '');
}

/**
 * Alle Fundstellen eines gefalteten Suchworts in einem gefalteten Text.
 *
 * Teilzeichenkette, nicht Wortanfang. Die Begründung liegt im Korpus:
 * „besitzkarte“ findet als Wortanfangssuche **null** Fragen und als Teilwort
 * **52**; „schein“ eine gegen 61; „karte“ null gegen 52. Deutsche
 * Rechtssprache verschluckt das gesuchte Wort im Kompositum – eine Suche, die
 * nur Wortanfänge kennt, schweigt bei der häufigsten deutschen Wortbildung.
 */
export function fundstellen(gefaltet: string, wort: string): number[] {
  if (wort.length === 0) {
    return [];
  }
  const stellen: number[] = [];
  let ab = gefaltet.indexOf(wort);
  while (ab !== -1) {
    stellen.push(ab);
    ab = gefaltet.indexOf(wort, ab + 1);
  }
  return stellen;
}
