/**
 * Der Rechenkern der Prüfungsreife.
 *
 * Diese Datei prüft nicht nur, dass die Formel rechnet, sondern dass sie die
 * **Fallen** vermeidet, an denen der erste Entwurf gescheitert ist. Die
 * Messwerte in den Kommentaren stammen aus der Nachrechnung gegen die
 * FSRS-Konstanten dieses Projekts; sie stehen ausgeschrieben, damit ein
 * späterer Umbau merkt, wenn er sie verschiebt.
 */

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

import { abrufwahrscheinlichkeit, ersterStand, naechsterStand } from '../src/shared/fsrs';
import {
  abrufFuer,
  belegteFragen,
  benoetigteFragen,
  gesamtstufeMitDeckel,
  reifegradVon,
  reifesatz,
  SCHWELLE_KIPPE,
  SCHWELLE_REIF,
  stufeFuer,
  type ReifeFrage,
} from '../src/shared/reife';

function frage(teil: Partial<ReifeFrage> = {}): ReifeFrage {
  return {
    bereich: 'I.1',
    stabilitaet: null,
    tageSeitAntwort: 0,
    bestaetigt: false,
    ...teil,
  };
}

describe('Der Beitrag einer einzelnen Frage', () => {
  it('ist null, solange der Abruf nicht belegt ist', () => {
    /* Der Kern der ganzen Umstellung. Ohne diese Regel stünde eine Frage mit
       S = 2,3065 – der Wert nach genau einer richtigen Antwort – bei 0,9468
       und damit über der Schwelle „prüfungsreif“. Eine einzige Antwort. */
    const einmalRichtig = frage({ stabilitaet: 2.3065, tageSeitAntwort: 1, bestaetigt: false });

    expect(abrufFuer(einmalRichtig)).toBe(0);
    expect(abrufFuer({ ...einmalRichtig, bestaetigt: true })).toBeCloseTo(0.9468, 4);
  });

  it('ist null für eine nie beantwortete Frage', () => {
    expect(abrufFuer(frage())).toBe(0);
    expect(abrufFuer(frage({ bestaetigt: true }))).toBe(0);
  });

  it('rechnet nie mit weniger als einem Tag Abstand', () => {
    /* `abrufwahrscheinlichkeit(S, 0)` ist für JEDES S exakt 1 – und
       `tageZwischen` im Anwendungskern liefert Bruchteile von Tagen. Ohne die
       Untergrenze stünde jede gerade beantwortete Frage bei 100 Prozent, und
       der Reifegrad spränge während einer Sitzung nach oben. */
    expect(abrufwahrscheinlichkeit(0.2, 0)).toBe(1);
    expect(abrufwahrscheinlichkeit(1000, 0)).toBe(1);

    const eben = frage({ stabilitaet: 2.3065, tageSeitAntwort: 0.01, bestaetigt: true });
    expect(abrufFuer(eben)).toBeCloseTo(abrufwahrscheinlichkeit(2.3065, 1), 10);
    expect(abrufFuer(eben)).toBeLessThan(0.95);
  });

  it('sinkt mit der Zeit', () => {
    const stand = { stabilitaet: 10, bestaetigt: true };
    const gestern = abrufFuer(frage({ ...stand, tageSeitAntwort: 1 }));
    const vorEinemMonat = abrufFuer(frage({ ...stand, tageSeitAntwort: 30 }));

    expect(vorEinemMonat).toBeLessThan(gestern);
  });
});

