// @vitest-environment node
/**
 * Hartnäckige Fragen – die, die immer wieder danebengehen.
 *
 * **Der Anlass.** Die vollständige Fehlerhistorie liegt seit jeher in
 * `antwort_log`, aber keine einzige Abfrage wertete sie je Frage aus:
 * `letzteAntwortKarte` liest nur die **letzte** Antwort, und der Filter „Nur
 * Fehler“ hängt genau daran. Eine Frage, die über Wochen viermal durchfiel und
 * gestern zufällig saß, verschwand aus jeder Liste — obwohl sie das Gegenteil
 * von gekonnt ist.
 *
 * Gemessen wird hier gegen eine echte SQLite-Datenbank mit einer echten
 * Historie über mehrere Tage, nicht gegen eine Attrappe.
 */

import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { Lernstand } from '../src/main/lernstand';
import { HARTNAECKIG_AB_TAGEN, hartnaeckigText } from '../src/shared/hartnaeckig';
import type { Frage, Katalog } from '../src/shared/katalog';

const TAG_MS = 86_400_000;

function mcFrage(id: string, kapitel: string, abschnitt: string | null): Frage {
  return {
    id,
    amtliche_nummer: id,
    kapitel,
    abschnitt,
    typ: 'mc',
    seite: 1,
    frage: { text: `Frage ${id}`, segmente: [{ t: `Frage ${id}` }] },
    bilder: [],
    optionen: ['a', 'b', 'c'].map((label) => ({
      label,
      inhalt: { text: label, segmente: [{ t: label }] },
      korrekt: label === 'a',
      bilder: [],
    })),
  } as unknown as Frage;
}

const FRAGEN: readonly Frage[] = [
  mcFrage('I.1-01', 'I', 'I.1'),
  mcFrage('I.1-02', 'I', 'I.1'),
  mcFrage('II-01', 'II', null),
  mcFrage('IV-01', 'IV', null),
];

const KATALOG: Katalog = {
  meta: {
    titel: 'Prüfkatalog',
    herausgeber: 'Bundesverwaltungsamt',
    stand: '2024-12-16',
    quellenangabe: 'Attrappe',
    quelle_url: 'https://www.bva.bund.de/',
    quelldatei_sha256: 'a'.repeat(64),
    fragen_gesamt: FRAGEN.length,
  },
  kapitel: [
    { id: 'I', titel: 'Waffenrecht', abschnitte: [{ id: 'I.1', titel: 'Begriffe' }] },
    { id: 'II', titel: 'Waffentechnik', abschnitte: [] },
    { id: 'IV', titel: 'Seenotsignalmittel', abschnitte: [] },
  ],
  bilder: [],
  fragen: FRAGEN,
};

let uhr: Date;
let db: Database.Database;
let lernstand: Lernstand;
let profilId: number;

/** Antwortet auf eine Frage; `auswahl` steuert die Fehlwahl-Auswertung. */
function antworten(frageId: string, richtig: boolean, auswahl: readonly string[] = ['b']): void {
  lernstand.antworten(profilId, {
    frageId,
    auswahl: richtig ? ['a'] : auswahl,
    richtig,
    bewertung: richtig ? 'gut' : 'nochmal',
    dauerMs: 1500,
  });
}

function tageWeiter(tage: number): void {
  uhr = new Date(uhr.getTime() + tage * TAG_MS);
}

beforeEach(() => {
  uhr = new Date('2026-03-01T10:00:00.000Z');
  db = new Database(':memory:');
  lernstand = new Lernstand(db, KATALOG, { jetzt: () => uhr });
  profilId = lernstand.profile()[0]!.id;
});

afterEach(() => {
  if (db.open) {
    db.close();
  }
});

