/**
 * Verhaltenstests der Suchansicht.
 *
 * Der Suchkern ist in `fragensuche.test.ts` gegen den echten Katalog gemessen.
 * Hier geht es um das, was die Oberfläche zusagt: Fokusführung, Live-Region,
 * Leerzustände, den Weg vom Treffer ins Üben und zurück – und um zwei
 * Zusicherungen, die inhaltlich sind: **welche Antwort richtig ist, steht
 * nirgends im Baum**, und die Musterantwort erscheint nicht.
 */

import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import axe from 'axe-core';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { App } from '../src/renderer/src/App';
import { erklaerungenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useErklaerungen';
import { suchindexZwischenspeicherLeeren } from '../src/renderer/src/hooks/useFragensuche';
import { glossarZwischenspeicherLeeren } from '../src/renderer/src/hooks/useGlossar';
import { normtexteZwischenspeicherLeeren } from '../src/renderer/src/hooks/useNormtexte';
import { themenZwischenspeicherLeeren } from '../src/renderer/src/hooks/useThemen';
import type { Erklaerungen } from '../src/shared/erklaerungen';
import type { Glossar } from '../src/shared/glossar';
import { EINSTELLUNGEN_STANDARD, type AnwendungsInfo, type LernAppBridge } from '../src/shared/ipc';
import type { Frage, Katalog, RichText } from '../src/shared/katalog';
import type { Lernplan } from '../src/shared/lernplan';
import type { FrageStand, Lernuebersicht, Profil } from '../src/shared/lernstand';
import { EUPL_HINWEIS, EUPL_KENNUNG, KATALOG_HINWEIS } from '../src/shared/lizenzen';
import type { Pruefungsergebnis } from '../src/shared/pruefung';
import { jsdomOptionen } from '../src/shared/wcag';
import { NORMTEXTE_LEER } from '../src/shared/normtexte';
import { THEMEN_LEER } from '../src/shared/themen';

type Nutzer = ReturnType<typeof userEvent.setup>;

// ─── Katalogattrappe ────────────────────────────────────────────────────

function nurText(text: string): RichText {
  return { text, segmente: [{ t: text }] };
}

const ANSCHEINSWAFFE: Frage = {
  id: 'I.1-01',
  amtliche_nummer: '1.01',
  kapitel: 'I',
  abschnitt: 'I.1',
  typ: 'mc',
  seite: 10,
  frage: nurText('Welcher Gegenstand ist eine Anscheinswaffe?'),
  bilder: [],
  optionen: [
    { label: 'a', inhalt: nurText('Ein Sportgewehr'), korrekt: false, bilder: [] },
    { label: 'b', inhalt: nurText('Eine Spielzeugpistole'), korrekt: true, bilder: [] },
  ],
};

/** Eine offene Frage: Ihre Musterantwort darf nirgends erscheinen. */
const NOTWEHR: Frage = {
  id: 'I.5-01',
  amtliche_nummer: '5.01',
  kapitel: 'I',
  abschnitt: 'I.5',
  typ: 'freitext',
  seite: 80,
  frage: nurText('Was ist Notwehr?'),
  bilder: [],
  musterantwort: nurText('Die Verteidigung gegen einen gegenwärtigen rechtswidrigen Angriff.'),
};

/** Nur über die Antwortmöglichkeit zu finden – prüft die Rangklasse. */
const BUECHSE: Frage = {
  id: 'I.1-02',
  amtliche_nummer: '1.02',
  kapitel: 'I',
  abschnitt: 'I.1',
  typ: 'mc',
  seite: 11,
  frage: nurText('Welche Langwaffe ist gemeint?'),
  bilder: [],
  optionen: [
    { label: 'a', inhalt: nurText('Eine Büchse mit gezogenem Lauf'), korrekt: true, bilder: [] },
    { label: 'b', inhalt: nurText('Eine Flinte'), korrekt: false, bilder: [] },
  ],
};

/** Dreißig Füllfragen, damit die Portionierung greift (Schritt 25). */
const FUELLER: Frage[] = Array.from({ length: 30 }, (_, i) => ({
  id: `II-${String(i + 1).padStart(2, '0')}`,
  amtliche_nummer: String(i + 1).padStart(2, '0'),
  kapitel: 'II',
  abschnitt: null,
  typ: 'mc' as const,
  seite: 100 + i,
  frage: nurText(`Technikfrage Nummer ${String(i + 1)} zur Schusswaffe`),
  bilder: [],
  optionen: [
    { label: 'a', inhalt: nurText('Erste Möglichkeit'), korrekt: true, bilder: [] },
    { label: 'b', inhalt: nurText('Zweite Möglichkeit'), korrekt: false, bilder: [] },
  ],
}));

/** Traegt die Katalogwoerter, auf die die Begriffsbruecke bei „Tresor" zeigt. */
const AUFBEWAHRUNG: Frage = {
  id: 'I.1-03',
  amtliche_nummer: '1.03',
  kapitel: 'I',
  abschnitt: 'I.1',
  typ: 'mc',
  seite: 12,
  frage: nurText('Wie ist die Aufbewahrung geregelt?'),
  bilder: [],
  optionen: [
    { label: 'a', inhalt: nurText('In einem Sicherheitsbehältnis'), korrekt: true, bilder: [] },
    { label: 'b', inhalt: nurText('Frei zugänglich'), korrekt: false, bilder: [] },
  ],
};

const KATALOG: Katalog = {
  meta: {
    titel: 'Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)',
    herausgeber: 'Bundesverwaltungsamt',
    stand: '2024-12-16',
    quellenangabe: 'Amtlicher Fragenkatalog des Bundesverwaltungsamtes, Stand 16.12.2024.',
    quelle_url: 'https://www.bva.bund.de/',
    quelldatei_sha256: '7e5e41fa',
    fragen_gesamt: 34,
  },
  kapitel: [
    {
      id: 'I',
      titel: 'Waffenrecht',
      abschnitte: [
        { id: 'I.1', titel: 'Begriffe' },
        { id: 'I.5', titel: 'Notwehr und Notstand' },
      ],
    },
    { id: 'II', titel: 'Waffentechnik', abschnitte: [] },
  ],
  bilder: [],
  fragen: [ANSCHEINSWAFFE, BUECHSE, AUFBEWAHRUNG, NOTWEHR, ...FUELLER],
};

// ─── Übrige Attrappen ───────────────────────────────────────────────────

const PROFIL: Profil = {
  id: 1,
  name: 'Testprofil',
  pruefungstermin: null,
  kapitelAusschluss: [],
  erstelltAm: '2026-01-01T08:00:00.000Z',
};

const UEBERSICHT: Lernuebersicht = {
  fragenGesamt: 34,
  beantwortet: 0,
  belegt: 0,
  reifegrad: 0 / 34,
  stufe: 'ohne_beleg',
  deckelnd: [],
  faellig: 0,
  gemerkt: 0,
  offen: 0,
  fehler: 0,
  heuteRichtig: 0,
  heuteFalsch: 0,
  heuteBearbeitet: 0,
  tageSeitLetzterAntwort: null,
  bereiche: [],
  themengruppen: [],
};

const STAND: FrageStand = {
  frageId: 'I.1-01',
  versuche: 0,
  richtige: 0,
  zuletztBeantwortet: null,
  faelligAb: null,
  gemerkt: false,
  letzteBewertung: null,
};

const INFO: AnwendungsInfo = {
  anwendungsVersion: '0.0.0-attrappe',
  baukennung: 'abc1234',
  baustand: '2026-08-22',
  electronVersion: '43.4.1',
  chromeVersion: '140.0.0.0',
  nodeVersion: '22.19.1',
  plattform: 'win32',
  datenbank: { verfuegbar: true, sqliteVersion: '3.50.2', meldung: 'SQLite bereit.' },
};

const ERGEBNIS: Pruefungsergebnis = {
  profilId: 'standard',
  gesamt: 0,
  richtig: 0,
  falsch: 0,
  unbeantwortet: 0,
  quote: 0,
  urteil: 'nicht_bestanden',
  begruendung: '0 Prozent richtig.',
  verletzteKriterien: [],
  bereiche: [],
  fehlerIds: [],
  dauerMs: 0,
  zeitAbgelaufen: false,
  zeitpunkt: '2026-01-01T08:00:00.000Z',
};

const LERNPLAN: Lernplan = {
  termin: null,
  tageBisTermin: null,
  gesamtFragen: 34,
  nieBeantwortet: 34,
  faellig: 0,
  zielquote: 0.9,
  prognoseHeute: 0,
  prognoseAmTermin: null,
  pensum: { neu: 1, wiederholung: 0, gesamt: 1, minuten: 1 },
  machbarkeit: 'kein_termin',
  sekundenProFrage: 25,
};

const ERKLAERUNGEN: Erklaerungen = {
  meta: { version: 1, stand: '2026-08-20', gesetzesstand: {}, hinweis: 'Eigener Inhalt.' },
  zuFrage: {},
};

const GLOSSAR: Glossar = {
  meta: { version: 1, stand: '2026-08-21', gesetzesstand: {}, hinweis: 'Eigener Inhalt.' },
  eintraege: [],
};

function attrappe(ueberschreibungen: Partial<LernAppBridge> = {}): LernAppBridge {
  return {
    anwendungsInfoLesen: vi.fn(() => Promise.resolve(INFO)),
    anzeigegroesseSetzen: vi.fn((prozent: number) => Promise.resolve(prozent)),
    anzeigegroesseBeobachten: vi.fn(() => () => undefined),
    hilfsmittelAktiv: vi.fn(() => Promise.resolve(false)),
    hilfsmittelBeobachten: vi.fn(() => () => undefined),
    menuebefehlBeobachten: vi.fn(() => () => undefined),
    /* Die Erststart-Frage nach dem abwaehlbaren Kapitel ist beantwortet:
       Diese Pruefungen sehen den Startbildschirm an, nicht das Tor davor. */
    einstellungenLesen: vi.fn(() =>
      Promise.resolve({ ...EINSTELLUNGEN_STANDARD, zuschnittGefragt: true }),
    ),
    einstellungenSchreiben: vi.fn(() => Promise.resolve(EINSTELLUNGEN_STANDARD)),
    katalogLaden: vi.fn(() => Promise.resolve(KATALOG)),
    katalogBild: vi.fn(() => Promise.resolve('data:image/png;base64,')),
    erklaerungenLaden: vi.fn(() => Promise.resolve(ERKLAERUNGEN)),
    glossarLaden: vi.fn(() => Promise.resolve(GLOSSAR)),
    /* Ohne Normtexte bleibt es beim Zitat – genau wie vor 0.22.0.
       Diese Pruefungen sehen die Fundstellen an, nicht den Gesetzestext;
       den prueft tests/normstelle.test.tsx an eigenen Faellen. */
    normtexteLaden: vi.fn(() => Promise.resolve(NORMTEXTE_LEER)),
    themenLaden: vi.fn(() => Promise.resolve(THEMEN_LEER)),
    profilListe: vi.fn(() => Promise.resolve([PROFIL])),
    profilAnlegen: vi.fn(() => Promise.resolve(PROFIL)),
    profilAktualisieren: vi.fn(() => Promise.resolve(PROFIL)),
    profilLoeschen: vi.fn(() => Promise.resolve([PROFIL])),
    /* Wird von der Suche nie gebraucht: Eine Sitzung mit fester Fragenliste
       entsteht aus dem geladenen Katalog. Die Attrappe bedient nur den Weg
       über „Weiterlernen“. */
    lernSitzung: vi.fn(() =>
      Promise.resolve([{ frageId: 'I.1-01', optionsReihenfolge: ['a', 'b'], gemerkt: false }]),
    ),
    lernAntworten: vi.fn(() => Promise.resolve(STAND)),
    lernMerken: vi.fn(() => Promise.resolve(STAND)),
    lernUebersicht: vi.fn(() => Promise.resolve(UEBERSICHT)),
    lernPlan: vi.fn(() => Promise.resolve(LERNPLAN)),
    lernZuruecksetzen: vi.fn(() => Promise.resolve(UEBERSICHT)),
    pruefungStarten: vi.fn(() => Promise.resolve({ fragen: [], warnungen: [] })),
    pruefungAuswerten: vi.fn(() => Promise.resolve(ERGEBNIS)),
    pruefungVerlauf: vi.fn(() => Promise.resolve([])),
    pruefungSichern: vi.fn(() => Promise.resolve(true)),
    pruefungOffen: vi.fn(() => Promise.resolve(null)),
    pruefungVerwerfen: vi.fn(() => Promise.resolve()),
    pruefungLaeuft: vi.fn(() => Promise.resolve()),
    sicherungUebernehmen: vi.fn(() => Promise.resolve({ art: 'abgelaufen' as const })),
    fehlerprotokollDrucken: vi.fn(() =>
      Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 }),
    ),
    fragenlisteDrucken: vi.fn(() => Promise.resolve({ gespeichert: false, pfad: null, bytes: 0 })),
    lernberichtDrucken: vi.fn(() =>
      Promise.resolve({ gespeichert: true, pfad: 'C:/x.pdf', bytes: 1 }),
    ),
    /* Die Erklärung selbst prüft `renderer-ueber.test.tsx`; hier genügt,
       dass die Brücke den Kanal kennt. */
    datenschutzLesen: vi.fn(() => Promise.resolve({ erklaerung: null, warnung: null })),
    lizenzenLesen: vi.fn(() =>
      Promise.resolve({
        eigene: { kennung: EUPL_KENNUNG, name: 'EUPL 1.2', hinweis: EUPL_HINWEIS, text: null },
        katalog: {
          quellenangabe: KATALOG.meta.quellenangabe,
          quelleUrl: KATALOG.meta.quelle_url,
          hinweis: KATALOG_HINWEIS,
        },
        dritte: [],
      }),
    ),
    ...ueberschreibungen,
  };
}

