import test from "node:test";
import assert from "node:assert/strict";
import {
  calculateLeaderboardScore,
  calculateMissionScore,
  cappedAttempts,
  createRunId,
  deriveIntegrity,
  EMPTY_STATE,
  isSequenceCorrect,
  isTerminalAnswer,
  MAX_ATTEMPTS_PER_QUESTION,
  nextOpenIndexInChapter,
  registerWrongAnswer,
  STRIKE_LIMIT,
  normalizeCommand,
  sanitizeAlias,
  sanitizeProgress,
  shuffleItems,
  shuffleSequenceTokens,
  solutionText
} from "./game.js";

test("erzeugt anonyme Lauf-IDs und normalisiert kurze Aliase", () => {
  assert.equal(createRunId(() => 1_000, () => 0.5), "rs-89oqgw");
  assert.equal(sanitizeAlias("  Tux   mit Hut  "), "Tux mit Hut");
  assert.equal(sanitizeAlias("a".repeat(30)).length, 18);
});

test("normiert Kurs- und Lernmodus fair auf maximal 1000 Score", () => {
  assert.equal(
    calculateLeaderboardScore({ xp: 2700, maxXp: 2700, integrity: 100 }),
    1000
  );
  assert.equal(
    calculateLeaderboardScore({ xp: 1350, maxXp: 2700, integrity: 80 }),
    575
  );
});

test("normalisiert Leerzeichen und Pipes in Terminalbefehlen", () => {
  assert.equal(
    normalizeCommand("  cut  -d: -f1 /etc/passwd|sort  "),
    "cut -d: -f1 /etc/passwd | sort"
  );
});

test("akzeptiert nur freigegebene simulierte Terminalantworten", () => {
  assert.equal(
    isTerminalAnswer("journalctl   -b -p err", [
      "journalctl -b -p err",
      "journalctl -p err -b"
    ]),
    true
  );
  assert.equal(isTerminalAnswer("rm -rf /", ["journalctl -b -p err"]), false);
  assert.equal(isTerminalAnswer("tr a-z a-z", ["tr a-z A-Z"]), false);
});

test("prüft die Reihenfolge einer Pipeline exakt", () => {
  assert.equal(
    isSequenceCorrect(["cut", "|", "sort"], ["cut", "|", "sort"]),
    true
  );
  assert.equal(
    isSequenceCorrect(["sort", "|", "cut"], ["cut", "|", "sort"]),
    false
  );
});

test("mischt Sequenzbausteine und verrät nie direkt die Lösung", () => {
  const answer = ["#!/bin/bash", "if condition; then", "echo yes", "fi"];
  const tokens = [...answer, "restart service", "done"];
  const shuffled = shuffleSequenceTokens(tokens, answer, () => 0.999);

  assert.notDeepEqual(shuffled, tokens);
  assert.notDeepEqual(shuffled.slice(0, answer.length), answer);
  assert.deepEqual([...shuffled].sort(), [...tokens].sort());
});

test("mischt auch Auswahloptionen selbst bei unverändertem Zufallswert", () => {
  const options = ["richtig", "falsch-a", "falsch-b"];
  assert.notDeepEqual(shuffleItems(options, () => 0.999), options);
});

test("zieht Fehlversuche und Hinweise ab, aber nie unter 35 XP", () => {
  assert.equal(
    calculateMissionScore({ baseXp: 100, wrongAttempts: 2, hintUsed: true }),
    65
  );
  assert.equal(
    calculateMissionScore({ baseXp: 50, wrongAttempts: 9, hintUsed: true }),
    35
  );
});

test("verwirft einen unvollständigen gespeicherten Lauf", () => {
  const state = sanitizeProgress(
    {
      started: true,
      mode: "course",
      runQuestionIds: ["boot"],
      completed: ["boot", "falsch", "boot"],
      xp: 70,
      integrity: 999,
      hints: ["falsch"]
    },
    ["boot", "storage"]
  );
  assert.equal(state.started, false);
  assert.deepEqual(state.runQuestionIds, []);
});

