/**
 * Einordnung eines Simulationslaufs gegenüber den früheren – reine Funktionen.
 *
 * Bis Fassung 0.22.0 stand jeder Lauf für sich: Die Auswertung nannte die
 * Zahlen des Tages, die Verlaufstabelle listete die alten, und den Vergleich
 * musste der Lernende im Kopf anstellen. Genau daran hängt aber die Frage,
 * die vor einer Prüfung zählt – geht es aufwärts, und wo hakt es immer wieder?
 *
 * **Was hier nicht geschieht.** Es wird nichts vorhergesagt. Jede Zahl ist
 * gemessene Vergangenheit; die abgelehnte Bestehenswahrscheinlichkeit bleibt
 * abgelehnt (siehe `docs/entscheidung-prognose.md`). Und nichts davon wirkt
 * auf den Lernstand zurück: Simulationsergebnisse justieren den Reifegrad
 * nicht nach (`docs/entscheidung-reifegrad.md`, Abschnitt 8).
 *
 * **Warum die Vorbehalte mitkommen.** `quote` zählt unbeantwortete Fragen wie
 * falsch beantwortete. Ein Lauf, in dem die Zeit ablief, fällt dadurch tief –
 * nicht weil das Wissen einbrach, sondern weil der Bogen nicht fertig wurde.
 * Ein Vergleichssatz, der das verschweigt, ist schlimmer als keiner. Deshalb
 * liefert {@link laufVergleichen} die Einschränkungen als eigene Sätze mit,
 * und die Oberfläche gibt sie unverändert aus.
 */

import type { Pruefungsverlauf, Zeitmodus } from './pruefung';

/**
 * Ab so vielen Fragen im Bereich ist eine Bereichsquote überhaupt eine
 * Auskunft. Bei zwei Fragen liegt man mit einem Fehler bei 50 Prozent – das
 * ist Zufall und kein Befund.
 */
const MINDEST_FRAGEN = 3;

/**
 * So viele Läufe fließen in die Suche nach wiederkehrenden Schwächen ein.
 *
 * Nicht alle: Wer im Frühjahr begann, hat den Anfang längst hinter sich, und
 * ein Bereich, der damals wackelte, ist keine „wiederkehrende Schwäche“ mehr.
 */
const BETRACHTETE_LAEUFE = 10;

/** So oft muss ein Bereich unter der Grenze gelegen haben – zweimal ist das Mindeste. */
const MINDESTENS_UNTER_GRENZE = 2;

/** Mehr wären keine Schwerpunkte mehr, sondern eine zweite Themenanalyse. */
const HOECHSTENS_BEREICHE = 5;

/** Unterhalb dieser Abweichung ist ein Dauerunterschied Rauschen, kein Befund. */
const DAUER_SCHWELLE_MS = 30_000;

const MINUTE_MS = 60_000;

/** Die Kennzahlen des eben abgeschlossenen Laufs, wie der Vergleich sie braucht. */
export interface Laufkennzahlen {
  /** ID des Prüfungsprofils, z. B. „dsb“. */
  readonly profilId: string;
  readonly zeitmodus: Zeitmodus;
  readonly gesamt: number;
  readonly quote: number;
  readonly dauerMs: number;
  readonly unbeantwortet: number;
  readonly zeitAbgelaufen: boolean;
  /**
   * Kennung der gespeicherten Verlaufszeile, falls der Lauf gespeichert wurde.
   *
   * Der Verlauf enthält den eben beendeten Lauf bereits – er wird vor dem
   * Laden geschrieben. Ohne diesen Ausschluss vergliche die Anwendung ihn mit
   * sich selbst und meldete jedem Prüfling ein makelloses „gleichauf“.
   */
  readonly laufId?: number;
}

export interface Laufvergleich {
  /** Der Lauf, mit dem verglichen wurde. */
  readonly frueher: Pruefungsverlauf;
  /** Unterschied der Trefferquote in Prozentpunkten; positiv heißt besser. */
  readonly punkte: number;
  /** Unterschied der Dauer in Minuten; positiv heißt schneller. `null` bei Gleichstand. */
  readonly minuten: number | null;
  /** Der fertige Satz. Die Oberfläche formuliert nichts nach. */
  readonly satz: string;
  /** Was am Vergleich nicht stimmt, in fertigen Sätzen. Leer, wenn nichts dagegen spricht. */
  readonly vorbehalte: readonly string[];
}

function anzahl(wert: number, eins: string, mehrere: string): string {
  return `${String(wert)} ${wert === 1 ? eins : mehrere}`;
}