// ─── Hilfsgriffe ────────────────────────────────────────────────────────

async function sucheOeffnen(bruecke: LernAppBridge = attrappe()): Promise<Nutzer> {
  vi.stubGlobal('lernApp', bruecke);
  const nutzer = userEvent.setup();
  render(<App />);

  await nutzer.click(await screen.findByRole('button', { name: 'Fragen durchsuchen' }));
  await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 });
  return nutzer;
}

function feld(): HTMLElement {
  return screen.getByLabelText('Suchbegriff');
}

function trefferliste(): HTMLElement[] {
  return screen.queryAllByRole('heading', { level: 2 }).filter((h) => h.tabIndex === -1);
}

afterEach(() => {
  vi.unstubAllGlobals();
  erklaerungenZwischenspeicherLeeren();
  glossarZwischenspeicherLeeren();
  normtexteZwischenspeicherLeeren();
  themenZwischenspeicherLeeren();
  suchindexZwischenspeicherLeeren();
});

// ─── Tests ──────────────────────────────────────────────────────────────

describe('Suche – Zugang und Aufbau', () => {
  it('ist vom Startbildschirm aus erreichbar und setzt den Fokus auf die Überschrift', async () => {
    await sucheOeffnen();
    expect(screen.getByRole('heading', { name: 'Fragen durchsuchen', level: 1 })).toHaveFocus();
  });

  it('führt genau eine h1', async () => {
    await sucheOeffnen();
    expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
  });

  it('bietet genau zwei Bedienelemente vor der Liste: Feld und Bereich', async () => {
    await sucheOeffnen();
    const bereich = screen.getByRole('group', { name: 'Bereich' });
    expect(feld()).toBeInTheDocument();
    expect(within(bereich).getAllByRole('radio').length).toBeGreaterThan(1);
    // Keine Kästchen für „wo gesucht wird“ – das wären Tokenizer-Fragen.
    expect(screen.queryAllByRole('checkbox')).toEqual([]);
  });

  it('nennt die Fragenzahl in jeder Bereichsbeschriftung', async () => {
    await sucheOeffnen();
    expect(screen.getByRole('radio', { name: 'Alle Bereiche (34)' })).toBeInTheDocument();
    expect(screen.getByRole('radio', { name: /I\.5 – Notwehr und Notstand \(1\)/u })).toBeVisible();
  });
});