test("nennt die exakte Lösung je Fragetyp", () => {
  assert.equal(
    solutionText({
      type: "choice",
      choices: [
        { label: "falsch-a", correct: false },
        { label: "ps -ef --forest", correct: true }
      ]
    }),
    "ps -ef --forest"
  );
  assert.equal(
    solutionText({
      type: "terminal",
      acceptedAnswers: ["journalctl -b -p err", "journalctl -p err -b"]
    }),
    "journalctl -b -p err"
  );
  assert.equal(
    solutionText({ type: "sequence", answer: ["cut", "|", "sort"] }),
    "cut | sort"
  );
  assert.equal(solutionText({ type: "unbekannt" }), "");
});

test("deckelt Fehlversuche und leitet die Integrität daraus ab", () => {
  assert.equal(cappedAttempts(3), 3);
  assert.equal(cappedAttempts(99), MAX_ATTEMPTS_PER_QUESTION);
  assert.equal(cappedAttempts(-4), 0);
  assert.equal(cappedAttempts("keine Zahl"), 0);

  assert.equal(deriveIntegrity({}), 100);
  assert.equal(deriveIntegrity({ boot: 3 }), 94);
  assert.equal(deriveIntegrity({ boot: 2, storage: 1 }), 94);
  assert.equal(deriveIntegrity({ boot: 99 }), 60);
  assert.equal(deriveIntegrity({ a: 20, b: 20, c: 20 }), 20);
});

function testState(overrides = {}) {
  return {
    ...EMPTY_STATE,
    started: true,
    mode: "course",
    strikes: 0,
    strikeChapterId: null,
    ...overrides
  };
}

const wrongMission = { id: "boot", xp: 100, chapterId: "kap-01" };

test("zieht bei einer falschen Antwort Punkte ab und zählt den Strike", () => {
  const outcome = registerWrongAnswer(testState(), {
    mission: wrongMission,
    chapterId: "kap-01"
  });

  assert.equal(outcome.state.attempts.boot, 1);
  assert.equal(outcome.state.integrity, 98);
  assert.equal(outcome.penalty.xp, 10);
  assert.equal(outcome.penalty.integrity, 2);
  assert.equal(outcome.strikes, 1);
  assert.equal(outcome.limitReached, false);
  assert.equal(outcome.state.strikeChapterId, "kap-01");
});

test("meldet beim dritten Fehler im selben Kapitel das Limit", () => {
  let state = testState();
  let outcome;
  for (let index = 0; index < 3; index += 1) {
    outcome = registerWrongAnswer(state, {
      mission: wrongMission,
      chapterId: "kap-01"
    });
    state = outcome.state;
  }

  assert.equal(outcome.strikes, 3);
  assert.equal(outcome.limitReached, true);
});

test("beginnt den Strike-Zähler in einem neuen Kapitel bei eins", () => {
  const first = registerWrongAnswer(testState(), {
    mission: wrongMission,
    chapterId: "kap-01"
  });
  const second = registerWrongAnswer(first.state, {
    mission: { id: "storage", xp: 90, chapterId: "kap-02" },
    chapterId: "kap-02"
  });

  assert.equal(second.strikes, 1);
  assert.equal(second.limitReached, false);
  assert.equal(second.state.strikeChapterId, "kap-02");
});

test("behauptet keinen Abzug mehr, wenn der Versuchsdeckel greift", () => {
  const outcome = registerWrongAnswer(
    testState({ attempts: { boot: MAX_ATTEMPTS_PER_QUESTION } }),
    { mission: wrongMission, chapterId: "kap-01" }
  );

  assert.equal(outcome.state.attempts.boot, MAX_ATTEMPTS_PER_QUESTION);
  assert.equal(outcome.penalty.xp, 0);
  assert.equal(outcome.penalty.integrity, 0);
});

