/**
 * Prüfungssimulation.
 *
 * Die AWaffV schreibt weder Fragenzahl noch Zeit oder Bestehensgrenze vor –
 * die reale Prüfung unterscheidet sich je nach Prüfungsstelle erheblich.
 * Deshalb gibt es hier Profile statt eines festen Modus. Die Werte stammen aus
 * veröffentlichten Prüfungsordnungen (siehe PLAN.md, Abschnitt 2.1).
 */

import type { Fragetyp } from './katalog';

/** Wie das Bestehen ermittelt wird. */
export type Wertungsart =
  /** Anteil richtiger Antworten muss die Grenze erreichen. */
  | 'quote'
  /** Fehlerpunkte dürfen eine Obergrenze nicht überschreiten. */
  | 'fehlerpunkte';

/**
 * Zusatzbedingung, die unabhängig vom Gesamtergebnis zum Nichtbestehen führt.
 * Manche Träger werten etwa mehr als zwei Fehler bei Notwehr und Notstand als
 * nicht bestanden, auch wenn die Gesamtquote stimmt.
 */
export interface KoKriterium {
  /** Abschnitts- oder Kapitel-ID, z. B. „I.5“. */
  readonly bereich: string;
  readonly bezeichnung: string;
  readonly maxFehler: number;
}

export interface Pruefungsprofil {
  readonly id: string;
  readonly name: string;
  /** Kurze Erläuterung, woher die Werte stammen. */
  readonly beschreibung: string;
  readonly fragenAnzahl: number;
  readonly wertung: Wertungsart;
  /** Bei `quote`: nötiger Anteil richtiger Antworten (0 bis 1). */
  readonly bestehensQuote?: number;
  /** Bei `fehlerpunkte`: höchstzulässige Fehlerzahl. */
  readonly maxFehler?: number;
  /** Vorgeschlagenes Zeitlimit in Minuten; `null` bedeutet ohne Zeitlimit. */
  readonly zeitMinuten: number | null;
  /**
   * Untergrenze einer Grauzone: Wer darüber, aber unter der Bestehensgrenze
   * liegt, wird bei manchen Trägern mündlich nachgeprüft.
   */
  readonly nachpruefungAb?: number;
  /** Anteil offener Fragen (0 bis 1); der Rest ist Multiple Choice. */
  readonly anteilOffen?: number;
  /** Feste Fragenzahl je Bereich, wenn der Träger Themenquoten vorgibt. */
  readonly themenquoten?: Readonly<Record<string, number>>;
  readonly koKriterien?: readonly KoKriterium[];
  /** Frei einstellbares Profil – die Werte dürfen verändert werden. */
  readonly anpassbar?: boolean;
}

/**
 * Vorgegebene Profile.
 *
 * Wichtig: Keines davon ist „die“ amtliche Prüfung. Die Software weist darauf
 * hin, dass allein der zuständige Prüfungsausschuss entscheidet.
 */
