import { useCallback, useEffect, useRef, useState } from 'react';

import { sprechtempoBereinigen, SPRECHTEMPO_STANDARD } from '../../../shared/sprechtempo';
import { fuerSprachausgabe, inHaeppchen, stimmeWaehlen } from '../lernen/sprachausgabe';

export interface Sprachausgabe {
  /** Die Laufzeitumgebung kann sprechen. */
  readonly moeglich: boolean;
  /** Es wird gerade gesprochen. */
  readonly laeuft: boolean;
  /** Eine Ausgabe ist angehalten und lässt sich fortsetzen. */
  readonly angehalten: boolean;
  /** Liest den Text vor und bricht eine laufende Ausgabe dafür ab. */
  readonly sprich: (text: string) => void;
  readonly abbrechen: () => void;
  /** Hält an und merkt sich die Stelle. */
  readonly anhalten: () => void;
  /** Spricht ab der gemerkten Stelle weiter. */
  readonly fortsetzen: () => void;
}

/** Die Sprachausgabe des Browsers, oder `undefined` (Tests, jsdom). */
function ausgabe(): SpeechSynthesis | undefined {
  return typeof window === 'undefined' ? undefined : window.speechSynthesis;
}

/**
 * Sprachausgabe über die Systemstimmen.
 *
 * **Warum nicht auf die Stimmenliste gewartet wird.** `getVoices()` liefert
 * in Electron unter Windows eine leere Liste, und `voiceschanged` bleibt aus,
 * bis zum ersten Mal wirklich gesprochen wurde – nachgemessen: drei deutsche
 * Stimmen, aber erst nach dem ersten `speak()` sichtbar. Wer die Schaltfläche
 * von einer nicht leeren Liste abhängig macht, gibt sie deshalb nie frei. Die
 * Anwendung setzt darum nur `lang = "de-DE"` und überlässt die erste
 * Äußerung dem System; ab der zweiten ist die Liste da und eine ausdrücklich
 * deutsche Stimme wird gewählt.
 *
 * **Warum in Stücken gesprochen wird.** Chromium bricht eine Äußerung nach
 * etwa 15 Sekunden ab. Eine Erklärung dieser Anwendung ist deutlich länger,
 * verstummte also mitten im Satz. {@link inHaeppchen} zerlegt sie an
 * Satzgrenzen; die Stücke laufen als Warteschlange durch.
 *
 * **WCAG 1.4.2.** Was länger als drei Sekunden tönt, muss sich anhalten
 * lassen. Deshalb gibt es {@link Sprachausgabe.abbrechen} und `laeuft`, damit
 * die Oberfläche einen sichtbaren Ausschalter zeigen kann, solange gesprochen
 * wird.
 *
 * **Warum die Stelle mit übergeben wird.** Gemeldet von einem Nutzer: Wurde
 * eine Frage oder Rückmeldung vorgelesen und man ging weiter, redete die
 * Stimme die alte Stelle zu Ende, während die neue schon dastand. Man hörte
 * etwas anderes, als man sah – wer sich die Fragen vorlesen lässt, verliert
 * damit die Orientierung, und das ist keine Unschönheit, sondern der Zweck
 * der Funktion.
 *
 * Der Haken kann das nicht von selbst merken: Er kennt nur Text, nicht die
 * Ansicht. Deshalb nennt der Aufrufer die Stelle, zu der die Ausgabe gehört –
 * Fragennummer und Schritt. Wechselt sie, verstummt die Stimme, bevor
 * irgendetwas Neues beginnt.
 *
 * Bewusst im Haken und nicht in den beiden Ansichten: Lernsitzung und
 * Prüfungslauf hatten den Fehler beide, und zwei Umsetzungen derselben Regel
 * sind eine zu viel.
 *
 * @param kennung Die Stelle, zu der eine Ausgabe gehört. Ändert sie sich,
 *   wird abgebrochen. Ohne Angabe bricht nur das Verlassen der Ansicht ab.
 */
