/**
 * Einordnung eines Laufs gegenüber den früheren.
 *
 * Der Schwerpunkt liegt auf dem, was die Gegenprüfung als Falle benannt hat:
 * `quote` zählt unbeantwortete Fragen wie falsch beantwortete, und ein Lauf,
 * in dem die Zeit ablief, darf deshalb nicht als Wissenseinbruch dastehen.
 */

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

import type { BereichErgebnis, Pruefungsverlauf } from '../src/shared/pruefung';
import {
  laufVergleichen,
  wiederkehrendeSchwaechen,
  type Laufkennzahlen,
} from '../src/shared/verlaufsvergleich';

function lauf(werte: Partial<Pruefungsverlauf> = {}): Pruefungsverlauf {
  return {
    id: 1,
    profilId: 'standard',
    profilName: 'Standard',
    zeitpunkt: '2026-08-01T10:00:00.000Z',
    gesamt: 80,
    richtig: 60,
    quote: 0.75,
    urteil: 'nicht_bestanden',
    dauerMs: 60 * 60_000,
    zeitmodus: 'normal',
    unbeantwortet: 0,
    zeitAbgelaufen: false,
    bereiche: null,
    bestehensQuote: 0.8,
    ...werte,
  };
}

function aktuell(werte: Partial<Laufkennzahlen> = {}): Laufkennzahlen {
  return {
    profilId: 'standard',
    zeitmodus: 'normal',
    gesamt: 80,
    quote: 0.8,
    dauerMs: 50 * 60_000,
    unbeantwortet: 0,
    zeitAbgelaufen: false,
    laufId: 99,
    ...werte,
  };
}

/** Datum unverändert durchreichen: Der Test prüft Sätze, nicht `Intl`. */
const roh = (iso: string): string => iso;