export const PRUEFUNGSPROFILE: readonly Pruefungsprofil[] = Object.freeze([
  Object.freeze({
    id: 'standard',
    name: 'Standard',
    beschreibung:
      '80 Fragen, 120 Minuten, 80 Prozent zum Bestehen. Verbreiteter Zuschnitt, ' +
      'wie ihn auch gängige Online-Trainer verwenden.',
    fragenAnzahl: 80,
    wertung: 'quote',
    bestehensQuote: 0.8,
    zeitMinuten: 120,
  }),
  Object.freeze({
    id: 'dsb',
    name: 'Nach Art des Deutschen Schützenbundes',
    beschreibung:
      '100 Fragen in festen Themenblöcken, 120 Minuten, 75 Prozent zum Bestehen. ' +
      'Zwischen 60 und 74 Prozent ist eine mündliche Nachprüfung vorgesehen. ' +
      'Höchstens 80 Prozent Multiple Choice, der Rest ist auszuformulieren.',
    fragenAnzahl: 100,
    wertung: 'quote',
    bestehensQuote: 0.75,
    nachpruefungAb: 0.6,
    zeitMinuten: 120,
    anteilOffen: 0.2,
    themenquoten: Object.freeze({
      'I.1': 10,
      'I.2': 20,
      'I.3': 10,
      'I.4': 10,
      'I.5': 10,
      II: 20,
      III: 10,
      IV: 10,
    }),
  }),
  Object.freeze({
    id: 'bdmp',
    name: 'Nach Art des BDMP',
    beschreibung:
      'Fragen aus dem amtlichen Katalog, bis 120 Minuten, 70 Prozent zum Bestehen ' +
      'laut Prüfungsordnung des Verbands.',
    fragenAnzahl: 80,
    wertung: 'quote',
    bestehensQuote: 0.7,
    zeitMinuten: 120,
  }),
  Object.freeze({
    id: 'fehlerpunkte',
    name: 'Nach Art privater Lehrgangsträger',
    beschreibung:
      '75 Fragen mit Fehlerpunktegrenze statt Quote. Mehr als zwei Fehler bei ' +
      'Notwehr und Notstand führen bei manchen Trägern unabhängig vom ' +
      'Gesamtergebnis zum Nichtbestehen.',
    fragenAnzahl: 75,
    wertung: 'fehlerpunkte',
    maxFehler: 15,
    zeitMinuten: 90,
    anteilOffen: 0.15,
    koKriterien: Object.freeze([
      Object.freeze({ bereich: 'I.5', bezeichnung: 'Notwehr und Notstand', maxFehler: 2 }),
    ]),
  }),
  Object.freeze({
    id: 'frei',
    name: 'Selbst einstellen',
    beschreibung: 'Fragenzahl, Zeit, Bestehensgrenze und Anteil offener Fragen frei wählen.',
    fragenAnzahl: 40,
    wertung: 'quote',
    bestehensQuote: 0.75,
    zeitMinuten: null,
    anpassbar: true,
  }),
]);

/** Wie das Zeitlimit gehandhabt wird (WCAG 2.2.1 – Timing Adjustable). */
export type Zeitmodus =
  /** Ohne Zeitbegrenzung – immer verfügbar, auch in der Simulation. */
  | 'aus'
  /** Vorgabe des Profils. */
  | 'normal'
  /** Vorgabe plus 25 Prozent – üblicher Nachteilsausgleich. */
  | 'plus25'
  /** Vorgabe plus 50 Prozent. */
  | 'plus50';

export const ZEITMODUS_BEZEICHNUNG: Readonly<Record<Zeitmodus, string>> = Object.freeze({
  aus: 'Ohne Zeitbegrenzung',
  normal: 'Vorgesehene Zeit',
  plus25: 'Vorgesehene Zeit plus 25 Prozent',
  plus50: 'Vorgesehene Zeit plus 50 Prozent',
});

/** Errechnet die tatsächliche Bearbeitungszeit in Minuten. */
export function zeitInMinuten(profil: Pruefungsprofil, modus: Zeitmodus): number | null {
  if (modus === 'aus' || profil.zeitMinuten === null) {
    return null;
  }
  const faktor = modus === 'plus25' ? 1.25 : modus === 'plus50' ? 1.5 : 1;
  return Math.round(profil.zeitMinuten * faktor);
}

/** Einstellungen eines konkreten Simulationslaufs. */
export interface Pruefungsauftrag {
  readonly profilId: string;
  readonly zeitmodus: Zeitmodus;
  /** Überschreibt die Profilwerte, nur bei anpassbaren Profilen. */
  readonly fragenAnzahl?: number;
  readonly bestehensQuote?: number;
  readonly zeitMinuten?: number | null;
  /**
   * Anteil offener Fragen (0 bis 1), nur bei anpassbaren Profilen.
   *
   * Ein **Wunsch**, keine Zusage: Der Katalog hat 104 offene Fragen von 575,
   * und sie liegen ungleich – Kapitel III hat eine einzige von 49. Was sich
   * nicht ziehen lässt, meldet der Anwendungskern als Warnung, statt es still
   * durch Auswahlfragen zu ersetzen.
   */
  readonly anteilOffen?: number;
  /**
   * Kapitel, aus denen in dieser Simulation keine Frage vorkommt.
   *
   * Vorbelegt aus dem Lernprofil (`profil.kapitel_ausschluss`), je Simulation
   * aber änderbar. Die Vorbelegung ist keine Bequemlichkeit: Erststart-Frage
   * und Schalter unter „Ihr Lernplan“ sagen zu, dass abgewählte Fragen „in
   * keiner Sitzung und in keiner Zahl mehr“ vorkommen. Eine Simulation, die
   * sie ungefragt wieder mitzieht, bricht diese Zusage.
   */
  readonly kapitelAusschluss?: readonly string[];
  /**
   * Antwortmöglichkeiten mischen. Vorgabe ist `false`.
   *
   * Gilt auch hier und nicht nur im Lernmodus: Wer die Antworten über ihre
   * Stelle behalten muss, braucht das gerade in der Simulation – sonst
   * prüft sie eine Beeinträchtigung mit, nicht den Stoff.
   */
  readonly optionenMischen?: boolean;
}