describe('Auszählung', () => {
  it('meldet nichts, solange nichts danebenging', () => {
    antworten('I.1-01', true);

    expect(lernstand.hartnaeckige(profilId)).toEqual([]);
  });

  it('meldet eine Frage erst ab der Schwelle', () => {
    for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN - 1; tag++) {
      antworten('I.1-01', false);
      tageWeiter(1);
    }
    expect(lernstand.hartnaeckige(profilId)).toEqual([]);

    antworten('I.1-01', false);
    expect(lernstand.hartnaeckige(profilId)).toHaveLength(1);
  });

  it('zählt Tage und nicht bloße Fehlschläge', () => {
    /* Drei Fehlschläge in einer Sitzung sind die gewöhnliche
       Wiedereinreihung, keine Hartnäckigkeit – die Frage kommt in derselben
       Sitzung zweimal wieder (`MAX_WIEDEREINREIHUNGEN`). */
    for (let versuch = 0; versuch < 6; versuch++) {
      antworten('I.1-01', false);
    }

    expect(lernstand.hartnaeckige(profilId)).toEqual([]);
  });

  it('behält eine Frage, die zwischendurch einmal saß', () => {
    /* Der eigentliche Befund: „Nur Fehler“ fragt die letzte Antwort. Wer
       gestern zufällig richtig lag, verschwand dort – und war doch nicht
       gekonnt. */
    for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) {
      antworten('I.1-01', false);
      tageWeiter(1);
    }
    antworten('I.1-01', true);

    const liste = lernstand.hartnaeckige(profilId);
    expect(liste).toHaveLength(1);
    expect(liste[0]?.frageId).toBe('I.1-01');
  });

  it('nennt Fehlschläge, Tage und den letzten Zeitpunkt', () => {
    for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) {
      antworten('I.1-01', false);
      antworten('I.1-01', false);
      tageWeiter(1);
    }

    const eintrag = lernstand.hartnaeckige(profilId)[0];
    expect(eintrag?.tage).toBe(HARTNAECKIG_AB_TAGEN);
    expect(eintrag?.fehlschlaege).toBe(HARTNAECKIG_AB_TAGEN * 2);
    expect(eintrag?.zuletzt).not.toBe('');
  });

  it('sortiert die auffälligsten nach vorn', () => {
    for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN + 2; tag++) {
      antworten('II-01', false);
      tageWeiter(1);
    }
    uhr = new Date('2026-03-01T10:00:00.000Z');
    for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) {
      antworten('I.1-01', false);
      tageWeiter(1);
    }

    const liste = lernstand.hartnaeckige(profilId);
    expect(liste[0]?.frageId).toBe('II-01');
  });
});

describe('Wiederkehrende Fehlwahl', () => {
  it('nennt den immer gleichen falschen Buchstaben', () => {
    /* Die Spalte `auswahl` wurde bis 0.22.0 geschrieben und von keiner
       Abfrage gelesen. Immer derselbe Fehlgriff heißt Verwechslung – das ist
       mit einem Merksatz zu heilen. */
    for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) {
      antworten('I.1-01', false, ['c']);
      tageWeiter(1);
    }

    expect(lernstand.hartnaeckige(profilId)[0]?.haeufigsteFehlwahl).toBe('c');
  });

  it('schweigt, wenn die Fehlgriffe streuen', () => {
    /* Gestreute Fehlgriffe heißen Nichtwissen – da gibt es kein Muster zu
       benennen, und eines zu behaupten wäre schlimmer als Schweigen. */
    antworten('I.1-01', false, ['b']);
    tageWeiter(1);
    antworten('I.1-01', false, ['c']);
    tageWeiter(1);
    antworten('I.1-01', false, ['b', 'c']);

    expect(lernstand.hartnaeckige(profilId)[0]?.haeufigsteFehlwahl).toBeUndefined();
  });

  it('schweigt bei einem Gleichstand', () => {
    for (const wahl of [['b'], ['b'], ['c'], ['c']]) {
      antworten('I.1-01', false, wahl);
      tageWeiter(1);
    }

    expect(lernstand.hartnaeckige(profilId)[0]?.haeufigsteFehlwahl).toBeUndefined();
  });
});

describe('Lernumfang', () => {
  it('übergeht ein dauerhaft abgewähltes Kapitel', () => {
    /* Wer Kapitel IV abgewählt hat, soll es auch hier nicht sehen –
       dieselbe Regel wie überall sonst. */
    for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) {
      antworten('IV-01', false);
      tageWeiter(1);
    }
    expect(lernstand.hartnaeckige(profilId)).toHaveLength(1);

    lernstand.profilAktualisieren({ id: profilId, kapitelAusschluss: ['IV'] });

    expect(lernstand.hartnaeckige(profilId)).toEqual([]);
  });
});

describe('Als Sitzungsfilter', () => {
  it('legt genau die hartnäckigen Fragen vor', () => {
    for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) {
      antworten('I.1-01', false);
      tageWeiter(1);
    }
    antworten('I.1-02', false);

    const sitzung = lernstand.sitzung(profilId, { nurHartnaeckige: true, anzahl: 50 });

    expect(sitzung.map((eintrag) => eintrag.frageId)).toEqual(['I.1-01']);
  });

  it('steht neben „nur Fehler“ und ersetzt es nicht', () => {
    /* Das gedruckte Fehlerprotokoll verspricht ausdrücklich: „Sobald Sie
       eine davon wieder richtig beantworten, verschwindet sie aus dieser
       Liste.“ Diese Zusage darf der neue Filter nicht brechen. */
    for (let tag = 0; tag < HARTNAECKIG_AB_TAGEN; tag++) {
      antworten('I.1-01', false);
      tageWeiter(1);
    }
    antworten('I.1-01', true);

    expect(lernstand.sitzung(profilId, { nurFehler: true, anzahl: 50 })).toEqual([]);
    expect(lernstand.sitzung(profilId, { nurHartnaeckige: true, anzahl: 50 })).toHaveLength(1);
  });
});