/**
 * Sucht den jüngsten vergleichbaren Lauf und ordnet den aktuellen ein.
 *
 * Vergleichbar heißt: **dasselbe Prüfungsprofil und dieselbe Zeitstufe.** Ein
 * Bogen ohne Uhr und einer unter Zeitdruck sind zwei verschiedene Aufgaben;
 * sie nebeneinanderzustellen hieße, den Nachteilsausgleich als Fortschritt zu
 * verbuchen. Läufe ohne vermerkte Zeitstufe (vor Schema-Version 4) scheiden
 * damit aus – bei ihnen ist nicht bekannt, was zutrifft.
 *
 * `datumText` wird hereingereicht, weil das Datumsformat der Oberfläche
 * gehört: `Intl` in einer Vertragsdatei wäre eine Abhängigkeit, die hier
 * nichts zu suchen hat.
 *
 * @returns `null`, wenn es keinen vergleichbaren Lauf gibt – dann sagt die
 * Anwendung nichts, statt etwas Schiefes zu sagen.
 */
/**
 * Wie weit zwei Läufe allein durch die Ziehung auseinanderfallen.
 *
 * **Warum es diese Zahl braucht.** Ein Bogen zieht 75 bis 100 Fragen aus 575.
 * Zwei Läufe messen deshalb nie denselben Bestand, sondern zwei Stichproben
 * daraus. Auch wer zwischen den Läufen nichts gelernt und nichts vergessen
 * hat, bekommt zwei verschiedene Quoten. Ohne diese Zahl liest sich jeder
 * Unterschied als Fortschritt oder Rückschritt – auch der, der keiner ist.
 *
 * **Die Rechnung.** Zurückgegeben wird die Standardabweichung des
 * Unterschieds zweier unabhängiger Bögen, in Prozentpunkten:
 * `sqrt(p·(1−p)/n₁ + p·(1−p)/n₂)`, mit `p` als gemeinsamer Quote beider
 * Läufe.
 *
 * **Warum die binomiale Formel hier trägt, obwohl der Reifegrad sie
 * ausschlägt.** `docs/entscheidung-reifegrad.md` Abschnitt 5 lehnt eine
 * Bestehenswahrscheinlichkeit ab, weil die Trefferchancen je Frage
 * zweigipflig verteilt sind – ungesehene bei null, gefestigte bei 0,9. Das
 * ist richtig und betrifft eine **Prognose** über künftige Bögen. Hier geht
 * es um die bereits gezogene Stichprobe, und dort hebt sich die
 * Zweigipfligkeit auf: Eine zufällig gezogene Frage zu beantworten ist selbst
 * ein Bernoulli-Versuch mit der mittleren Trefferchance. Am 01.09.2026 mit
 * 200 000 Läufen gegen die in der Notiz beschriebene Verteilung nachgerechnet
 * – bei 85, 70 und 50 Prozent gefestigter Fragen lag die gemessene Streuung
 * jedes Mal **unter** der binomialen. Die Endlichkeitskorrektur
 * `(N−n)/(N−1)` bliebe zusätzlich draußen; sie machte die Zahl kleiner. Diese
 * Schätzung ist also die vorsichtige Seite, und das ist die richtige Seite.
 */
export function streuungPunkte(gesamtA: number, quoteA: number, gesamtB: number, quoteB: number) {
  const p = (quoteA * gesamtA + quoteB * gesamtB) / (gesamtA + gesamtB);
  return Math.sqrt((p * (1 - p)) / gesamtA + (p * (1 - p)) / gesamtB) * 100;
}