describe('laufVergleichen', () => {
  it('nennt Prozentpunkte und Minuten', () => {
    const vergleich = laufVergleichen(aktuell(), [lauf()], roh);

    expect(vergleich?.punkte).toBe(5);
    expect(vergleich?.minuten).toBe(10);
    expect(vergleich?.satz).toContain('5 Prozentpunkte besser');
    expect(vergleich?.satz).toContain('10 Minuten schneller');
  });

  it('nennt eine Verschlechterung genauso deutlich', () => {
    const vergleich = laufVergleichen(aktuell({ quote: 0.6, dauerMs: 75 * 60_000 }), [lauf()], roh);

    expect(vergleich?.satz).toContain('15 Prozentpunkte schlechter');
    expect(vergleich?.satz).toContain('15 Minuten langsamer');
  });

  it('sagt bei einem einzelnen Punkt „Prozentpunkt“', () => {
    const vergleich = laufVergleichen(aktuell({ quote: 0.76 }), [lauf()], roh);

    expect(vergleich?.satz).toContain('1 Prozentpunkt besser');
    expect(vergleich?.satz).not.toContain('1 Prozentpunkte');
  });

  it('behauptet keinen Unterschied, wo keiner ist', () => {
    const vergleich = laufVergleichen(
      aktuell({ quote: 0.75, dauerMs: 60 * 60_000 + 10_000 }),
      [lauf()],
      roh,
    );

    expect(vergleich?.satz).toContain('gleichauf in der Trefferquote');
    expect(vergleich?.satz).toContain('bei praktisch gleicher Bearbeitungsdauer');
    expect(vergleich?.minuten).toBeNull();
  });

  it('rundet oberhalb der Schwelle nie auf null Minuten', () => {
    // 45 Sekunden liegen über der Schwelle, wären aber auf 1 Minute gerundet
    // knapp – „0 Minuten schneller“ wäre ein Satz, der sich selbst widerspricht.
    const vergleich = laufVergleichen(aktuell({ dauerMs: 60 * 60_000 - 45_000 }), [lauf()], roh);

    expect(vergleich?.minuten).toBe(1);
    expect(vergleich?.satz).toContain('1 Minute schneller');
  });

  it('vergleicht nicht mit sich selbst', () => {
    // Der eben gespeicherte Lauf steht bereits im Verlauf. Ohne den Ausschluss
    // über die Kennung meldete die Anwendung jedem Prüfling ein „gleichauf“.
    expect(laufVergleichen(aktuell({ laufId: 7 }), [lauf({ id: 7 })], roh)).toBeNull();
  });

  it('vergleicht nur mit demselben Profil und derselben Zeitstufe', () => {
    const fremd = [
      lauf({ id: 2, profilId: 'dsb' }),
      lauf({ id: 3, zeitmodus: 'plus50' }),
      // Ohne vermerkte Zeitstufe ist nicht bekannt, was zutrifft.
      lauf({ id: 4, zeitmodus: null }),
    ];

    expect(laufVergleichen(aktuell(), fremd, roh)).toBeNull();
  });

  it('nimmt den jüngsten passenden Lauf, nicht den ersten überhaupt', () => {
    const verlauf = [
      lauf({ id: 5, profilId: 'dsb' }),
      lauf({ id: 4, zeitpunkt: '2026-07-20T10:00:00.000Z' }),
      lauf({ id: 3, zeitpunkt: '2026-07-01T10:00:00.000Z' }),
    ];

    expect(laufVergleichen(aktuell(), verlauf, roh)?.frueher.id).toBe(4);
  });

  it('sagt dazu, wenn diesmal die Zeit ablief', () => {
    const vergleich = laufVergleichen(
      aktuell({ quote: 0.4, unbeantwortet: 23, zeitAbgelaufen: true }),
      [lauf()],
      roh,
    );

    expect(vergleich?.vorbehalte).toContain(
      'Diesmal blieben 23 Fragen unbeantwortet, weil die Zeit ablief.',
    );
    expect(vergleich?.vorbehalte.join(' ')).toContain(
      'zählen in der Trefferquote wie falsch beantwortete',
    );
  });

  it('sagt dazu, wenn damals die Zeit ablief', () => {
    const vergleich = laufVergleichen(
      aktuell(),
      [lauf({ unbeantwortet: 12, zeitAbgelaufen: true })],
      roh,
    );

    expect(vergleich?.vorbehalte).toContain(
      'Damals blieben 12 Fragen unbeantwortet, weil die Zeit ablief.',
    );
  });

  it('unterscheidet „nicht festgehalten“ von „nichts offen geblieben“', () => {
    const vergleich = laufVergleichen(
      aktuell(),
      [lauf({ unbeantwortet: null, zeitAbgelaufen: null })],
      roh,
    );

    expect(vergleich?.vorbehalte.join(' ')).toContain('nicht festgehalten');
    // Der allgemeine Satz gehört nur dahin, wo tatsächlich etwas offen blieb.
    expect(vergleich?.vorbehalte.join(' ')).not.toContain('wie falsch beantwortete');
  });

  it('schweigt über Unbeantwortete, wenn es keine gab', () => {
    /* Bis 0.26.4 stand hier `toEqual([])`. Das war zu weit gefasst: Die
       Standardwerte dieser Datei – 80 Fragen, 75 gegen 80 Prozent – sind
       selbst ein Rauschfall, und seit dem Vorbehalt zur Ziehung steht dort
       zu Recht ein Satz. Geprüft wird jetzt, was der Name verspricht. */
    const vergleich = laufVergleichen(aktuell(), [lauf()], roh);

    expect(vergleich?.vorbehalte.join(' ')).not.toContain('unbeantwortet');
    expect(vergleich?.vorbehalte.join(' ')).not.toContain('wie falsch beantwortete');
  });

  /*
    Der Vorbehalt, der gefehlt hat.

    Ein Bogen zieht 75 bis 100 Fragen aus 575. Zwei Läufe messen deshalb zwei
    Stichproben und nicht denselben Bestand; auch ohne jede Änderung am
    Wissensstand fallen sie auseinander. Ohne diesen Satz meldet die
    Anwendung „5 Prozentpunkte besser“ – vier Fragen – als Fortschritt, und
    der Prüfling richtet sein Lernen danach aus.
  */
  it('nennt den Vorbehalt, wenn der Unterschied unter der Streuung der Ziehung liegt', () => {
    const vergleich = laufVergleichen(aktuell(), [lauf()], roh);

    expect(vergleich?.punkte).toBe(5);
    const text = vergleich?.vorbehalte.join(' ') ?? '';
    expect(text).toContain('5 Prozentpunkten entspricht 4 Fragen');
    expect(text).toContain('durch die Ziehung');
    expect(text).toContain('keine Aussage');
  });

  it('schweigt, sobald der Unterschied über der Streuung liegt', () => {
    const vergleich = laufVergleichen(aktuell({ quote: 0.95 }), [lauf({ quote: 0.6 })], roh);

    expect(vergleich?.punkte).toBe(35);
    expect(vergleich?.vorbehalte.join(' ')).not.toContain('durch die Ziehung');
  });

  it('schweigt bei Gleichstand – dort behauptet niemand einen Unterschied', () => {
    const vergleich = laufVergleichen(aktuell({ quote: 0.75 }), [lauf({ quote: 0.75 })], roh);

    expect(vergleich?.punkte).toBe(0);
    expect(vergleich?.vorbehalte.join(' ')).not.toContain('durch die Ziehung');
  });

  it('rechnet die Streuung aus beiden Bogengrößen, nicht aus einer', () => {
    /* Ein kleiner Bogen streut stärker. Bei 20 gegen 20 Fragen sind selbst
       10 Prozentpunkte noch Rauschen, bei 200 gegen 200 nicht mehr. */
    const klein = laufVergleichen(
      aktuell({ gesamt: 20, quote: 0.85 }),
      [lauf({ gesamt: 20, quote: 0.75 })],
      roh,
    );
    const gross = laufVergleichen(
      aktuell({ gesamt: 200, quote: 0.85 }),
      [lauf({ gesamt: 200, quote: 0.75 })],
      roh,
    );

    expect(klein?.punkte).toBe(10);
    expect(gross?.punkte).toBe(10);
    expect(klein?.vorbehalte.join(' ')).toContain('durch die Ziehung');
    expect(gross?.vorbehalte.join(' ')).not.toContain('durch die Ziehung');
  });

  it('nennt eine abweichende Bogengröße', () => {
    const vergleich = laufVergleichen(aktuell({ gesamt: 40 }), [lauf({ gesamt: 80 })], roh);

    expect(vergleich?.vorbehalte).toContain('Der Bogen hatte damals 80 Fragen, dieser 40 Fragen.');
  });

  it('trägt das Datum des Vergleichslaufs in den Satz', () => {
    const vergleich = laufVergleichen(
      aktuell(),
      [lauf({ zeitpunkt: '2026-07-04T08:00:00.000Z' })],
      roh,
    );

    expect(vergleich?.satz).toContain('(2026-07-04T08:00:00.000Z)');
  });

  it('vergleicht auch, wenn der Lauf gar nicht gespeichert wurde', () => {
    // Ersatzauswertung ohne Kanal: Es gibt keine Kennung, weil keine Zeile
    // geschrieben wurde – dann ist auch nichts auszuschließen.
    const { laufId: _weg, ...ohneKennung } = aktuell();

    expect(laufVergleichen(ohneKennung, [lauf()], roh)?.frueher.id).toBe(1);
  });
});