describe('Suche – der Fragen-Browser', () => {
  it('zeigt bei leerem Feld alle Fragen des Bereichs', async () => {
    await sucheOeffnen();
    // 34 Fragen, portioniert auf 25.
    expect(trefferliste()).toHaveLength(25);
  });

  it('kürzt auf den gewählten Bereich', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.click(screen.getByRole('radio', { name: /I\.5 – Notwehr/u }));
    await waitFor(() => {
      expect(trefferliste()).toHaveLength(1);
    });
    expect(screen.getByRole('heading', { name: /Was ist Notwehr/u })).toBeInTheDocument();
  });
});

describe('Suche – Finden', () => {
  it('filtert während des Tippens', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'anscheinswaffe');
    await waitFor(() => {
      expect(trefferliste()).toHaveLength(1);
    });
  });

  it('findet über Umlaute und ß hinweg', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'buechse');
    await waitFor(() => {
      expect(trefferliste()).toHaveLength(1);
    });
    expect(screen.getByRole('heading', { name: /Welche Langwaffe/u })).toBeInTheDocument();
  });

  it('sagt an einem Antworttreffer, in welcher Möglichkeit er sitzt', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'buechse');
    expect(await screen.findByText('Treffer in Antwortmöglichkeit a.')).toBeInTheDocument();
  });

  it('meldet die Trefferzahl in einer Live-Region – als ganzen Satz', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'anscheinswaffe');

    /* Die Region spricht erst nach einer Ruhepause: Eine höfliche Region, die
       bei jedem Anschlag eine neue Zahl nennt, ist genau das, wovor der
       Prüfplan zu 4.1.3 warnt. */
    const region = await screen.findByText('Eine Frage gefunden.');
    expect(region).toHaveAttribute('role', 'status');
  });

  it('findet eine Frage über ihre amtliche Nummer', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), '5.01');
    expect(await screen.findByText('Über die Fragennummer gefunden:')).toBeInTheDocument();
    expect(screen.getByText('Über die Fragennummer gefunden.')).toBeInTheDocument();
  });
});