export function laufVergleichen(
  aktuell: Laufkennzahlen,
  verlauf: readonly Pruefungsverlauf[],
  datumText: (iso: string) => string,
): Laufvergleich | null {
  const frueher = verlauf.find(
    (eintrag) =>
      eintrag.id !== aktuell.laufId &&
      eintrag.profilId === aktuell.profilId &&
      eintrag.zeitmodus === aktuell.zeitmodus,
  );
  if (frueher === undefined) {
    return null;
  }

  const punkte = Math.round((aktuell.quote - frueher.quote) * 100);
  const dauerDelta = frueher.dauerMs - aktuell.dauerMs;
  /* Aufgerundet auf mindestens eine Minute: Oberhalb der Schwelle etwas als
     „0 Minuten schneller“ auszuweisen wäre ein Widerspruch in sich. */
  const minuten =
    Math.abs(dauerDelta) < DAUER_SCHWELLE_MS
      ? null
      : Math.sign(dauerDelta) * Math.max(1, Math.round(Math.abs(dauerDelta) / MINUTE_MS));

  const quotenteil =
    punkte === 0
      ? 'gleichauf in der Trefferquote'
      : `${anzahl(Math.abs(punkte), 'Prozentpunkt', 'Prozentpunkte')} ${punkte > 0 ? 'besser' : 'schlechter'}`;
  const dauerteil =
    minuten === null
      ? 'bei praktisch gleicher Bearbeitungsdauer'
      : `${anzahl(Math.abs(minuten), 'Minute', 'Minuten')} ${minuten > 0 ? 'schneller' : 'langsamer'}`;

  const vorbehalte: string[] = [];
  if (aktuell.unbeantwortet > 0) {
    vorbehalte.push(
      `Diesmal blieben ${anzahl(aktuell.unbeantwortet, 'Frage', 'Fragen')} unbeantwortet` +
        `${aktuell.zeitAbgelaufen ? ', weil die Zeit ablief' : ''}.`,
    );
  }
  if (frueher.unbeantwortet === null) {
    vorbehalte.push(
      'Zu jenem Lauf wurde nicht festgehalten, wie viele Fragen unbeantwortet blieben – ' +
        'er stammt aus einer früheren Programmfassung.',
    );
  } else if (frueher.unbeantwortet > 0) {
    vorbehalte.push(
      `Damals blieben ${anzahl(frueher.unbeantwortet, 'Frage', 'Fragen')} unbeantwortet` +
        `${frueher.zeitAbgelaufen === true ? ', weil die Zeit ablief' : ''}.`,
    );
  }
  /* Der Satz, um den es geht: Ohne ihn liest sich ein abgebrochener Lauf wie
     ein Wissenseinbruch. Er steht genau dann da, wenn er zutrifft. */
  if (aktuell.unbeantwortet > 0 || (frueher.unbeantwortet ?? 0) > 0) {
    vorbehalte.push(
      'Unbeantwortete Fragen zählen in der Trefferquote wie falsch beantwortete. ' +
        'Ein Lauf, in dem die Zeit ablief, sieht deshalb nach einem Einbruch aus, ' +
        'auch wenn nur der Bogen nicht fertig wurde.',
    );
  }
  if (frueher.gesamt !== aktuell.gesamt) {
    vorbehalte.push(
      `Der Bogen hatte damals ${anzahl(frueher.gesamt, 'Frage', 'Fragen')}, dieser ` +
        `${anzahl(aktuell.gesamt, 'Frage', 'Fragen')}.`,
    );
  }
  /* Der Unterschied liegt unter dem, was allein die Ziehung erzeugt. Ohne
     diesen Satz behauptet der Vergleich einen Fortschritt, den er nicht
     gemessen hat – und der Prüfling richtet sein Lernen danach aus. */
  const streuung = streuungPunkte(aktuell.gesamt, aktuell.quote, frueher.gesamt, frueher.quote);
  if (punkte !== 0 && Math.abs(punkte) <= streuung) {
    const fragen = Math.round((Math.abs(punkte) / 100) * aktuell.gesamt);
    vorbehalte.push(
      `Der Unterschied von ${anzahl(Math.abs(punkte), 'Prozentpunkt', 'Prozentpunkten')} ` +
        `entspricht ${anzahl(fragen, 'Frage', 'Fragen')}. Zwei Bögen aus demselben Bestand ` +
        `fallen schon durch die Ziehung um rund ` +
        `${anzahl(Math.round(streuung), 'Prozentpunkt', 'Prozentpunkte')} auseinander, auch ` +
        `bei unverändertem Wissensstand – dieser Unterschied liegt darunter und trägt für ` +
        `sich genommen keine Aussage.`,
    );
  }

  return {
    frueher,
    punkte,
    minuten,
    satz:
      `Gegenüber Ihrer letzten Simulation mit demselben Profil und derselben Zeitvorgabe ` +
      `(${datumText(frueher.zeitpunkt)}): ${quotenteil}, ${dauerteil}.`,
    vorbehalte,
  };
}

/** Ein Bereich, der in mehreren Läufen unter der Bestehensgrenze lag. */
export interface Schwachstelle {
  /** Abschnitts- oder Kapitel-ID, z. B. „I.4“. */
  readonly bereich: string;
  readonly titel: string;
  /** Läufe, in denen der Bereich mit genügend Fragen vorkam. */
  readonly laeufe: number;
  /** Davon die, in denen er unter der Bestehensgrenze lag. */
  readonly unterGrenze: number;
  /** Fragen dieses Bereichs über die gezählten Läufe hinweg. */
  readonly gesamt: number;
  readonly richtig: number;
}

