import { readFile } from "node:fs/promises";
import { expect, test } from "@playwright/test";
import { chapters, questionsById } from "../lib/question-bank.js";

async function mockScoreboard(page) {
  const scores = [];
  await page.route("**/kernel-panic/api/scores", async (route) => {
    if (route.request().method() === "POST") {
      const payload = route.request().postDataJSON();
      scores.unshift({
        alias: payload.alias,
        score: 1000,
        mode: payload.mode === "learning" ? "Lernmodus" : "Kurslauf",
        questionCount: payload.runQuestionIds.length,
        integrity: 100,
        clean: payload.outcomes.filter(
          ([, wrongAttempts, hintUsed]) => wrongAttempts === 0 && !hintUsed
        ).length,
        mistakes: payload.outcomes.reduce(
          (sum, [, wrongAttempts]) => sum + wrongAttempts,
          0
        ),
        hints: payload.outcomes.filter(([, , hintUsed]) => hintUsed).length,
        createdAt: "2026-07-30T10:00:00+00:00"
      });
      await route.fulfill({
        status: 201,
        contentType: "application/json",
        body: JSON.stringify({
          entry: scores[0],
          duplicate: false,
          scores
        })
      });
      return;
    }
    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({ scores })
    });
  });
}

async function solveCurrentQuestion(page) {
  const panel = page.locator(".puzzle-panel");
  const questionId = await panel.getAttribute("data-question-id");
  const question = questionsById[questionId];
  expect(question, `Unbekannte Frage im DOM: ${questionId}`).toBeTruthy();

  if (question.type === "choice") {
    const correct = question.choices.find((choice) => choice.correct);
    await page
      .locator(".choice-list")
      .getByText(correct.label, { exact: true })
      .locator("..")
      .click();
  }

  if (question.type === "terminal") {
    await page.getByLabel("Simuliertes Terminal").fill(question.acceptedAnswers[0]);
    await page.getByRole("button", { name: "Ausführen" }).click();
  }

  if (question.type === "sequence") {
    const pool = page.locator(".token-pool");
    const original = question.tokens;
    const rendered = await pool.getByRole("button").allTextContents();
    expect(rendered).not.toEqual(original);
    expect(rendered.slice(0, question.answer.length)).not.toEqual(question.answer);

    const used = new Map();
    for (const value of question.answer) {
      const occurrence = used.get(value) ?? 0;
      await pool
        .getByRole("button", { name: value, exact: true })
        .nth(occurrence)
        .click();
      used.set(value, occurrence + 1);
    }
    await page.getByRole("button", { name: "Reihenfolge prüfen" }).click();
  }

  await expect(page.getByText("FRAGE GELÖST")).toBeVisible();
}

async function answerCurrentQuestionWrong(page) {
  const panel = page.locator(".puzzle-panel");
  const questionId = await panel.getAttribute("data-question-id");
  const question = questionsById[questionId];
  expect(question, `Unbekannte Frage im DOM: ${questionId}`).toBeTruthy();

  if (question.type === "choice") {
    const wrong = question.choices.find((choice) => !choice.correct);
    await page
      .locator(".choice-list")
      .getByText(wrong.label, { exact: true })
      .locator("..")
      .click();
  }

  if (question.type === "terminal") {
    await page
      .getByLabel("Simuliertes Terminal")
      .fill("definitiv-kein-gueltiger-befehl");
    await page.getByRole("button", { name: "Ausführen" }).click();
  }

  if (question.type === "sequence") {
    // Ein einzelner Baustein ist bei mehrteiliger Lösung immer zu kurz.
    await page.locator(".token-pool").getByRole("button").first().click();
    await page.getByRole("button", { name: "Reihenfolge prüfen" }).click();
  }

  await expect(page.getByTestId("wrong-feedback")).toBeVisible();
  return question;
}

function solutionOf(question) {
  if (question.type === "choice") {
    return question.choices.find((choice) => choice.correct).label;
  }
  if (question.type === "terminal") return question.acceptedAnswers[0];
  return question.answer.join(" ");
}