function bereich(id: string, gesamt: number, richtig: number): BereichErgebnis {
  return { bereich: id, titel: `Titel ${id}`, gesamt, richtig };
}

describe('wiederkehrendeSchwaechen', () => {
  it('meldet einen Bereich, der zweimal unter der Grenze lag', () => {
    const verlauf = [
      lauf({ id: 2, bereiche: [bereich('I.4', 10, 4), bereich('I.1', 10, 9)] }),
      lauf({ id: 1, bereiche: [bereich('I.4', 10, 5), bereich('I.1', 10, 10)] }),
    ];

    const befund = wiederkehrendeSchwaechen(verlauf);

    expect(befund.grundlage).toBe(2);
    expect(befund.bereiche).toHaveLength(1);
    expect(befund.bereiche[0]).toMatchObject({
      bereich: 'I.4',
      titel: 'Titel I.4',
      laeufe: 2,
      unterGrenze: 2,
      gesamt: 20,
      richtig: 9,
    });
  });

  it('macht aus einem schlechten Tag keine Lücke', () => {
    const verlauf = [
      lauf({ id: 2, bereiche: [bereich('I.4', 10, 3)] }),
      lauf({ id: 1, bereiche: [bereich('I.4', 10, 10)] }),
    ];

    expect(wiederkehrendeSchwaechen(verlauf).bereiche).toEqual([]);
  });

  it('zählt Bereiche mit zu wenigen Fragen gar nicht erst', () => {
    // Bei zwei Fragen entscheidet ein Fehler über 50 Prozentpunkte.
    const verlauf = [
      lauf({ id: 2, bereiche: [bereich('III', 2, 0)] }),
      lauf({ id: 1, bereiche: [bereich('III', 2, 0)] }),
    ];

    expect(wiederkehrendeSchwaechen(verlauf).bereiche).toEqual([]);
  });

  it('misst an der Grenze des jeweiligen Laufs', () => {
    // Sieben von zehn: unter einer Grenze von 80, über einer von 60 Prozent.
    const verlauf = [
      lauf({ id: 2, bestehensQuote: 0.8, bereiche: [bereich('I.4', 10, 7)] }),
      lauf({ id: 1, bestehensQuote: 0.6, bereiche: [bereich('I.4', 10, 7)] }),
    ];

    const befund = wiederkehrendeSchwaechen(verlauf);

    expect(befund.bereiche).toEqual([]);
    // Gezählt wurde er trotzdem in beiden Läufen – nur eben einmal als Treffer.
    const verlauf2 = [
      lauf({ id: 2, bestehensQuote: 0.8, bereiche: [bereich('I.4', 10, 7)] }),
      lauf({ id: 1, bestehensQuote: 0.8, bereiche: [bereich('I.4', 10, 7)] }),
    ];
    expect(wiederkehrendeSchwaechen(verlauf2).bereiche[0]).toMatchObject({
      laeufe: 2,
      unterGrenze: 2,
    });
  });

  it('lässt Läufe ohne gespeicherte Bereiche außen vor', () => {
    const verlauf = [
      lauf({ id: 3, bereiche: null }),
      lauf({ id: 2, bereiche: [bereich('I.4', 10, 2)] }),
      lauf({ id: 1, bereiche: [bereich('I.4', 10, 2)] }),
    ];

    // Grundlage sind zwei Läufe, nicht drei: Der alte trägt nichts bei, und
    // ihn mitzuzählen behauptete eine breitere Grundlage, als es gibt.
    expect(wiederkehrendeSchwaechen(verlauf).grundlage).toBe(2);
  });

  it('lässt Läufe ohne gespeicherte Grenze außen vor', () => {
    const verlauf = [
      lauf({ id: 2, bestehensQuote: null, bereiche: [bereich('I.4', 10, 1)] }),
      lauf({ id: 1, bestehensQuote: null, bereiche: [bereich('I.4', 10, 1)] }),
    ];

    expect(wiederkehrendeSchwaechen(verlauf)).toEqual({ grundlage: 0, bereiche: [] });
  });

  it('betrachtet höchstens zehn Läufe', () => {
    const verlauf = Array.from({ length: 14 }, (_unbenutzt, i) =>
      lauf({ id: 100 - i, bereiche: [bereich('I.4', 10, 4)] }),
    );

    const befund = wiederkehrendeSchwaechen(verlauf);

    expect(befund.grundlage).toBe(10);
    expect(befund.bereiche[0]).toMatchObject({ laeufe: 10, gesamt: 100 });
  });

  it('stellt den schwächsten Bereich nach vorn', () => {
    const verlauf = [
      lauf({ id: 2, bereiche: [bereich('I.4', 10, 6), bereich('II', 10, 2)] }),
      lauf({ id: 1, bereiche: [bereich('I.4', 10, 6), bereich('II', 10, 2)] }),
    ];

    expect(wiederkehrendeSchwaechen(verlauf).bereiche.map((b) => b.bereich)).toEqual(['II', 'I.4']);
  });

  it('nennt höchstens fünf Bereiche', () => {
    const bereiche = ['I.1', 'I.2', 'I.3', 'I.4', 'I.5', 'II', 'III'].map((id) =>
      bereich(id, 10, 1),
    );
    const verlauf = [lauf({ id: 2, bereiche }), lauf({ id: 1, bereiche })];

    expect(wiederkehrendeSchwaechen(verlauf).bereiche).toHaveLength(5);
  });

  it('sagt bei leerem Verlauf nichts', () => {
    expect(wiederkehrendeSchwaechen([])).toEqual({ grundlage: 0, bereiche: [] });
  });
});