export function useVorlesen(
  aktiv: boolean,
  kennung?: string | number,
  tempo: number = SPRECHTEMPO_STANDARD,
): Sprachausgabe {
  /*
    Zwei Zähler statt eines Wahrheitswerts. `auftrag` zählt jede Anweisung
    mit – sprechen wie abbrechen –, `sprechend` merkt sich, zu welchem
    Auftrag die laufende Ausgabe gehört. Nur wenn beide übereinstimmen, läuft
    wirklich etwas.

    Das ist kein Selbstzweck: Wird die Sprachausgabe abgeschaltet oder die
    Ansicht verlassen, genügt es, `auftrag` weiterzuzählen und dem System das
    Abbrechen zu sagen – das `end`-Ereignis eines längst abgebrochenen
    Stücks kann dann nicht mehr fälschlich „fertig“ melden.
  */
  const auftrag = useRef(0);
  const [sprechend, setzeSprechend] = useState(0);

  /* Wird die Sprachausgabe abgeschaltet, verfällt die Anzeige sofort –
     angepasst beim Rendern statt in einem Effekt, wie React es für genau
     diesen Fall vorsieht. Ein Effekt bräuchte dafür eine zusätzliche
     Renderrunde, und das Feld stünde solange falsch da. */
  const [warAktiv, setzeWarAktiv] = useState(aktiv);
  if (warAktiv !== aktiv) {
    setzeWarAktiv(aktiv);
    if (sprechend !== 0) {
      setzeSprechend(0);
    }
  }

  /*
    ── Anhalten und Fortsetzen ────────────────────────────────────────────

    Naheliegend wäre `speechSynthesis.pause()` und `resume()`. Beides ist in
    Chromium unter Windows unzuverlässig – die Ausgabe bleibt gelegentlich
    stumm stehen und lässt sich nicht mehr aufwecken. Stattdessen wird das
    getan, was die Häppchen-Warteschlange ohnehin hergibt: abbrechen und sich
    merken, welches Stück gerade lief. „Fortsetzen“ reiht ab diesem Stück neu
    ein. Der Preis ist ein Sprung an den Anfang des laufenden Satzes; das ist
    beim Zuhören eher hilfreich als störend.
  */
  const stuecke = useRef<readonly string[]>([]);
  const stelle = useRef(0);
  const [angehalten, setzeAngehalten] = useState(false);

  const moeglich = ausgabe() !== undefined;
  const laeuft = aktiv && sprechend !== 0;

  const abbrechen = useCallback(() => {
    auftrag.current += 1;
    setzeSprechend(0);
    setzeAngehalten(false);
    stuecke.current = [];
    ausgabe()?.cancel();
  }, []);

  /** Spricht die gemerkten Stücke ab `ab`. Der gemeinsame Weg beider Wege. */
  const sprichStuecke = useCallback(
    (ab: number) => {
      const stimmwerk = ausgabe();
      const liste = stuecke.current;
      if (!stimmwerk || !aktiv || ab >= liste.length) {
        return;
      }

      stimmwerk.cancel();
      auftrag.current += 1;
      const meiner = auftrag.current;
      setzeSprechend(meiner);
      setzeAngehalten(false);

      /* Erst nach dem ersten `speak()` ist die Liste gefüllt (siehe oben).
         Beim allerersten Aufruf bleibt es deshalb bei `lang`. */
      const stimme = stimmeWaehlen(stimmwerk.getVoices());

      /* Nur der jüngste Auftrag darf „fertig“ melden: Das `end`-Ereignis
         eines gerade abgebrochenen Stücks träfe sonst die neue Ausgabe. Der
         Vergleich steht in der Rückruffunktion – beim Rendern wäre der
         Zugriff auf die Referenz nicht zulässig. */
      const fertig = (): void => {
        if (auftrag.current === meiner) {
          setzeSprechend(0);
          stuecke.current = [];
        }
      };

      for (let index = ab; index < liste.length; index++) {
        const aeusserung = new SpeechSynthesisUtterance(liste[index] ?? '');
        aeusserung.lang = 'de-DE';
        aeusserung.rate = sprechtempoBereinigen(tempo);
        if (stimme) {
          aeusserung.voice = stimme;
        }

        /* Woran das Fortsetzen hängt: Jedes Stück meldet beim Beginn seine
           Nummer. Ohne das wüsste „Fortsetzen“ nur, dass etwas lief. */
        aeusserung.onstart = () => {
          if (auftrag.current === meiner) {
            stelle.current = index;
          }
        };

        if (index === liste.length - 1) {
          aeusserung.onend = fertig;
        }
        aeusserung.onerror = (ereignis) => {
          /* „interrupted“ und „canceled“ sind der Normalfall beim Abbrechen
             und keine Störung. */
          if (ereignis.error !== 'interrupted' && ereignis.error !== 'canceled') {
            console.warn('[vorlesen] Sprachausgabe gescheitert:', ereignis.error);
          }
          fertig();
        };

        stimmwerk.speak(aeusserung);
      }
    },
    [aktiv, tempo],
  );

  const sprich = useCallback(
    (text: string) => {
      const zerlegt = inHaeppchen(fuerSprachausgabe(text));
      if (zerlegt.length === 0) {
        return;
      }
      stuecke.current = zerlegt;
      stelle.current = 0;
      sprichStuecke(0);
    },
    [sprichStuecke],
  );

  const anhalten = useCallback(() => {
    if (!laeuft) {
      return;
    }
    auftrag.current += 1;
    setzeSprechend(0);
    setzeAngehalten(true);
    ausgabe()?.cancel();
  }, [laeuft]);

  const fortsetzen = useCallback(() => {
    sprichStuecke(stelle.current);
  }, [sprichStuecke]);

  /* Abschalten muss sofort still sein – eine Stimme, die nach dem Ausschalten
     weiterredet, ist genau die Barriere, die 1.4.2 verhindern soll. */
  useEffect(() => {
    if (!aktiv) {
      auftrag.current += 1;
      ausgabe()?.cancel();
    }
  }, [aktiv]);

  /*
    Ebenso beim Verlassen der Ansicht – und beim Wechsel der Stelle.

    Beides in einem Aufräumer, weil es dieselbe Regel ist: Was gesprochen
    wird, gehört zu dem, was zu sehen ist; verschwindet das eine, hat das
    andere zu schweigen. Der Aufräumer läuft, bevor die Effekte der neuen
    Runde starten – die Ansage der nächsten Stelle beginnt also nach dem
    Abbruch, nicht davor.
  */
  useEffect(
    () => () => {
      auftrag.current += 1;
      setzeSprechend(0);
      setzeAngehalten(false);
      stuecke.current = [];
      ausgabe()?.cancel();
    },
    [kennung],
  );

  return { moeglich, laeuft, angehalten, sprich, abbrechen, anhalten, fortsetzen };
}