describe('Suche – Leerzustände sind Wege, keine Sackgassen', () => {
  it('bittet bei einem Zeichen um ein weiteres, statt „keine Treffer“ zu melden', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'a');
    expect(await screen.findByText('Bitte noch ein Zeichen eingeben.')).toBeInTheDocument();
  });

  it('benennt das Wort, das im Katalog nicht vorkommt', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'zwergpudel');
    expect(await screen.findByText(/„zwergpudel“ kommt im Katalog nicht vor/u)).toBeInTheDocument();
  });

  it('rechnet vor, was ohne die Bereichseinschränkung zu finden wäre', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.click(screen.getByRole('radio', { name: /I\.5 – Notwehr/u }));
    await nutzer.type(feld(), 'anscheinswaffe');

    expect(
      await screen.findByText(/Ohne die Einschränkung.*gäbe es 1 Treffer/u),
    ).toBeInTheDocument();
    await nutzer.click(screen.getByRole('button', { name: 'Einschränkung aufheben' }));
    await waitFor(() => {
      expect(trefferliste()).toHaveLength(1);
    });
  });
});

describe('Suche – Portionierung', () => {
  it('lädt auf Knopfdruck nach und setzt den Fokus auf den ersten neuen Treffer', async () => {
    const nutzer = await sucheOeffnen();
    expect(trefferliste()).toHaveLength(25);

    await nutzer.click(screen.getByRole('button', { name: /Weitere 9 anzeigen \(25 von 34\)/u }));

    await waitFor(() => {
      expect(trefferliste()).toHaveLength(34);
    });
    /* Der Fokus geht auf den ersten neu erschienenen Treffer, nicht auf den
       Knopf: Die neuen Karten stehen im Baum VOR ihm und würden beim
       Weitertabben sonst vollständig übersprungen. */
    expect(trefferliste()[25]).toHaveFocus();
  });
});