describe('Die Zahl steigt nie durch eine falsche Antwort', () => {
  /*
    Der blockierende Befund der Gegenprobe, in einen Test gegossen.

    Mit der reinen FSRS-Abrufwahrscheinlichkeit stieg der Beitrag jeder
    überfälligen Frage beim „Nicht gewusst“ – gemessen in allen fünf Fällen
    unten, etwa von 0,707 auf 0,884. Der Grund: Der neue Stand wird wieder am
    ersten Tag bewertet, der alte ging mit seinem echten, viel größeren Alter
    ein. Eine Lernanwendung, die das Falschantworten belohnt, ist kaputt.
  */
  const FAELLE = [
    { stabilitaet: 0.212, tageSeitAntwort: 30 },
    { stabilitaet: 1.2931, tageSeitAntwort: 10 },
    { stabilitaet: 2.3065, tageSeitAntwort: 20 },
    { stabilitaet: 8.2956, tageSeitAntwort: 60 },
    { stabilitaet: 60, tageSeitAntwort: 90 },
  ] as const;

  it.each(FAELLE)(
    'S=$stabilitaet nach $tageSeitAntwort Tagen: das rohe FSRS-Modell stiege, der Reifegrad nicht',
    ({ stabilitaet, tageSeitAntwort }) => {
      const vorher = frage({ stabilitaet, tageSeitAntwort, bestaetigt: true });

      // Was FSRS aus einem „Nicht gewusst“ macht.
      const neu = naechsterStand({ stabilitaet, schwierigkeit: 5 }, 1, tageSeitAntwort);

      /* Zum Vergleich: So sähe es ohne die Belegregel aus – nachweislich
         höher als vorher. Der Test hält das ausdrücklich fest, damit
         niemand die Regel als überflüssige Vorsicht wieder herausnimmt. */
      const ohneBelegregel = abrufwahrscheinlichkeit(neu.stabilitaet, 1);
      expect(ohneBelegregel).toBeGreaterThan(abrufwahrscheinlichkeit(stabilitaet, tageSeitAntwort));

      // Mit der Belegregel: Die falsche Antwort nimmt den Beleg weg.
      const nachher = frage({
        stabilitaet: neu.stabilitaet,
        tageSeitAntwort: 0,
        bestaetigt: false,
      });
      expect(abrufFuer(nachher)).toBe(0);
      expect(abrufFuer(nachher)).toBeLessThanOrEqual(abrufFuer(vorher));
    },
  );
});

describe('Der Reifegrad über eine Fragenmenge', () => {
  it('lässt unbelegte Fragen im Nenner stehen', () => {
    /* Sie herauszunehmen ergäbe eine Zahl, die bei der ersten belegten Frage
       auf 100 Prozent springt – die häufigste Art, eine Fortschrittsanzeige
       zu belügen. */
    const menge = [
      frage({ stabilitaet: 10, tageSeitAntwort: 1, bestaetigt: true }),
      ...Array.from({ length: 9 }, () => frage()),
    ];

    expect(reifegradVon(menge)).toBeCloseTo(abrufwahrscheinlichkeit(10, 1) / 10, 10);
  });

  it('ist null für eine leere Menge, nicht NaN', () => {
    expect(reifegradVon([])).toBe(0);
  });

  it('bleibt bei null, wenn der ganze Katalog genau einmal durchgearbeitet wurde', () => {
    /*
      Der zweite blockierende Befund. Mit der reinen Abrufwahrscheinlichkeit
      ergab genau dieser Zustand:

        0 % richtig  -> 0,766  ->  372 von 486
        50 % richtig -> 0,857  ->  416 von 486, also „Prüfungsreif“

      Wer jede Frage einmal falsch beantwortet, hätte 372 von 486 gelesen.
    */
    const einDurchgang = [
      ...Array.from({ length: 50 }, () =>
        frage({ stabilitaet: ersterStand(3).stabilitaet, tageSeitAntwort: 1 }),
      ),
      ...Array.from({ length: 50 }, () =>
        frage({ stabilitaet: ersterStand(1).stabilitaet, tageSeitAntwort: 1 }),
      ),
    ];

    expect(reifegradVon(einDurchgang)).toBe(0);
  });

  it('blickt für die Prognose in die Zukunft', () => {
    const menge = [frage({ stabilitaet: 10, tageSeitAntwort: 1, bestaetigt: true })];

    expect(reifegradVon(menge, 30)).toBeCloseTo(abrufwahrscheinlichkeit(10, 31), 10);
    expect(reifegradVon(menge, 30)).toBeLessThan(reifegradVon(menge, 0));
  });
});

