/**
 * Prüft die FSRS-6-Umsetzung gegen die Referenzimplementierung.
 *
 * Die Sollwerte in `fixtures/fsrs-referenz.json` sind **nicht** mit dem hier
 * geprüften Code entstanden, sondern mit `data-pipeline/fsrs_referenz.py` –
 * einer wörtlichen Übertragung von `py-fsrs/fsrs/scheduler.py` nach Python.
 * Damit sind es zwei unabhängige Wege zum selben Ergebnis; ein Tippfehler in
 * einer Formel fällt auf, statt sich still in die Lernpläne fortzusetzen.
 *
 * Wenn diese Datei rot wird, ist zuerst zu klären, ob sich die Referenz
 * geändert hat. Die Sollwerte dürfen nur neu erzeugt, nie von Hand angepasst
 * werden:
 *
 *     python data-pipeline/fsrs_referenz.py > app/tests/fixtures/fsrs-referenz.json
 */

import { readFileSync } from 'node:fs';
import { join } from 'node:path';

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

import {
  abrufwahrscheinlichkeit,
  ersterStand,
  FSRS_PARAMETER,
  intervallTage,
  naechsterStand,
  type FsrsGrad,
  type Gedaechtnisstand,
} from '../src/shared/fsrs';

interface Referenz {
  readonly konstanten: { readonly ABFALL: number; readonly STRECKUNG: number };
  readonly erstStand: Record<string, Gedaechtnisstand>;
  readonly kurve: readonly { readonly s: number; readonly t: number; readonly r: number }[];
  readonly intervall: readonly {
    readonly s: number;
    readonly ziel: number;
    readonly tage: number;
  }[];
  readonly folgen: readonly {
    readonly name: string;
    readonly schritte: readonly (Gedaechtnisstand & { grad: number; abstand: number })[];
  }[];
}

const referenz = JSON.parse(
  readFileSync(join(__dirname, 'fixtures', 'fsrs-referenz.json'), 'utf8'),
) as Referenz;

/**
 * Toleranz für den Gleitkommavergleich.
 *
 * Beide Seiten rechnen mit IEEE-754-Doubles, aber die Rechenreihenfolge ist
 * nicht wörtlich identisch. Zwölf Stellen liegen weit unter jeder Auswirkung
 * auf einen Termin – ein Intervall wird ohnehin auf Tage gerundet – und weit
 * über dem, was ein echter Formelfehler verursachen würde.
 */
const STELLEN = 12;

function gradVon(wert: number): FsrsGrad {
  if (wert !== 1 && wert !== 2 && wert !== 3 && wert !== 4) {
    throw new Error(`Ungültiger Grad in der Referenz: ${String(wert)}`);
  }
  return wert;
}

describe('FSRS-Parameter', () => {
  it('umfasst genau die 21 Werte von FSRS-6', () => {
    expect(FSRS_PARAMETER).toHaveLength(21);
    expect(FSRS_PARAMETER.every((w) => Number.isFinite(w))).toBe(true);
  });

  it('ist gegen Änderungen zur Laufzeit gesichert', () => {
    expect(Object.isFrozen(FSRS_PARAMETER)).toBe(true);
  });
});