describe('Suche – vom Treffer ins Üben und zurück', () => {
  it('startet eine Sitzung mit genau dieser Frage', async () => {
    /* Eine Sitzung mit fester Fragenliste wird aus dem bereits geladenen
       Katalog gebaut, ohne den Kanal `lernen:sitzung` – geprüft wird deshalb
       das Ergebnis und nicht der Aufruf. */
    const bruecke = attrappe();
    const nutzer = await sucheOeffnen(bruecke);
    await nutzer.type(feld(), 'anscheinswaffe');

    await nutzer.click(await screen.findByRole('button', { name: 'Frage 1.01 üben' }));

    expect(await screen.findByText(/Welcher Gegenstand ist eine Anscheinswaffe\?/u)).toBeVisible();
    expect(screen.getByText(/Frage 1 von 1/u)).toBeInTheDocument();
    expect(screen.queryByText(/Welche Langwaffe/u)).not.toBeInTheDocument();
    expect(bruecke.lernSitzung).not.toHaveBeenCalled();
  });

  it('führt aus der Auswertung zurück in dieselbe Trefferliste', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'anscheinswaffe');
    await nutzer.click(await screen.findByRole('button', { name: 'Frage 1.01 üben' }));

    await nutzer.click(await screen.findByRole('button', { name: 'Sitzung beenden' }));
    const zurueck = await screen.findByRole('button', { name: 'Zurück zur Suche' });
    await nutzer.click(zurueck);

    await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 });
    expect(feld()).toHaveValue('anscheinswaffe');
  });

  it('deckelt „alle Treffer üben“ auf den Sitzungsumfang', async () => {
    await sucheOeffnen();
    /* Beim Stöbern hat niemand gesucht – dort sind es Fragen, keine Treffer.
       34 im Bestand, Sitzungsumfang 20. */
    expect(
      screen.getByRole('button', { name: 'Die ersten 20 von 34 Fragen üben' }),
    ).toBeInTheDocument();
  });

  it('spricht bei genau einem Treffer nicht von „Alle 1 Fragen“', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'anscheinswaffe');
    expect(await screen.findByRole('button', { name: 'Diese eine Frage üben' })).toBeVisible();
  });
});

describe('Suche – verrät nichts', () => {
  it('zeigt nirgends, welche Antwort richtig ist', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'anscheinswaffe');
    await nutzer.click(await screen.findByText(/Antwortmöglichkeiten anzeigen/u));

    const inhalt = document.body.textContent;
    expect(inhalt).toContain('Eine Spielzeugpistole');
    // Nichts, was die Lösung ausweist – weder Wort noch Klasse noch Attribut.
    expect(inhalt).not.toMatch(/richtig|korrekt/iu);
    expect(document.querySelector('[data-korrekt]')).toBeNull();
  });

  it('zeigt die Musterantwort einer offenen Frage nicht', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'notwehr');
    await waitFor(() => {
      expect(trefferliste()).toHaveLength(1);
    });
    expect(screen.queryByText(/Verteidigung gegen einen gegenwärtigen/u)).not.toBeInTheDocument();
  });

  it('sagt bei einem Musterantworttreffer, dass sie nicht gezeigt wird', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'rechtswidrigen');
    expect(
      await screen.findByText('Treffer nur in der Musterantwort – sie wird hier nicht gezeigt.'),
    ).toBeInTheDocument();
  });
});

describe('Suche – Barrierefreiheit', () => {
  it('hat keine axe-Verstöße, auch mit Trefferliste', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'schusswaffe');
    await waitFor(() => {
      expect(trefferliste().length).toBeGreaterThan(0);
    });

    const ergebnis = await axe.run(document.body, jsdomOptionen());
    expect(ergebnis.violations.map((v) => v.id)).toEqual([]);
  });

  it('markiert die Fundstelle und benennt sie zusätzlich im Klartext', async () => {
    /* Die Markierung allein wäre eine Aussage über Farbe (WCAG 1.4.1). Sie
       wegzulassen hülfe Blinden und verteuerte es für Sehbehinderte. */
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'anscheinswaffe');
    await waitFor(() => {
      expect(trefferliste()).toHaveLength(1);
    });

    const marken = document.querySelectorAll('mark.fundstelle');
    expect(marken.length).toBeGreaterThan(0);
    expect(marken[0]?.textContent.toLowerCase()).toContain('anscheinswaffe');
    expect(screen.getByText('Treffer im Fragetext.')).toBeInTheDocument();
  });
});