describe('Die Einstufung', () => {
  it('entscheidet an derselben ganzen Zahl, die die Anzeige nennt', () => {
    /*
      Sonst gäbe es ein Band, in dem „Prüfungsreif“ neben einer Zahl steht,
      die eine Stufe tiefer als nicht ausreichend ausgewiesen wird: Bei 486
      Fragen verlangt die Schwelle 0,85 genau 414 Fragen (aufgerundet von
      413,1). Ein Reifegrad von 0,8505 ergibt gerundet 413 – also NICHT reif,
      obwohl der Bruchwert über der Schwelle liegt.
    */
    expect(benoetigteFragen(SCHWELLE_REIF, 486)).toBe(414);
    expect(belegteFragen(0.8505, 486)).toBe(413);
    expect(stufeFuer(413, 486)).toBe('kippe');
    expect(stufeFuer(414, 486)).toBe('reif');
  });

  it.each([23, 35, 43, 49, 89, 90, 92, 154, 486, 575])(
    'nennt bei %i Fragen nie eine Stufe, die die genannte Zahl nicht trägt',
    (gesamt) => {
      /* Die Gegenprobe der Gegenprobe: Für JEDE Zahl belegter Fragen muss
         gelten, dass die Stufe genau dann erreicht ist, wenn die Zahl die
         Zielzahl erreicht – in beide Richtungen. */
      for (let belegt = 0; belegt <= gesamt; belegt++) {
        const stufe = stufeFuer(belegt, gesamt);
        if (stufe === 'reif') {
          expect(belegt).toBeGreaterThanOrEqual(benoetigteFragen(SCHWELLE_REIF, gesamt));
        } else if (belegt > 0) {
          expect(belegt).toBeLessThan(benoetigteFragen(SCHWELLE_REIF, gesamt));
        }
        if (stufe === 'kippe') {
          expect(belegt).toBeGreaterThanOrEqual(benoetigteFragen(SCHWELLE_KIPPE, gesamt));
        }
      }
    },
  );

  it('unterscheidet „noch nichts belegt“ von „noch nicht so weit“', () => {
    /* Wer noch keine Frage wiedergesehen hat, bekommt keine Einstufung
       vorgegaukelt, sondern die Auskunft, woran das liegt. */
    expect(stufeFuer(0, 486)).toBe('ohne_beleg');
    expect(stufeFuer(1, 486)).toBe('zurueck');
    expect(stufeFuer(0, 0)).toBe('ohne_beleg');
  });

  it('deckelt das Gesamturteil an einem zurückliegenden K.-o.-Bereich', () => {
    /* Wer insgesamt bei 88 Prozent steht und in Notwehr und Notstand bei 60,
       fällt unter manchen Ordnungen sicher durch. Gedeckelt wird auf „Kippe“,
       nicht auf „zurück“: Der übrige Stand ist ja vorhanden. */
    expect(gesamtstufeMitDeckel('reif', ['reif'])).toBe('reif');
    expect(gesamtstufeMitDeckel('reif', ['kippe'])).toBe('kippe');
    expect(gesamtstufeMitDeckel('reif', ['zurueck'])).toBe('kippe');
    expect(gesamtstufeMitDeckel('reif', [])).toBe('reif');
    // Wer ohnehin zurückliegt, wird durch den Deckel nicht angehoben.
    expect(gesamtstufeMitDeckel('zurueck', ['zurueck'])).toBe('zurueck');
  });
});