/*
  Die Ist-Behaltensquote.

  Der Lernplan steuert auf eine Zielquote (0,90 bis 0,97), und die Reife-Ampel
  zeigt modellierte Abrufwahrscheinlichkeiten. Wie oft der Lernende fällige
  Wiederholungen **tatsächlich** trifft, stand bis 0.22.0 nirgends – obwohl es
  im Protokoll steht und nur zu zählen war.

  Geprüft wird hier vor allem, was **nicht** hineinzählt: Antworten ohne
  Abstand und die Zeilen, die eine Simulation für nie aufgeschlagene Fragen
  anlegt.
*/
describe('Behaltensquote', () => {
  /** Beantwortet `anzahl` Fragen an aufeinanderfolgenden Tagen. */
  function wiederholungen(anzahl: number, richtig: boolean): void {
    for (let lauf = 0; lauf < anzahl; lauf++) {
      antworten('I.1-01', richtig);
      tageWeiter(1);
    }
  }

  it('schweigt, solange zu wenig vorliegt', () => {
    /* Eine Quote aus drei Antworten schwankt zwischen 0 und 100 Prozent und
       sähe nur aus wie eine Auskunft. */
    wiederholungen(5, true);

    expect(lernstand.uebersicht(profilId).behaltensquote).toBeNull();
  });

  it('zählt Wiederholungen mit Abstand und ihre Treffer', () => {
    wiederholungen(20, true);
    wiederholungen(5, false);

    const quote = lernstand.uebersicht(profilId).behaltensquote;
    /* Die allererste Antwort hat keinen Vorgänger und zählt nicht mit. */
    expect(quote?.gesamt).toBe(24);
    expect(quote?.richtig).toBe(19);
  });

  it('zählt Antworten ohne Abstand nicht mit', () => {
    /* Zwei Antworten am selben Tag sagen nichts über das Behalten – das ist
       dieselbe Grenze, nach der auch der Reife-Beleg entsteht.

       Zehn Fehlversuche hintereinander an einem Tag: Der **erste** hat einen
       Tag Abstand zur letzten Antwort und zählt zu Recht mit; die neun
       darauf haben keinen. Ohne die Abstandsregel fiele die Quote hier um
       zehn Fehlschläge statt um einen. */
    wiederholungen(25, true);
    const vorher = lernstand.uebersicht(profilId).behaltensquote;

    for (let lauf = 0; lauf < 10; lauf++) {
      antworten('I.1-01', false);
    }

    const nachher = lernstand.uebersicht(profilId).behaltensquote;
    expect(nachher?.gesamt).toBe((vorher?.gesamt ?? 0) + 1);
    expect(nachher?.richtig).toBe(vorher?.richtig);
  });

  it('zählt nie aufgeschlagene Simulationsfragen nicht mit', () => {
    /* Sie tragen `richtig = 0`, ohne dass jemand sie gesehen hätte. Als
       Antwort brächten sie erfundene Fehlschläge in die Quote – genau die
       Verunreinigung, die entscheidung-reifegrad.md Abschnitt 8 benennt. */
    wiederholungen(25, true);
    const vorher = lernstand.uebersicht(profilId).behaltensquote;

    for (let lauf = 0; lauf < 10; lauf++) {
      lernstand.protokollieren(profilId, {
        frageId: 'I.1-01',
        auswahl: [],
        richtig: false,
        bewertung: 'nochmal',
        dauerMs: 0,
      });
      tageWeiter(1);
    }

    expect(lernstand.uebersicht(profilId).behaltensquote).toEqual(vorher);
  });

  it('lässt sie auch nicht als Vorgänger durchgehen', () => {
    /* Der zweite Teil des doppelten Ausschlusses: Als Vorgänger verschöben
       sie den gemessenen Abstand – eine Antwort von heute erschiene dann als
       Wiederholung nach einem Tag, obwohl die Frage nie vorlag. */
    wiederholungen(25, true);
    const vorher = lernstand.uebersicht(profilId).behaltensquote;

    /* Eine Simulationszeile von gestern, dann eine echte Antwort von heute:
       Ohne den Ausschluss zählte die echte Antwort mit dem Abstand zur
       Simulationszeile. */
    lernstand.protokollieren(profilId, {
      frageId: 'II-01',
      auswahl: [],
      richtig: false,
      bewertung: 'nochmal',
      dauerMs: 0,
    });
    tageWeiter(2);
    antworten('II-01', true);

    /* Die Antwort auf II-01 ist deren erste echte – ohne Vorgänger, also
       ohne Beitrag zur Quote. */
    expect(lernstand.uebersicht(profilId).behaltensquote).toEqual(vorher);
  });
});

describe('Der Satz zur Bilanz', () => {
  it('beugt Einzahl und Mehrzahl', () => {
    expect(hartnaeckigText({ frageId: 'x', fehlschlaege: 1, tage: 1, zuletzt: '' })).toBe(
      'einmal an einem Tag danebengegangen',
    );
    expect(hartnaeckigText({ frageId: 'x', fehlschlaege: 5, tage: 3, zuletzt: '' })).toBe(
      '5-mal an 3 Tagen danebengegangen',
    );
  });
});