/** Eine Frage im Prüfungsbogen. */
export interface Pruefungsfrage {
  readonly frageId: string;
  readonly optionsReihenfolge: readonly string[];
}

/**
 * Ein fertig zusammengestellter Bogen samt der Abweichungen dabei.
 *
 * `warnungen` ist der Grund für diesen eigenen Typ. Beim Ziehen kann der
 * Anwendungskern von den Vorgaben abweichen: ein Themenbereich hat zu wenige
 * Fragen, der Bogen fällt kürzer aus als das Profil vorsieht, ein festes
 * Profil nimmt eine Überschreibung nicht an, oder es fehlten offene Fragen und
 * wurde mit Auswahlfragen aufgefüllt. Bis Fassung 0.20.0 gingen diese vier
 * Meldungen ausschließlich über `console.warn` in das Protokoll des
 * Hauptprozesses – wer simulierte, hielt seinen Bogen für profilgetreu.
 *
 * Sie gehören an den Bildschirm, und zwar in fertigen Sätzen ohne Feldnamen:
 * Die Oberfläche gibt sie unverändert aus und formuliert nichts nach.
 */
export interface Pruefungsbogen {
  readonly fragen: readonly Pruefungsfrage[];
  /** Leer, wenn der Bogen genau den Vorgaben entspricht. */
  readonly warnungen: readonly string[];
}

/**
 * Ablaufphase eines Laufs, soweit sie sich sichern lässt.
 *
 * „abgabefrage" wird auf „bearbeiten" abgebildet – eine offene Rückfrage
 * gehört nicht wiederhergestellt. „auswerten" wird nie gesichert: Ab dort
 * gehört der Lauf der Auswertung.
 */
export type GesichertePhase = 'bearbeiten' | 'nachbewertung';

/** Eingaben zu einer Frage, wie sie ein unterbrochener Lauf festhält. */
export interface GesicherteEingabe {
  readonly auswahl: readonly string[];
  readonly freitext: string;
  /**
   * Selbstbewertung einer offenen Frage.
   *
   * Fehlt, solange nicht bewertet wurde – dreiwertig mit Absicht: „noch
   * nicht bewertet" ist etwas anderes als „als falsch bewertet". Ein
   * Ersatzwert `false` schriebe eine unbewertete Frage beim Fortsetzen als
   * falsch fest.
   */
  readonly selbst?: boolean;
}

/** Was der Renderer an einem laufenden Bogen sichert – und nur das. */
export interface Zwischenstand {
  readonly eingaben: Readonly<Record<string, GesicherteEingabe>>;
  readonly position: number;
  readonly phase: GesichertePhase;
  readonly zeitAbgelaufen: boolean;
  /**
   * Verbrauchte Bearbeitungszeit in Millisekunden.
   *
   * Stets `Date.now() - startMs`, nie ein nebenher hochgezählter Sammelwert:
   * Restzeit und Bearbeitungsdauer müssen aus derselben Rechnung stammen,
   * sonst laufen Anzeige und Wertung auseinander.
   */
  readonly verbrauchtMs: number;
}

/**
 * Ein unterbrochener Lauf, wie ihn die Oberfläche zum Fortsetzen bekommt.
 *
 * `auftrag`, `profil` und `bogen` stammen aus dem Anwendungskern, nicht aus
 * dem Renderer – sie wurden beim Starten dort abgelegt.
 */
export interface OffenerLauf {
  readonly auftrag: Pruefungsauftrag;
  /** Das wirksame Profil des Laufs, mit den tatsächlich gewählten Werten. */
  readonly profil: Pruefungsprofil;
  readonly bogen: readonly Pruefungsfrage[];
  readonly eingaben: Readonly<Record<string, GesicherteEingabe>>;
  readonly position: number;
  readonly phase: GesichertePhase;
  readonly zeitAbgelaufen: boolean;
  readonly verbrauchtMs: number;
  readonly begonnenAm: string;
  readonly gesichertAm: string;
}