describe('Suche – was die Nachprüfung gefunden hat', () => {
  /*
    Jeder Test hier steht für einen bestätigten Befund aus der adversarischen
    Nachprüfung vor der Veröffentlichung von 0.10.0. Sie stehen zusammen,
    weil sie eines gemeinsam haben: Die Ansicht sagte etwas anderes, als sie
    tat – und jeder Lauf war grün dabei.
  */

  it('meldet bei einem Nummerntreffer nicht zugleich „Keine Frage gefunden“', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), '1.01');

    expect(await screen.findByText('Über die Fragennummer gefunden:')).toBeInTheDocument();
    expect(screen.queryByText('Keine Frage gefunden.')).not.toBeInTheDocument();
    // Auch die Live-Region darf das nicht sagen.
    expect(await screen.findByText('Eine Frage über die Nummer gefunden.')).toBeInTheDocument();
  });

  it('fordert bei einer einstelligen Fragennummer kein weiteres Zeichen', async () => {
    /* Eingabe „5“: Die Ansicht zeigte drei Fragen und sagte gleichzeitig
       „Bitte noch ein Zeichen eingeben.“ – wer hört statt sieht, hielt die
       Suche für nicht ausgeführt. */
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), '5');

    await waitFor(() => {
      expect(screen.getByText('Über die Fragennummer gefunden:')).toBeInTheDocument();
    });
    expect(screen.queryByText('Bitte noch ein Zeichen eingeben.')).not.toBeInTheDocument();
  });

  it('findet auch mit Satzzeichen am Wortrand', async () => {
    /* „Notwehr!“ fand null Fragen, „Notwehr“ eine – das Satzzeichen wurde
       stillschweigend mitgesucht. Besonders ärgerlich bei den typografischen
       Anführungszeichen, die die Anwendung selbst in ihren Hinweisen setzt. */
    const nutzer = await sucheOeffnen();
    for (const eingabe of ['Notwehr!', '(Notwehr', '„Notwehr“', 'Notwehr;']) {
      await nutzer.clear(feld());
      await nutzer.type(feld(), eingabe);
      await waitFor(() => {
        expect(trefferliste(), `„${eingabe}“ findet nichts`).toHaveLength(1);
      });
    }
  });

  it('hält dem Nutzer seine eigene Schreibweise vor, nicht die Kanonform', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'Schießstättenübung');

    expect(
      await screen.findByText(/„Schießstättenübung“ kommt im Katalog nicht vor/u),
    ).toBeInTheDocument();
  });

  it('verspricht bei der Begriffsbrücke nur, was ein Klick auch liefert', async () => {
    /*
      Der schwerste Befund: Der Knopf nannte die Vereinigungsmenge über alle
      Katalogwörter und setzte beim Klick nur das erste ein. Jetzt trägt jeder
      Vorschlag seine eigene Zahl – und die muss stimmen.
    */
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'Tresor');

    const vorschlag = await screen.findByRole('button', { name: /^Aufbewahrung \(\d+\)$/u });
    const versprochen = Number(/\((\d+)\)/u.exec(vorschlag.textContent)?.[1] ?? '0');
    expect(versprochen).toBeGreaterThan(0);

    await nutzer.click(vorschlag);
    await waitFor(() => {
      expect(trefferliste()).toHaveLength(versprochen);
    });
  });

  it('lässt den Fokus nach einem Angebotsklick nicht auf <body> fallen', async () => {
    /* Der Kasten mit dem Knopf verschwindet im selben Render – ohne
       Fokusführung stünde man danach am Dokumentanfang. */
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'Tresor');
    await nutzer.click(await screen.findByRole('button', { name: /^Aufbewahrung \(\d+\)$/u }));

    await waitFor(() => {
      expect(feld()).toHaveFocus();
    });
    expect(document.activeElement).not.toBe(document.body);
  });

  it('sperrt den Weg in die Suche, solange der Katalog fehlt', async () => {
    /* Sonst führte der Knopf in eine Ansicht, die App.tsx mangels
       Katalogindex gar nicht rendert: leerer Hauptbereich, keine Überschrift,
       Fokus auf <body>. */
    vi.stubGlobal(
      'lernApp',
      attrappe({ katalogLaden: vi.fn(() => Promise.reject(new Error('kaputt'))) }),
    );
    render(<App />);

    const knopf = await screen.findByRole('button', { name: 'Fragen durchsuchen' });
    /* Gesperrt heißt erreichbar (Projektregel, docs/stand.md 7.13): kein
       natives `disabled`, dafür `aria-disabled` und der Grund als Text am
       Knopf. Ein `disabled` Knopf nähme den Fokus mit auf `<body>`. */
    await waitFor(() => {
      expect(knopf).toHaveAttribute('aria-disabled', 'true');
    });
    expect(knopf).toBeEnabled();
    expect(knopf).toHaveAccessibleDescription(/lässt sich nicht darin suchen/u);
  });
});