export interface Schwaechenbefund {
  /** Läufe, die überhaupt Bereichsergebnisse mitbringen – die Grundlage der Aussage. */
  readonly grundlage: number;
  readonly bereiche: readonly Schwachstelle[];
}

interface Sammler {
  titel: string;
  laeufe: number;
  unterGrenze: number;
  gesamt: number;
  richtig: number;
}

/**
 * Bereiche, die über mehrere Simulationen hinweg unter der Bestehensgrenze
 * lagen.
 *
 * **Warum an der Bestehensgrenze gemessen wird.** Eine eigene Grenze je
 * Bereich gibt es nicht; die Prüfungsordnungen kennen nur eine Gesamtgrenze
 * und – bei manchen Trägern – Zusatzbedingungen für einzelne Bereiche. Die
 * Gesamtgrenze des jeweiligen Laufs ist deshalb der einzige Maßstab, der
 * nicht erfunden ist. Die Oberfläche sagt das dazu.
 *
 * **Warum zweimal das Mindeste ist.** Einmal daneben ist ein Tag, zweimal ist
 * ein Muster. Und Bereiche mit weniger als {@link MINDEST_FRAGEN} Fragen im
 * Bogen bleiben ganz außen vor: Bei zwei Fragen entscheidet ein Fehler über
 * 50 Prozentpunkte.
 *
 * Läufe ohne gespeicherte Bereichsergebnisse (vor Schema-Version 10) zählen
 * nicht mit – auch nicht als Grundlage. Sie fehlen der Aussage, und `grundlage`
 * sagt der Oberfläche, wie viele Läufe wirklich dahinterstehen.
 */
export function wiederkehrendeSchwaechen(verlauf: readonly Pruefungsverlauf[]): Schwaechenbefund {
  const brauchbar = verlauf
    .filter((eintrag) => eintrag.bereiche !== null && eintrag.bestehensQuote !== null)
    .slice(0, BETRACHTETE_LAEUFE);

  const sammler = new Map<string, Sammler>();

  for (const lauf of brauchbar) {
    const grenze = lauf.bestehensQuote ?? 0;
    for (const bereich of lauf.bereiche ?? []) {
      if (bereich.gesamt < MINDEST_FRAGEN) {
        continue;
      }
      let eintrag = sammler.get(bereich.bereich);
      if (eintrag === undefined) {
        /* Der Titel des jüngsten Laufs gewinnt: Die Liste ist neueste zuerst,
           und benennt der Katalog einen Abschnitt um, ist die neue Fassung
           die richtige. */
        eintrag = { titel: bereich.titel, laeufe: 0, unterGrenze: 0, gesamt: 0, richtig: 0 };
        sammler.set(bereich.bereich, eintrag);
      }
      eintrag.laeufe += 1;
      eintrag.gesamt += bereich.gesamt;
      eintrag.richtig += bereich.richtig;
      if (bereich.richtig / bereich.gesamt < grenze) {
        eintrag.unterGrenze += 1;
      }
    }
  }

  const bereiche = [...sammler.entries()]
    .filter(([, werte]) => werte.unterGrenze >= MINDESTENS_UNTER_GRENZE)
    .map(([bereich, werte]) => ({ bereich, ...werte }))
    /* Schwächster zuerst, bei Gleichstand der häufiger auffällige; die
       Bereichs-ID bricht den Rest, damit die Reihenfolge feststeht. */
    .sort(
      (a, b) =>
        a.richtig / a.gesamt - b.richtig / b.gesamt ||
        b.unterGrenze - a.unterGrenze ||
        a.bereich.localeCompare(b.bereich, 'de'),
    )
    .slice(0, HOECHSTENS_BEREICHE);

  return { grundlage: brauchbar.length, bereiche };
}

/**
 * Die Schwellen, an denen die Oberfläche ihre Beschriftung ausrichtet.
 *
 * Ausdrücklich nicht doppelt gepflegt: Stünde „mindestens drei Fragen“ als
 * Text im Bauteil, liefe es beim nächsten Wert still auseinander.
 */
export const VERGLEICH_GRENZEN = Object.freeze({
  mindestFragen: MINDEST_FRAGEN,
  betrachteteLaeufe: BETRACHTETE_LAEUFE,
  mindestensUnterGrenze: MINDESTENS_UNTER_GRENZE,
  hoechstensBereiche: HOECHSTENS_BEREICHE,
});