describe('Vergessenskurve', () => {
  it('trifft die Referenzwerte', () => {
    expect(referenz.kurve.length).toBeGreaterThan(0);
    for (const fall of referenz.kurve) {
      expect(abrufwahrscheinlichkeit(fall.s, fall.t)).toBeCloseTo(fall.r, STELLEN);
    }
  });

  it('liefert nach genau einer Stabilitätsdauer 90 Prozent', () => {
    // Die Definition der Stabilität. Gilt sie nicht, ist der Streckfaktor falsch.
    for (const s of [0.212, 1, 2.3065, 10, 137.5]) {
      expect(abrufwahrscheinlichkeit(s, s)).toBeCloseTo(0.9, STELLEN);
    }
  });

  it('fällt streng monoton und bleibt zwischen 0 und 1', () => {
    let vorher = abrufwahrscheinlichkeit(10, 0);
    expect(vorher).toBe(1);
    for (const t of [1, 5, 10, 50, 500, 5000]) {
      const jetzt = abrufwahrscheinlichkeit(10, t);
      expect(jetzt).toBeLessThan(vorher);
      expect(jetzt).toBeGreaterThan(0);
      vorher = jetzt;
    }
  });

  it('wertet eine nie beantwortete Frage als nicht abrufbar', () => {
    // 0 statt NaN: Die Prognose summiert über alle Fragen des Katalogs, auch
    // über nie gesehene. Ein NaN würde die gesamte Summe unbrauchbar machen.
    expect(abrufwahrscheinlichkeit(0, 5)).toBe(0);
    expect(abrufwahrscheinlichkeit(Number.NaN, 5)).toBe(0);
    expect(abrufwahrscheinlichkeit(-1, 5)).toBe(0);
  });

  it('behandelt eine negative Zeitspanne wie den Zeitpunkt selbst', () => {
    expect(abrufwahrscheinlichkeit(10, -3)).toBe(1);
  });
});

describe('Intervall', () => {
  it('trifft die Referenzwerte', () => {
    for (const fall of referenz.intervall) {
      expect(intervallTage(fall.s, fall.ziel)).toBeCloseTo(fall.tage, STELLEN);
    }
  });

  it('entspricht bei 90 Prozent Zielquote genau der Stabilität', () => {
    for (const s of [1, 2.3065, 42, 365]) {
      expect(intervallTage(s, 0.9)).toBeCloseTo(s, STELLEN);
    }
  });

  it('ist die Umkehrung der Vergessenskurve', () => {
    for (const ziel of [0.7, 0.8, 0.9, 0.95, 0.99]) {
      const tage = intervallTage(20, ziel);
      expect(abrufwahrscheinlichkeit(20, tage)).toBeCloseTo(ziel, STELLEN);
    }
  });

  it('verkürzt sich, wenn eine höhere Zielquote verlangt wird', () => {
    // Die Grundlage der Prüfungsvorbereitung: Je sicherer der Stoff sitzen
    // soll, desto häufiger muss wiederholt werden.
    expect(intervallTage(30, 0.97)).toBeLessThan(intervallTage(30, 0.9));
    expect(intervallTage(30, 0.9)).toBeLessThan(intervallTage(30, 0.8));
  });
});

describe('Erste Beantwortung', () => {
  it('trifft die Referenzwerte für alle vier Bewertungen', () => {
    for (const [grad, soll] of Object.entries(referenz.erstStand)) {
      const ist = ersterStand(gradVon(Number(grad)));
      expect(ist.stabilitaet).toBeCloseTo(soll.stabilitaet, STELLEN);
      expect(ist.schwierigkeit).toBeCloseTo(soll.schwierigkeit, STELLEN);
    }
  });

  it('ordnet bessere Bewertungen höherer Stabilität und geringerer Schwierigkeit zu', () => {
    const staende = ([1, 2, 3, 4] as const).map((g) => ersterStand(g));
    for (let i = 1; i < staende.length; i += 1) {
      const vorher = staende[i - 1];
      const jetzt = staende[i];
      if (vorher === undefined || jetzt === undefined) {
        throw new Error('Unerwartet leerer Stand.');
      }
      expect(jetzt.stabilitaet).toBeGreaterThan(vorher.stabilitaet);
      expect(jetzt.schwierigkeit).toBeLessThan(vorher.schwierigkeit);
    }
  });
});