test("Kurslauf löst 30 Zufallsfragen und baut das System auf", async ({
  page
}) => {
  test.setTimeout(90_000);
  const errors = [];
  page.on("console", (message) => {
    if (message.type() === "error") errors.push(message.text());
  });
  await mockScoreboard(page);

  await page.goto("/kernel-panic/");
  await expect(page).toHaveTitle(/Kernel Panic/);
  await expect(
    page.getByRole("heading", { name: /KERNEL PANIC/i })
  ).toBeVisible();
  await page.getByRole("button", { name: /Kurslauf starten/ }).click();

  const seenQuestions = new Set();
  let successMoments = 0;
  for (let index = 0; index < 30; index += 1) {
    const questionId = await page
      .locator(".puzzle-panel")
      .getAttribute("data-question-id");
    seenQuestions.add(questionId);
    await solveCurrentQuestion(page);

    const chapterEnds = (index + 1) % 3 === 0;
    if (!chapterEnds) {
      await page.getByRole("button", { name: "Nächste Frage →" }).click();
      continue;
    }

    const chapter = chapters.find(
      (item) => item.id === questionsById[questionId].chapterId
    );
    await page
      .getByRole("button", { name: "Kapitel abschließen →" })
      .click();
    await expect(page.locator(".chapter-success")).toBeVisible();
    await expect(
      page.getByRole("heading", { name: chapter.success.headline })
    ).toBeVisible();
    await expect(page.getByText(chapter.success.summary)).toBeVisible();
    successMoments += 1;

    if (successMoments === 1) {
      await page.screenshot({
        path: "/tmp/kernel-panic-chapter-success.png",
        fullPage: true
      });
    }

    await page
      .getByRole("button", {
        name:
          index === 29
            ? "Gesamtsystem hochfahren →"
            : /Weiter zu Kapitel \d+ →/
      })
      .click();
  }

  await expect(
    page.getByRole("heading", { name: /Dein Linux läuft/i })
  ).toBeVisible();
  await expect(page.getByText("10 / 10")).toBeVisible();
  await expect(page.getByText("30", { exact: true })).toBeVisible();
  await expect(page.getByText("MISSION COMPLETE")).toBeVisible();
  expect(seenQuestions.size).toBe(30);
  expect(successMoments).toBe(10);
  expect(errors).toEqual([]);

  await page.getByLabel("Alias eintragen").fill("Tux-Test");
  await page.getByRole("button", { name: "Score eintragen" }).click();
  await expect(page.getByText("Tux-Test", { exact: true })).toBeVisible();
  await expect(
    page.getByText("Score gespeichert – willkommen im Maschinenraum.")
  ).toBeVisible();
  await page.screenshot({
    path: "/tmp/kernel-panic-completion.png",
    fullPage: true
  });

  await page.reload();
  await expect(page.getByText("Tux-Test", { exact: true })).toBeVisible();
});

test("falsche Antwort zieht Punkte ab, zeigt die Lösung und schaltet weiter", async ({
  page
}) => {
  await page.goto("/kernel-panic/");
  await page.getByRole("button", { name: /Lernmodus starten/ }).click();

  const first = await page
    .locator(".puzzle-panel")
    .getAttribute("data-question-id");
  const question = await answerCurrentQuestionWrong(page);

  const box = page.getByTestId("wrong-feedback");
  await expect(box).toContainText("FEHLER IM SYSTEM");
  await expect(box).toContainText("−10 XP");
  await expect(box).toContainText("Fehler 1 von 3");
  await expect(box.locator("code")).toHaveText(solutionOf(question));
  await expect(
    page.getByLabel("Spielstatus").getByText("98%", { exact: true })
  ).toBeVisible();
  await page.screenshot({
    path: "/tmp/kernel-panic-wrong-feedback.png",
    fullPage: true
  });

  // Auto-Advance ohne Klick.
  await expect(page.locator(".puzzle-panel")).not.toHaveAttribute(
    "data-question-id",
    first,
    { timeout: 8_000 }
  );
  await expect(
    page.getByLabel("Spielstatus").getByText("0/100", { exact: true })
  ).toBeVisible();
});