describe('Der Satz zum Stand', () => {
  it('nennt die nachprüfbare Zahl vor der Auslegung', () => {
    const satz = reifesatz(414, 486, 'reif');

    expect(satz.startsWith('414 von 486 Fragen sitzen belegt.')).toBe(true);
  });

  it('verspricht bei „Prüfungsreif“ keine Bestehenswahrscheinlichkeit', () => {
    /* Eine Angabe wie „neun von zehn Läufen“ setzte voraus, dass alle Fragen
       dieselbe Trefferchance haben. Das Gegenteil ist der Fall – die
       Verteilung ist zweigipflig, und die Zahl wäre exakt aussehend und
       falsch. */
    const satz = reifesatz(414, 486, 'reif');

    expect(satz).not.toMatch(/Prozent|von zehn|wahrscheinlich/u);
    expect(satz).toContain('strengsten Bestehensgrenze');
  });

  it('nennt bei „Auf der Kippe“, wie viele Fragen noch fehlen', () => {
    const satz = reifesatz(389, 486, 'kippe');

    expect(satz).toContain('389 von 486');
    expect(satz).toContain('414');
  });

  it('stellt den Grund vor die Einordnung, wenn ein K.-o.-Bereich deckelt', () => {
    /* Wer hier „prüfungsreif“ läse und den Nachsatz überginge, ginge mit
       einer Lücke in die Prüfung, die ihn unabhängig vom Rest durchfallen
       lässt. */
    const satz = reifesatz(430, 486, 'kippe', ['I.5 – Notwehr und Notstand']);

    expect(satz).toContain('I.5 – Notwehr und Notstand');
    expect(satz).toContain('zwei Fehler');
    expect(satz.indexOf('liegt zurück')).toBeLessThan(satz.indexOf('zwei Fehler'));
  });

  it('erklärt beim leeren Stand die Regel, statt eine Stufe zu nennen', () => {
    const satz = reifesatz(0, 486, 'ohne_beleg');

    expect(satz).toContain('mindestens einem Tag Abstand');
    expect(satz).not.toContain('0 von 486');
  });
});

describe('reifesatz – der Deckel bei schwachem Gesamtstand', () => {
  /*
    Gemeldet aus der laufenden Anwendung: „2 von 486 Fragen sitzen belegt.
    Insgesamt reicht das – aber I.5 – Notwehr und Notstand liegt zurück.“
    Bei 2 von 486 reicht überhaupt nichts.

    Die Ursache war, dass der Satz den Deckel unbedingt vor die Einordnung
    zog, sobald ein K.-o.-Bereich zurücklag – ohne zu prüfen, ob der
    Gesamtstand für sich genommen genügt. Keine der übrigen Prüfungen hier
    hat das bemerkt, weil sie den Deckel nur beim starken Stand ansahen.
  */
  it('behauptet bei schwachem Gesamtstand nicht, dass es insgesamt reicht', () => {
    const satz = reifesatz(2, 486, 'zurueck', ['I.5 – Notwehr und Notstand']);

    expect(satz).toContain('2 von 486 Fragen sitzen belegt.');
    expect(satz).not.toContain('Insgesamt reicht das');
    expect(satz).toContain('I.5 – Notwehr und Notstand');
    expect(satz).toContain('nur zwei Fehler');
  });

  it('nennt bei schwachem Stand die Bestehensgrenze und den Bereich', () => {
    const satz = reifesatz(2, 486, 'zurueck', ['I.5 – Notwehr und Notstand']);

    expect(satz).toContain('Die strengste Bestehensgrenze dieses Programms verlangt');
    expect(satz).toContain('Dazu liegt');
  });

  it('sagt „Insgesamt reicht das“ weiterhin, wenn der Gesamtstand genügt', () => {
    /* Der Fall, für den der Satz gedacht war: Der Stand allein trüge, und
       nur der K.-o.-Bereich hält ihn auf. */
    const satz = reifesatz(460, 486, 'kippe', ['I.5 – Notwehr und Notstand']);

    expect(satz).toContain('Insgesamt reicht das – aber I.5 – Notwehr und Notstand liegt zurück.');
  });

  it('bleibt beim Stand auf der Kippe bei der Einordnung, statt sie zu überschreiben', () => {
    const satz = reifesatz(400, 486, 'kippe', ['I.5 – Notwehr und Notstand']);

    expect(satz).not.toContain('Insgesamt reicht das');
    expect(satz).toContain('strengste Bestehensgrenze');
    expect(satz).toContain('Dazu liegt I.5 – Notwehr und Notstand zurück.');
  });

  it('lässt den Zusatz weg, wenn kein Bereich zurückliegt', () => {
    expect(reifesatz(2, 486, 'zurueck')).not.toContain('Dazu liegt');
    expect(reifesatz(460, 486, 'reif')).not.toContain('Insgesamt reicht das');
  });
});