/** Antwort auf eine Prüfungsfrage. */
export interface Pruefungsantwort {
  readonly frageId: string;
  readonly auswahl: readonly string[];
  readonly freitext?: string;
  /** Bei offenen Fragen bewertet der Prüfling selbst. */
  readonly selbstAlsRichtig?: boolean;
}

/** Ergebnis je Bereich, für die Themenanalyse der Auswertung. */
export interface BereichErgebnis {
  readonly bereich: string;
  readonly titel: string;
  readonly gesamt: number;
  readonly richtig: number;
}

export type Bestehensurteil = 'bestanden' | 'nachpruefung' | 'nicht_bestanden';

export interface Pruefungsergebnis {
  readonly profilId: string;
  readonly gesamt: number;
  readonly richtig: number;
  /**
   * Beantwortet, aber nicht richtig.
   *
   * Die drei Zahlen `richtig`, `falsch` und `unbeantwortet` bilden eine
   * Zerlegung von `gesamt` – sie summieren sich also darauf und überschneiden
   * sich nicht. Die Auswertung zeigt sie nebeneinander; würden die
   * unbeantworteten zusätzlich in `falsch` stecken, ergäbe die Anzeige mehr
   * Fragen als der Bogen hat.
   */
  readonly falsch: number;
  readonly unbeantwortet: number;
  /** Anteil richtiger Antworten, 0 bis 1. */
  readonly quote: number;
  readonly urteil: Bestehensurteil;
  /** Begründung in einem Satz, für die Anzeige. */
  readonly begruendung: string;
  /** Ausgelöste K.-o.-Kriterien, falls vorhanden. */
  readonly verletzteKriterien: readonly string[];
  readonly bereiche: readonly BereichErgebnis[];
  /**
   * IDs aller nicht richtig beantworteten Fragen – für „Fehler wiederholen“.
   * Anders als {@link falsch} zählen hier die unbeantworteten mit: Wer sie
   * nie gesehen hat, soll sie üben können.
   */
  readonly fehlerIds: readonly string[];
  readonly dauerMs: number;
  readonly zeitAbgelaufen: boolean;
  /** Zeitpunkt als ISO-Zeichenkette. */
  readonly zeitpunkt: string;
  /**
   * Kennung der gespeicherten Verlaufszeile.
   *
   * Fehlt, wenn der Lauf **nicht** gespeichert wurde – das ist der Fall der
   * Ersatzauswertung im Renderer, wenn der Kanal ausfällt
   * (`renderer/src/pruefung/bewertung.ts`). Eine erfundene Kennung wäre dort
   * schlimmer als keine: Der Vergleich mit früheren Läufen schließt den
   * aktuellen über genau diese Kennung aus und würde sonst den falschen
   * ausschließen.
   */
  readonly laufId?: number;
}