test("Weiter-Button überspringt die Wartezeit nach einem Fehler", async ({
  page
}) => {
  await page.goto("/kernel-panic/");
  await page.getByRole("button", { name: /Lernmodus starten/ }).click();

  const first = await page
    .locator(".puzzle-panel")
    .getAttribute("data-question-id");
  await answerCurrentQuestionWrong(page);
  await page
    .getByTestId("wrong-feedback")
    .getByRole("button", { name: "Weiter →" })
    .click();

  await expect(page.locator(".puzzle-panel")).not.toHaveAttribute(
    "data-question-id",
    first
  );
  await expect(page.getByTestId("wrong-feedback")).toHaveCount(0);
});

test("verzichtet bei reduzierter Bewegung auf den Countdown", async ({
  page
}) => {
  // page.emulateMedia statt test.use({ reducedMotion }): letzteres kommt in
  // diesem Setup nicht im Browser an, matchMedia meldet dann false und der
  // Test prüfte unbemerkt den normalen Pfad.
  await page.emulateMedia({ reducedMotion: "reduce" });
  await page.goto("/kernel-panic/");
  expect(
    await page.evaluate(
      () => window.matchMedia("(prefers-reduced-motion: reduce)").matches
    )
  ).toBe(true);
  await page.getByRole("button", { name: /Lernmodus starten/ }).click();

  const first = await page
    .locator(".puzzle-panel")
    .getAttribute("data-question-id");
  await answerCurrentQuestionWrong(page);

  const box = page.getByTestId("wrong-feedback");
  await expect(box).toContainText("Es geht gleich automatisch weiter");
  await expect(box.locator(".auto-advance-bar")).toHaveCount(0);
  await expect(box.getByRole("button", { name: "Weiter →" })).toBeVisible();

  // Der Auto-Advance bleibt aktiv, nur ohne Animation.
  await expect(page.locator(".puzzle-panel")).not.toHaveAttribute(
    "data-question-id",
    first,
    { timeout: 8_000 }
  );
});

test("drei Fehler setzen auf den Kapitelanfang zurück", async ({ page }) => {
  test.setTimeout(60_000);
  await page.goto("/kernel-panic/");
  await page.getByRole("button", { name: /Kurslauf starten/ }).click();

  const firstOfChapter = await page
    .locator(".puzzle-panel")
    .getAttribute("data-question-id");

  for (let index = 0; index < 2; index += 1) {
    await answerCurrentQuestionWrong(page);
    await page
      .getByTestId("wrong-feedback")
      .getByRole("button", { name: "Weiter →" })
      .click();
  }

  const question = await answerCurrentQuestionWrong(page);
  const box = page.getByTestId("wrong-feedback");
  await expect(box).toContainText("Fehler 3 von 3");
  // Auch beim dritten Fehler bleibt die Lösung lesbar, bevor das Kapitel
  // neu startet.
  await expect(box.locator("code")).toHaveText(solutionOf(question));
  await box.getByRole("button", { name: "Kapitel neu starten →" }).click();

  await expect(page.locator(".strike-reset")).toBeVisible();
  await expect(
    page.getByRole("heading", { name: /Kernel Panic im Kapitel/ })
  ).toBeVisible();
  // Die Fehlversuche aller Kapitelfragen, nicht nur der gelösten. Bewusst auf
  // die Kachel gescopt: ein bloßes getByText("3") träfe auch "Offen 3".
  await expect(
    page
      .locator(".chapter-success-stats div")
      .filter({ hasText: "Fehlversuche" })
      .locator("strong")
  ).toHaveText("3");
  // Erst nach der Einblendung fotografieren, sonst zeigt das Bild die
  // halbtransparente Zwischenstufe.
  await expect(page.locator(".strike-reset-card")).toHaveCSS("opacity", "1");
  await page.screenshot({
    path: "/tmp/kernel-panic-strike-reset.png",
    fullPage: true
  });
  await page.getByRole("button", { name: "Kapitel neu starten →" }).click();

  await expect(page.locator(".puzzle-panel")).toHaveAttribute(
    "data-question-id",
    firstOfChapter
  );

  // Fortschritt bleibt erhalten, nur die Integrität ist gesunken.
  await expect(
    page.getByLabel("Spielstatus").getByText("0/30", { exact: true })
  ).toBeVisible();
  await expect(
    page.getByLabel("Spielstatus").getByText("94%", { exact: true })
  ).toBeVisible();
});