describe('Fortschreibung über mehrere Wiederholungen', () => {
  /* Der eigentliche Test: Fehler in einer einzelnen Formel schaukeln sich über
     mehrere Schritte auf und werden dadurch sichtbar, auch wenn der erste
     Schritt noch zufällig passt. */
  for (const folge of referenz.folgen) {
    it(`folgt der Referenz: ${folge.name}`, () => {
      let stand: Gedaechtnisstand | null = null;
      for (const schritt of folge.schritte) {
        stand = naechsterStand(stand, gradVon(schritt.grad), schritt.abstand);
        expect(stand.stabilitaet).toBeCloseTo(schritt.stabilitaet, STELLEN);
        expect(stand.schwierigkeit).toBeCloseTo(schritt.schwierigkeit, STELLEN);
      }
    });
  }

  it('deckt alle vier Bewertungen und beide Zeitfälle ab', () => {
    // Sonst könnte die Referenzdatei schrumpfen, ohne dass es auffällt.
    const schritte = referenz.folgen.flatMap((f) => f.schritte);
    expect(new Set(schritte.map((s) => s.grad))).toEqual(new Set([1, 2, 3, 4]));
    expect(schritte.some((s) => s.abstand === 0)).toBe(true);
    expect(schritte.some((s) => s.abstand >= 1)).toBe(true);
  });
});

describe('Zusagen des Modells', () => {
  it('senkt die Stabilität bei einem Fehler niemals auf null', () => {
    let stand = ersterStand(3);
    for (let i = 0; i < 50; i += 1) {
      stand = naechsterStand(stand, 1, 1);
      expect(stand.stabilitaet).toBeGreaterThan(0);
      expect(Number.isFinite(stand.stabilitaet)).toBe(true);
    }
  });

  it('erhöht die Stabilität bei einem Fehler nie', () => {
    // Sonst würde sich Falschantworten lohnen.
    for (const abstand of [1, 3, 10, 100]) {
      const vorher = { stabilitaet: 40, schwierigkeit: 5 };
      expect(naechsterStand(vorher, 1, abstand).stabilitaet).toBeLessThan(vorher.stabilitaet);
    }
  });

  it('senkt die Stabilität bei „gut“ am selben Tag nie', () => {
    for (const s of [0.2, 1, 5, 50, 300]) {
      const vorher = { stabilitaet: s, schwierigkeit: 5 };
      expect(naechsterStand(vorher, 3, 0).stabilitaet).toBeGreaterThanOrEqual(s);
      expect(naechsterStand(vorher, 4, 0).stabilitaet).toBeGreaterThanOrEqual(s);
    }
  });

  it('belohnt eine späte Wiederholung stärker als eine frühe', () => {
    // Der didaktische Kern: Abrufen kurz vor dem Vergessen festigt am meisten.
    const vorher = { stabilitaet: 10, schwierigkeit: 5 };
    const frueh = naechsterStand(vorher, 3, 2).stabilitaet;
    const spaet = naechsterStand(vorher, 3, 9).stabilitaet;
    expect(spaet).toBeGreaterThan(frueh);
  });

  it('hält die Schwierigkeit stets zwischen 1 und 10', () => {
    let stand = ersterStand(1);
    for (const grad of [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] as const) {
      stand = naechsterStand(stand, grad, 1);
      expect(stand.schwierigkeit).toBeLessThanOrEqual(10);
      expect(stand.schwierigkeit).toBeGreaterThanOrEqual(1);
    }
    for (const grad of [4, 4, 4, 4, 4, 4, 4, 4, 4, 4] as const) {
      stand = naechsterStand(stand, grad, 30);
      expect(stand.schwierigkeit).toBeLessThanOrEqual(10);
      expect(stand.schwierigkeit).toBeGreaterThanOrEqual(1);
    }
  });

  it('verkraftet unsinnige Zeitangaben, ohne NaN zu erzeugen', () => {
    const vorher = { stabilitaet: 10, schwierigkeit: 5 };
    for (const abstand of [Number.NaN, Number.POSITIVE_INFINITY, -5]) {
      const stand = naechsterStand(vorher, 3, abstand);
      expect(Number.isFinite(stand.stabilitaet)).toBe(true);
      expect(Number.isFinite(stand.schwierigkeit)).toBe(true);
    }
  });
});