/** Ein gespeicherter Simulationslauf für die Verlaufsanzeige. */
export interface Pruefungsverlauf {
  readonly id: number;
  readonly profilId: string;
  readonly profilName: string;
  readonly zeitpunkt: string;
  readonly gesamt: number;
  readonly richtig: number;
  readonly quote: number;
  readonly urteil: Bestehensurteil;
  /** Gemessene Bearbeitungsdauer in Millisekunden. */
  readonly dauerMs: number;
  /**
   * Gewählte Zeitstufe des Laufs.
   *
   * `null` bei Läufen aus der Zeit vor Schema-Version 4: Sie ist dort nicht
   * mehr zu ermitteln, und sie zu raten wäre schlechter, als sie offen zu
   * lassen. Ohne diese Angabe stünden ein Lauf ohne Uhr und einer unter
   * Zeitdruck in der Tabelle nebeneinander, als wären sie vergleichbar.
   */
  readonly zeitmodus: Zeitmodus | null;
  /**
   * Wie viele Fragen des Bogens unbeantwortet blieben.
   *
   * `null` bei Läufen vor Schema-Version 10. Der Wert ist der Grund, warum es
   * diese Fassung gibt: `quote` zählt Unbeantwortete wie falsch beantwortete,
   * und ein Lauf, in dem die Zeit ablief, sähe im Vergleich sonst wie ein
   * Wissenseinbruch aus.
   */
  readonly unbeantwortet: number | null;
  /** Ob die Bearbeitungszeit ablief. `null` bei Läufen vor Fassung 10. */
  readonly zeitAbgelaufen: boolean | null;
  /**
   * Ergebnis je Bereich, so wie es die Auswertung des Laufs zeigte.
   *
   * `null` bei Läufen vor Fassung 10: Es wurde damals nicht gespeichert und
   * lässt sich aus `antwort_log` nicht zurückrechnen – dort steht nicht,
   * welcher Lauf welche Zeile schrieb.
   */
  readonly bereiche: readonly BereichErgebnis[] | null;
  /**
   * Die Bestehensgrenze dieses Laufs als Anteil richtiger Antworten.
   *
   * `null` bei Läufen vor Fassung 10. Siehe {@link grenzquote} – der Wert wird
   * beim Speichern aus dem *wirksamen* Profil gebildet, weil er sich später
   * nicht mehr ermitteln lässt: Beim frei eingestellten Profil sind die
   * gewählten Werte nach dem Lauf fort.
   */
  readonly bestehensQuote: number | null;
}

/**
 * Bereiche, in denen mindestens ein Profil ein K.-o.-Kriterium führt.
 *
 * Abgeleitet statt aufgezählt: Wer ein Kriterium ergänzt, ergänzt es an einer
 * Stelle. Die Prüfungsreife-Ampel deckelt daran ihr Gesamturteil – ein
 * zweiter, von Hand gepflegter Auszug wäre die nächste Zahl, die still
 * auseinanderläuft.
 */
export const KO_BEREICHE: readonly string[] = Object.freeze([
  ...new Set(
    PRUEFUNGSPROFILE.flatMap((profil) => (profil.koKriterien ?? []).map((k) => k.bereich)),
  ),
]);

/**
 * Liegt diese Frage in einem Bereich mit K.-o.-Kriterium?
 *
 * Geprüft werden Kapitel **und** Abschnitt, weil ein Kriterium beides
 * bezeichnen kann – „I.5“ ist heute ein Abschnitt. Dieselbe Regel wie in
 * `gehoertZuBereich` beim Zusammenstellen des Prüfungsbogens; sie steht hier
 * ein zweites Mal, weil der Anwendungskern nichts aus dem Renderer holen
 * darf und ein Umzug dieser vier Zeilen mehr Wege anfasste als er wert wäre.
 */
export function istKoFrage(kapitel: string, abschnitt: string | null): boolean {
  return KO_BEREICHE.some((bereich) => kapitel === bereich || abschnitt === bereich);
}

export function profilFinden(id: string): Pruefungsprofil | undefined {
  return PRUEFUNGSPROFILE.find((p) => p.id === id);
}

/** Menschlich lesbares Urteil. */
export const URTEIL_BEZEICHNUNG: Readonly<Record<Bestehensurteil, string>> = Object.freeze({
  bestanden: 'Bestanden',
  nachpruefung: 'Mündliche Nachprüfung',
  nicht_bestanden: 'Nicht bestanden',
});

/**
 * Ermittelt, ob mit diesem Profil bestanden wurde.
 *
 * Reine Rechenfunktion ohne Seiteneffekte, damit sie in Anwendungskern und
 * Oberfläche dasselbe Ergebnis liefert und gut prüfbar bleibt.
 */