describe('Suche – was den Rückweg überlebt', () => {
  /**
   * Übt die erste Frage der Trefferliste und beendet die Sitzung.
   *
   * Der Rückweg selbst bleibt dem Test überlassen: „Zurück zur Suche“ und
   * „Zum Start“ stehen am Sitzungsende nebeneinander, und der Unterschied
   * zwischen beiden ist genau das, was hier geprüft wird.
   */
  async function uebenUndBeenden(nutzer: Nutzer): Promise<void> {
    await nutzer.click(screen.getAllByRole('button', { name: /^Frage .+ üben$/u })[0]!);
    await screen.findByRole('button', { name: 'Sitzung beenden' });
    await nutzer.click(screen.getByRole('button', { name: 'Sitzung beenden' }));
    await screen.findByRole('button', { name: 'Zurück zur Suche' });
  }

  /*
    Bis Fassung 0.26.0 kam nur der Suchtext zurück. Wer auf einen Bereich
    eingeschränkt hatte, bekam danach die Liste über dem ganzen Katalog –
    „dieselbe Trefferliste“, wie der Knopf und das Handbuch sie versprechen,
    war sie damit nicht.

    Text und Bereich reisen deshalb als Paar mit dem Sitzungsauftrag
    (`Suchanfrage` in `lernen/typen.ts`) und nicht als Merkwert daneben. Sie
    leben genau so lange wie der Vorgang, aus dem sie stammen.
  */
  it('trägt die Bereichseinschränkung über den Rückweg aus einer Sitzung', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.click(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u }));
    await nutzer.type(feld(), 'anscheinswaffe');
    await waitFor(() => {
      expect(trefferliste()).toHaveLength(1);
    });

    await uebenUndBeenden(nutzer);
    await nutzer.click(screen.getByRole('button', { name: 'Zurück zur Suche' }));
    await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 });

    expect(feld()).toHaveValue('anscheinswaffe');
    expect(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u })).toBeChecked();
    await waitFor(() => {
      expect(trefferliste()).toHaveLength(1);
    });
  });

  /*
    Die Wache über die Verfallsregel, und sie ist die wichtigere von beiden:
    Ohne sie wäre der Kern des Entwurfs unbewacht. Die Anfrage soll den
    Vorgang NICHT überleben – wer über den Startbildschirm geht, beginnt von
    vorn. Ein Suchbegriff, der Stunden später wieder dasteht, wäre genau der
    stille Zustand, den niemand erklären kann.
  */
  it('vergisst die Anfrage auf dem Weg über den Startbildschirm', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.click(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u }));
    await nutzer.type(feld(), 'anscheinswaffe');
    await waitFor(() => {
      expect(trefferliste()).toHaveLength(1);
    });

    await uebenUndBeenden(nutzer);
    await nutzer.click(screen.getAllByRole('button', { name: 'Zum Start' })[0]!);
    await screen.findByRole('heading', { name: 'Heute lernen' });
    await nutzer.click(screen.getByRole('button', { name: 'Fragen durchsuchen' }));
    await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 });

    expect(feld()).toHaveValue('');
    expect(screen.getByRole('radio', { name: /^Alle Bereiche/u })).toBeChecked();
  });

  it('nennt den Bereich in der Ansage, auch wenn es Treffer gibt', async () => {
    /*
      Bis 0.26.0 hieß es schlicht „Eine Frage gefunden.“ – wer eingeschränkt
      hatte, erfuhr an dieser Stelle nicht, worauf sich die Zahl bezieht.
      Seit der Rückweg die Einschränkung mitbringt, wiegt das schwerer: Sie
      kann gesetzt sein, ohne dass man sie selbst gerade gesetzt hat.
    */
    const nutzer = await sucheOeffnen();
    await nutzer.click(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u }));
    await nutzer.type(feld(), 'anscheinswaffe');

    expect(await screen.findByText('Eine Frage gefunden in I.1 – Begriffe.')).toBeInTheDocument();
  });

  it('nennt die Einschränkung auch dann, wenn das Wort im ganzen Katalog fehlt', async () => {
    const nutzer = await sucheOeffnen();
    await nutzer.click(screen.getByRole('radio', { name: /^I\.1 – Begriffe/u }));
    await nutzer.type(feld(), 'zwergpudel');

    expect(await screen.findByText('Keine Frage gefunden in I.1 – Begriffe.')).toBeInTheDocument();
  });

  it('bringt die Zahl der nachgeladenen Treffer nicht mit', async () => {
    /*
      Sie ist kein gesetzter Wert, sondern eine Rolltiefe in anderer
      Schreibweise: Die Anwendung nennt sie an keiner Stelle, die beim
      Betreten im Bild steht – nur in der Knopfbeschriftung hinter allen
      Karten. Dieselbe Art Wert, die `App.tsx` für die Bildlaufstelle bereits
      verworfen hat.
    */
    const nutzer = await sucheOeffnen();
    await waitFor(() => {
      expect(trefferliste()).toHaveLength(25);
    });
    await nutzer.click(screen.getByRole('button', { name: /^Weitere \d+ anzeigen \(/u }));
    await waitFor(() => {
      expect(trefferliste().length).toBeGreaterThan(25);
    });

    await uebenUndBeenden(nutzer);
    await nutzer.click(screen.getByRole('button', { name: 'Zurück zur Suche' }));
    await screen.findByRole('heading', { name: 'Fragen durchsuchen', level: 1 });

    await waitFor(() => {
      expect(trefferliste()).toHaveLength(25);
    });
  });
});