test("hält XP, Integrität und Score deckungsgleich mit der Server-Formel", () => {
  const questions = [
    { id: "boot", xp: 100, chapterId: "kap-01" },
    { id: "storage", xp: 90, chapterId: "kap-01" }
  ];

  let state = testState({ hints: ["storage"] });
  for (let index = 0; index < 25; index += 1) {
    state = registerWrongAnswer(state, {
      mission: questions[0],
      chapterId: "kap-01"
    }).state;
  }

  const clientXp = questions.reduce(
    (total, question) =>
      total +
      calculateMissionScore({
        baseXp: question.xp,
        wrongAttempts: cappedAttempts(state.attempts[question.id]),
        hintUsed: state.hints.includes(question.id)
      }),
    0
  );
  const clientScore = calculateLeaderboardScore({
    xp: clientXp,
    maxXp: 190,
    integrity: state.integrity
  });

  // Erwartungswerte von Hand nach server/score_api.py:128-147:
  //   boot:    max(35, 100 - 20*10 - 0) = 35
  //   storage: max(35,  90 -  0    - 15) = 75  → xp 110
  //   mistakes = 20 → integrity = max(20, 100 - 40) = 60
  //   score = round(110/190 * 750 + 60 * 2.5) = 584
  // Bewusst hartkodiert: eine hier nachgebaute Formel würde dieselben
  // Bausteine benutzen und deshalb auch eine falsche Ableitung bestätigen.
  assert.equal(state.attempts.boot, MAX_ATTEMPTS_PER_QUESTION);
  assert.equal(clientXp, 110);
  assert.equal(state.integrity, 60);
  assert.equal(clientScore, 584);
});

test("findet die erste offene Frage eines Kapitels", () => {
  const runQuestions = [
    { id: "a1", chapterId: "kap-01" },
    { id: "a2", chapterId: "kap-01" },
    { id: "b1", chapterId: "kap-02" }
  ];

  assert.equal(nextOpenIndexInChapter(runQuestions, ["a1"], "kap-01"), 1);
  assert.equal(nextOpenIndexInChapter(runQuestions, [], "kap-02"), 2);
  assert.equal(nextOpenIndexInChapter(runQuestions, ["a1", "a2"], "kap-01"), -1);
});

test("überspringt die aktuelle Frage und läuft am Kapitelende um", () => {
  const runQuestions = [
    { id: "a1", chapterId: "kap-01" },
    { id: "a2", chapterId: "kap-01" },
    { id: "a3", chapterId: "kap-01" },
    { id: "b1", chapterId: "kap-02" }
  ];

  // Falsche Antwort auf a1: a1 bleibt offen, gezeigt wird trotzdem a2.
  assert.equal(nextOpenIndexInChapter(runQuestions, [], "kap-01", 0), 1);
  // Am Kapitelende zurück auf die übersprungene a1.
  assert.equal(nextOpenIndexInChapter(runQuestions, ["a2", "a3"], "kap-01", 2), 0);
  // Gelöste Fragen werden übersprungen.
  assert.equal(nextOpenIndexInChapter(runQuestions, ["a2"], "kap-01", 0), 2);
  // Vollständiges Kapitel meldet nichts Offenes mehr.
  assert.equal(
    nextOpenIndexInChapter(runQuestions, ["a1", "a2", "a3"], "kap-01", 0),
    -1
  );
});

test("bewahrt Strikes über einen Neuladen und klemmt sie ein", () => {
  // started: false ist Absicht. Die Laufprüfung greift nur bei started: true
  // und würde einen zweielementigen Kurslauf verwerfen; geprüft wird hier die
  // Feldübernahme, nicht die Laufvalidierung.
  const state = sanitizeProgress(
    {
      started: false,
      mode: "course",
      runQuestionIds: ["boot", "storage"],
      completed: ["boot"],
      attempts: { boot: 99, unbekannt: 5 },
      hints: [],
      strikes: 7,
      strikeChapterId: "kap-01"
    },
    ["boot", "storage"],
    ["kap-01", "kap-02"]
  );

  assert.equal(state.strikes, STRIKE_LIMIT);
  assert.equal(state.strikeChapterId, "kap-01");
  assert.equal(state.attempts.boot, MAX_ATTEMPTS_PER_QUESTION);
  assert.equal(state.attempts.unbekannt, undefined);
  assert.equal(state.integrity, 60);
});

test("verwirft ein unbekanntes Strike-Kapitel", () => {
  const state = sanitizeProgress(
    {
      started: false,
      mode: "course",
      runQuestionIds: ["boot", "storage"],
      strikes: 2,
      strikeChapterId: "erfunden"
    },
    ["boot", "storage"],
    ["kap-01"]
  );

  assert.equal(state.strikeChapterId, null);
  assert.equal(state.strikes, 2);
});