export function urteilBilden(
  profil: Pruefungsprofil,
  richtig: number,
  gesamt: number,
  verletzteKriterien: readonly string[],
): { urteil: Bestehensurteil; begruendung: string } {
  if (verletzteKriterien.length > 0) {
    return {
      urteil: 'nicht_bestanden',
      begruendung: `Nicht bestanden wegen: ${verletzteKriterien.join(', ')}.`,
    };
  }

  if (profil.wertung === 'fehlerpunkte') {
    const fehler = gesamt - richtig;
    const grenze = profil.maxFehler ?? 0;
    return fehler <= grenze
      ? {
          urteil: 'bestanden',
          begruendung: `${String(fehler)} von höchstens ${String(grenze)} zulässigen Fehlern.`,
        }
      : {
          urteil: 'nicht_bestanden',
          begruendung: `${String(fehler)} Fehler bei höchstens ${String(grenze)} zulässigen.`,
        };
  }

  const quote = gesamt > 0 ? richtig / gesamt : 0;
  const grenze = profil.bestehensQuote ?? 0.75;

  /* Die Begründung wird aus Trefferzahlen gebaut, nicht aus gerundeten
     Prozentwerten. Sonst entstünde ein Satz, der sich selbst widerspricht:
     149 von 200 sind 74,5 Prozent – kaufmännisch gerundet 75 –, und die
     Anzeige lautete „Nicht bestanden. 75 Prozent richtig, nötig waren 75
     Prozent." Mit Zahlen ist das eindeutig und rundungsfrei. */
  const noetig = benoetigteTreffer(grenze, gesamt);

  if (quote >= grenze) {
    return {
      urteil: 'bestanden',
      begruendung: `${String(richtig)} von ${String(gesamt)} richtig, nötig waren ${String(noetig)}.`,
    };
  }
  if (profil.nachpruefungAb !== undefined && quote >= profil.nachpruefungAb) {
    return {
      urteil: 'nachpruefung',
      begruendung:
        `${String(richtig)} von ${String(gesamt)} richtig. ` +
        `Ab ${String(benoetigteTreffer(profil.nachpruefungAb, gesamt))} richtigen Antworten sieht ` +
        `dieses Profil eine mündliche Nachprüfung vor, bestanden wäre ab ${String(noetig)}.`,
    };
  }
  return {
    urteil: 'nicht_bestanden',
    begruendung: `${String(richtig)} von ${String(gesamt)} richtig, nötig waren ${String(noetig)}.`,
  };
}

/**
 * Die Bestehensgrenze eines Laufs als Anteil richtiger Antworten.
 *
 * Nur die **Quotengrenze**: Ob ein Lauf bestanden ist, entscheidet
 * {@link urteilBilden}, und dort kann eine Zusatzbedingung ihn unabhängig von
 * jeder Quote zu Fall bringen. Dieser Wert dient dem Vergleich einzelner
 * Bereiche, nicht dem Urteil.
 *
 * Fehlerpunkte-Profile werden umgerechnet: 75 Fragen bei höchstens 15 Fehlern
 * sind 60 von 75, also 80 Prozent. Das ist keine Näherung, sondern dieselbe
 * Bedingung anders geschrieben.
 *
 * Der Ersatzwert 0,75 hält es mit {@link urteilBilden}: Ein Quotenprofil ohne
 * `bestehensQuote` ist ein Fehler im Vertrag, und beide Stellen müssen
 * denselben Ausweg nehmen, sonst begründet die Anwendung anders, als sie
 * rechnet.
 */
export function grenzquote(profil: Pruefungsprofil, gesamt: number): number | null {
  if (gesamt <= 0) {
    return null;
  }
  if (profil.wertung === 'fehlerpunkte') {
    return Math.max(0, (gesamt - (profil.maxFehler ?? 0)) / gesamt);
  }
  return profil.bestehensQuote ?? 0.75;
}

/**
 * Wie viele richtige Antworten eine Quote verlangt.
 *
 * Aufgerundet: Bei 75 Prozent von 200 Fragen sind 150 nötig, und 149 genügen
 * nicht – auch wenn 149/200 auf 75 Prozent gerundet gleich aussieht.
 */
function benoetigteTreffer(quote: number, gesamt: number): number {
  if (gesamt <= 0) {
    return 0;
  }
  const noetig = Math.ceil(quote * gesamt);
  /* Gleitkomma: 0,8 * 80 kann als 64,000000000000006 herauskommen und würde
     zu 65 aufgerundet. Ein Wert, der bis auf ein Millionstel eine ganze Zahl
     ist, wird deshalb als diese genommen. */
  const genau = quote * gesamt;
  return Math.abs(genau - Math.round(genau)) < 1e-6 ? Math.round(genau) : noetig;
}

/** Fragetypen, die ein Profil enthalten soll. */
export function gewuenschteTypen(profil: Pruefungsprofil): readonly Fragetyp[] {
  return profil.anteilOffen && profil.anteilOffen > 0
    ? (['mc', 'freitext', 'lueckentext'] as const)
    : (['mc'] as const);
}