test("ein ausstehender Kapitel-Reset lässt sich nicht durch Neuladen umgehen", async ({
  page
}) => {
  await page.goto("/kernel-panic/");
  await page.getByRole("button", { name: /Kurslauf starten/ }).click();
  await expect(page.locator(".puzzle-panel")).toBeVisible();

  const runIds = await page.evaluate(
    (key) => JSON.parse(window.localStorage.getItem(key)).runQuestionIds,
    "kernel-panic-progress-v2"
  );
  const firstOfChapter = runIds[0];
  const chapterId = questionsById[firstOfChapter].chapterId;

  await page.evaluate(
    ({ key, chapter }) => {
      const state = JSON.parse(window.localStorage.getItem(key));
      state.strikes = 3;
      state.strikeChapterId = chapter;
      window.localStorage.setItem(key, JSON.stringify(state));
    },
    { key: "kernel-panic-progress-v2", chapter: chapterId }
  );

  await page.reload();

  await expect(page.locator(".puzzle-panel")).toHaveAttribute(
    "data-question-id",
    firstOfChapter
  );

  // Der Zähler ist verbraucht: der nächste Fehler ist wieder Fehler 1.
  await answerCurrentQuestionWrong(page);
  await expect(page.getByTestId("wrong-feedback")).toContainText(
    "Fehler 1 von 3"
  );
});

test("Lernübersicht lässt sich laden und drucken", async ({ page }) => {
  test.setTimeout(150_000);
  await mockScoreboard(page);
  // Druckdialog nie öffnen — er würde den Test blockieren.
  await page.addInitScript(() => {
    window.print = () => {
      window.__printed = true;
    };
  });

  await page.goto("/kernel-panic/");
  await page.getByRole("button", { name: /Kurslauf starten/ }).click();

  await answerCurrentQuestionWrong(page);
  await page
    .getByTestId("wrong-feedback")
    .getByRole("button", { name: "Weiter →" })
    .click();

  // Budget: 30 Lösungen (inklusive der wiedervorgelegten Frage) plus
  // 10 Kapitel-Erfolgsbildschirme = 40 Durchläufe. 60 lässt Luft.
  for (let index = 0; index < 60; index += 1) {
    if (await page.locator(".completion").isVisible().catch(() => false)) break;

    const chapterSuccess = page.locator(".chapter-success");
    if (await chapterSuccess.isVisible().catch(() => false)) {
      await chapterSuccess.getByRole("button").click();
      continue;
    }

    await solveCurrentQuestion(page);
    await page
      .getByRole("button", { name: /Nächste Frage →|Kapitel abschließen →/ })
      .click();
  }

  await expect(
    page.getByRole("heading", { name: /Dein Linux läuft/i })
  ).toBeVisible();

  const download = page.waitForEvent("download");
  await page.getByRole("button", { name: "Spickzettel als .md laden" }).click();
  const file = await download;
  expect(file.suggestedFilename()).toMatch(
    /^kernel-panic-spickzettel-kurslauf-\d{4}-\d{2}-\d{2}\.md$/
  );
  // Nicht nur, dass ein Download startet: der Blob wird direkt nach dem Klick
  // wieder freigegeben, deshalb hier den tatsächlichen Inhalt prüfen.
  const markdown = await readFile(await file.path(), "utf8");
  expect(markdown).toContain("# Kernel Panic — mein Lern-Spickzettel");
  expect(markdown).toContain("Diese Fragen saßen nicht sofort (1)");
  expect(markdown).toContain("**Lösung:**");
  expect(markdown).not.toMatch(/Alias|Stiebitz/);

  await page.getByRole("button", { name: "Druckansicht öffnen" }).click();
  await expect(page.locator(".study-sheet")).toBeVisible();
  await expect(
    page.getByRole("heading", { name: /mein Lern-Spickzettel/ })
  ).toBeVisible();
  await expect(
    page.getByText(/Diese Fragen saßen nicht sofort \(1\)/)
  ).toBeVisible();
  await page.screenshot({
    path: "/tmp/kernel-panic-study-sheet.png",
    fullPage: true
  });

  await page.getByRole("button", { name: "Jetzt drucken" }).click();
  expect(await page.evaluate(() => window.__printed)).toBe(true);

  // Die Druckregeln greifen nur unter media: print. Ohne diese Emulation prüft
  // kein Test den Zustand, der am Ende auf dem Papier landet.
  await page.emulateMedia({ media: "print" });
  await expect(page.locator(".study-sheet-actions")).toBeHidden();
  await page.screenshot({
    path: "/tmp/kernel-panic-study-sheet-print.png",
    fullPage: true
  });

  for (const width of [1280, 390]) {
    await page.setViewportSize({ width, height: 900 });
    const dimensions = await page.evaluate(() => ({
      scrollWidth: document.documentElement.scrollWidth,
      clientWidth: document.documentElement.clientWidth
    }));
    expect(
      dimensions.scrollWidth,
      `horizontaler Überlauf bei ${width}px in der Druckansicht`
    ).toBeLessThanOrEqual(dimensions.clientWidth);
  }

  await page.emulateMedia({ media: null });
});