describe('Suche – die Einstellung „Antwortmöglichkeiten mischen“ gilt auch hier', () => {
  /*
    Befund der Prüfrunde zu 0.27.2. `sitzungStarten` in `App.tsx` sagt zu, die
    Einstellung „hier anzuhängen deckt alle sechs Wege mit fester Fragenliste
    auf einmal“. Sechs gibt es, aber nur fünf gehen durch `sitzungStarten`:
    Der Weg aus der Trefferliste baut den Auftrag selbst und ließ
    `optionenMischen` weg. Wer den Schalter umlegt und aus der Suche heraus
    übt, bekam die Katalogreihenfolge.

    Gemessen mit einer festen Zufallsquelle: `mischen` sortiert nach
    `Math.random()`; eine absteigende Folge kehrt die Reihenfolge um. Ohne
    die Behebung steht „a)“ vorn, mit ihr „b)“.
  */
  function zufallAbsteigend(): void {
    let naechster = 1;
    vi.spyOn(Math, 'random').mockImplementation(() => {
      naechster -= 0.1;
      return naechster;
    });
  }

  function markenReihenfolge(): string[] {
    return [...document.querySelectorAll('.antwortoption__marke')].map(
      (knoten) => knoten.textContent,
    );
  }

  const mitMischen = (): LernAppBridge =>
    attrappe({
      einstellungenLesen: vi.fn(() =>
        Promise.resolve({
          ...EINSTELLUNGEN_STANDARD,
          zuschnittGefragt: true,
          optionenMischen: true,
        }),
      ),
    });

  it('mischt die Antworten, wenn der Schalter an ist', async () => {
    zufallAbsteigend();
    const nutzer = await sucheOeffnen(mitMischen());
    await nutzer.type(feld(), 'anscheinswaffe');
    await nutzer.click(await screen.findByRole('button', { name: 'Frage 1.01 üben' }));

    expect(await screen.findByText(/Welcher Gegenstand ist eine Anscheinswaffe\?/u)).toBeVisible();
    expect(markenReihenfolge()).toEqual(['b)', 'a)']);
  });

  it('lässt die Katalogreihenfolge stehen, wenn er aus ist', async () => {
    /* Die Gegenprobe: Der Weg darf nicht dadurch geheilt werden, dass er
       immer mischt. Ab Werk steht der Schalter aus. */
    zufallAbsteigend();
    const nutzer = await sucheOeffnen();
    await nutzer.type(feld(), 'anscheinswaffe');
    await nutzer.click(await screen.findByRole('button', { name: 'Frage 1.01 üben' }));

    expect(await screen.findByText(/Welcher Gegenstand ist eine Anscheinswaffe\?/u)).toBeVisible();
    expect(markenReihenfolge()).toEqual(['a)', 'b)']);
  });
});

describe('Suche – „alle Treffer üben“ folgt dem eingestellten Sitzungsumfang', () => {
  /*
    Befund der Prüfrunde zu 0.27.2. Seit 0.26.6 ist der Sitzungsumfang
    einstellbar; `Suchansicht` rechnete weiter mit der Konstanten
    `SITZUNGSUMFANG`. Wer fünfzig eingestellt hatte, bekam aus der
    Trefferliste zwanzig — und der Knopf sagte es auch so, ohne dass sich das
    aus der Einstellung erklären ließ.
  */
  function mitUmfang(umfang: number): LernAppBridge {
    return attrappe({
      einstellungenLesen: vi.fn(() =>
        Promise.resolve({
          ...EINSTELLUNGEN_STANDARD,
          zuschnittGefragt: true,
          sitzungsumfang: umfang,
        }),
      ),
    });
  }

  it('deckelt auf die eingestellte Zahl', async () => {
    await sucheOeffnen(mitUmfang(5));

    expect(
      await screen.findByRole('button', { name: 'Die ersten 5 von 34 Fragen üben' }),
    ).toBeVisible();
  });

  it('legt dann auch wirklich so viele vor', async () => {
    const nutzer = await sucheOeffnen(mitUmfang(5));
    await nutzer.click(await screen.findByRole('button', { name: /Die ersten 5 von 34/u }));

    expect(await screen.findByText(/Frage 1 von 5/u)).toBeInTheDocument();
  });

  it('bleibt ab Werk bei zwanzig', async () => {
    /* Die Gegenprobe: Die Vorgabe darf sich dabei nicht verschieben. */
    await sucheOeffnen();

    expect(
      await screen.findByRole('button', { name: 'Die ersten 20 von 34 Fragen üben' }),
    ).toBeVisible();
  });
});