test("Lernmodus startet mit allen 100 Fragen", async ({ page }) => {
  await page.goto("/kernel-panic/");
  await page.getByRole("button", { name: /Lernmodus starten/ }).click();
  await expect(
    page.getByLabel("Spielstatus").getByText("0/100", { exact: true })
  ).toBeVisible();
  await expect(page.getByText(/Frage 1\/10 · Gesamt 1\/100/)).toBeVisible();
});

test("zufällige Fragenauswahl bleibt nach Neuladen stabil", async ({ page }) => {
  await page.goto("/kernel-panic/");
  await page.getByRole("button", { name: /Kurslauf starten/ }).click();
  const firstQuestion = await page
    .locator(".puzzle-panel")
    .getAttribute("data-question-id");

  await page.reload();
  await expect(page.locator(".puzzle-panel")).toHaveAttribute(
    "data-question-id",
    firstQuestion
  );
  await expect(
    page.getByLabel("Spielstatus").getByText("0/30", { exact: true })
  ).toBeVisible();
});

test("mobile Startansicht hat keinen horizontalen Überlauf", async ({
  page
}) => {
  await page.setViewportSize({ width: 390, height: 844 });
  await page.goto("/kernel-panic/");
  await expect(
    page.getByRole("button", { name: /Kurslauf starten/ })
  ).toBeVisible();

  const dimensions = await page.evaluate(() => ({
    scrollWidth: document.documentElement.scrollWidth,
    clientWidth: document.documentElement.clientWidth
  }));
  expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
});

test("Downloads und Sicherheitsheader werden ausgeliefert", async ({
  request
}) => {
  const pageResponse = await request.get("/kernel-panic/");
  expect(pageResponse.status()).toBe(200);
  const pageBody = await pageResponse.text();
  const contentPolicy =
    pageResponse.headers()["content-security-policy"] ?? pageBody;
  expect(contentPolicy).toContain("object-src");
  expect(contentPolicy).toContain("none");
  expect(pageResponse.headers()["x-content-type-options"]).toBe("nosniff");

  const pdfResponse = await request.get(
    "/kernel-panic/kernel-panic-projektarbeit.pdf"
  );
  expect(pdfResponse.status()).toBe(200);
  expect(pdfResponse.headers()["content-type"]).toContain("application/pdf");

  const zipResponse = await request.get(
    "/kernel-panic/kernel-panic-lpic-projekt.zip"
  );
  expect(zipResponse.status()).toBe(200);
  expect(zipResponse.headers()["content-type"]).toMatch(
    /application\/(zip|octet-stream)/
  );

  const opsNote = await request.get("/kernel-panic/ops-note.txt");
  expect(opsNote.status()).toBe(200);
  expect(await opsNote.text()).toContain("SQLite");

  const treasure = await request.get(
    "/kernel-panic/assets/scoreboard-backup.sqlite"
  );
  expect(treasure.status()).toBe(200);
  const signature = (await treasure.body()).subarray(0, 16).toString();
  expect(signature).toBe("SQLite format 3\u0000");

  if (process.env.BASE_URL) {
    const health = await request.get("/kernel-panic/api/health");
    expect(health.status()).toBe(200);
    expect(await health.json()).toEqual({ status: "ok" });

    const scores = await request.get("/kernel-panic/api/scores");
    expect(scores.status()).toBe(200);
    expect(Array.isArray((await scores.json()).scores)).toBe(true);
  }
});
