import { describe, it, expect, vi, beforeEach } from "vitest";
import type { GameRequest, AnswerSubmission } from "@lila/shared";

vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() }));

import { getGameTerms, getDistractors } from "@lila/db";
import { createGameSession, evaluateAnswer } from "./gameService.js";
import { InMemoryGameSessionStore } from "../gameSessionStore/index.js";

const mockGetGameTerms = vi.mocked(getGameTerms);
const mockGetDistractors = vi.mocked(getDistractors);

const validRequest: GameRequest = {
  source_language: "en",
  target_language: "it",
  pos: "noun",
  difficulty: "easy",
  rounds: 3,
};

const fakeTerms = [
  { termId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null },
  { termId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null },
  {
    termId: "t3",
    sourceText: "house",
    targetText: "casa",
    sourceGloss: "a building for living in",
  },
];

beforeEach(() => {
  vi.clearAllMocks();
  mockGetGameTerms.mockResolvedValue(fakeTerms);
  mockGetDistractors.mockResolvedValue([
    "wrong1",
    "wrong2",
    "wrong3",
    "wrong4",
    "wrong5",
    "wrong6",
  ]);
});

describe("createGameSession", () => {
  let store: InMemoryGameSessionStore;

  beforeEach(() => {
    store = new InMemoryGameSessionStore();
  });

  it("returns a session with the correct number of questions", async () => {
    const session = await createGameSession(validRequest, store, "user-1");

    expect(session.sessionId).toBeDefined();
    expect(session.questions).toHaveLength(3);
  });

  it("each question has exactly 4 options", async () => {
    const session = await createGameSession(validRequest, store, "user-1");

    for (const question of session.questions) {
      expect(question.options).toHaveLength(4);
    }
  });

  it("each question has a unique questionId", async () => {
    const session = await createGameSession(validRequest, store, "user-1");
    const ids = session.questions.map((q) => q.questionId);

    expect(new Set(ids).size).toBe(ids.length);
  });

  it("options have sequential optionIds 0-3", async () => {
    const session = await createGameSession(validRequest, store, "user-1");

    for (const question of session.questions) {
      const optionIds = question.options.map((o) => o.optionId);
      expect(optionIds).toEqual([0, 1, 2, 3]);
    }
  });

  it("the correct answer is always among the options", async () => {
    const session = await createGameSession(validRequest, store, "user-1");

    for (let i = 0; i < session.questions.length; i++) {
      const question = session.questions[i]!;
      const correctText = fakeTerms[i]!.targetText;
      const optionTexts = question.options.map((o) => o.text);

      expect(optionTexts).toContain(correctText);
    }
  });

  it("correct answer appears exactly once even if getDistractors returns a duplicate", async () => {
    mockGetDistractors.mockResolvedValueOnce([
      "cane",
      "wrong2",
      "wrong3",
      "wrong4",
      "wrong5",
      "wrong6",
    ]);

    const session = await createGameSession(validRequest, store, "user-1");
    const question = session.questions[0]!;
    const optionTexts = question.options.map((o) => o.text);

    expect(optionTexts.filter((t) => t === "cane")).toHaveLength(1);
    expect(question.options).toHaveLength(4);
  });

  it("sets the prompt from the source text", async () => {
    const session = await createGameSession(validRequest, store, "user-1");

    expect(session.questions[0]!.prompt).toBe("dog");
    expect(session.questions[1]!.prompt).toBe("cat");
    expect(session.questions[2]!.prompt).toBe("house");
  });

  it("passes gloss through (null or string)", async () => {
    const session = await createGameSession(validRequest, store, "user-1");

    expect(session.questions[0]!.gloss).toBeNull();
    expect(session.questions[2]!.gloss).toBe("a building for living in");
  });

  it("calls getGameTerms with the correct arguments", async () => {
    await createGameSession(validRequest, store, "user-1");

    expect(mockGetGameTerms).toHaveBeenCalledWith(
      "en",
      "it",
      "noun",
      "easy",
      3,
    );
  });

  it("calls getDistractors once per question", async () => {
    await createGameSession(validRequest, store, "user-1");

    expect(mockGetDistractors).toHaveBeenCalledTimes(3);
  });

  it("propagates unexpected errors from getGameTerms", async () => {
    mockGetGameTerms.mockRejectedValue(new Error("connection refused"));

    await expect(
      createGameSession(validRequest, store, "user-1"),
    ).rejects.toThrow("connection refused");
  });

  it("propagates getDistractors failure", async () => {
    mockGetDistractors.mockRejectedValue(new Error("db timeout"));

    await expect(
      createGameSession(validRequest, store, "user-1"),
    ).rejects.toThrow("db timeout");
  });

  it("throws when fewer than 3 unique distractors remain after deduplication", async () => {
    mockGetDistractors.mockResolvedValueOnce([
      "cane",
      "cane",
      "cane",
      "cane",
      "cane",
      "cane",
    ]);

    await expect(
      createGameSession(validRequest, store, "user-1"),
    ).rejects.toThrow("Not enough unique distractors");
  });

  it("duplicate distractors are deduplicated against each other", async () => {
    mockGetDistractors.mockResolvedValueOnce([
      "wrong1",
      "wrong1",
      "wrong1",
      "wrong2",
      "wrong3",
      "wrong4",
    ]);

    const session = await createGameSession(validRequest, store, "user-1");
    const question = session.questions[0]!;
    const optionTexts = question.options.map((o) => o.text);

    expect(new Set(optionTexts).size).toBe(4);
    expect(question.options).toHaveLength(4);
  });
});

describe("evaluateAnswer", () => {
  let store: InMemoryGameSessionStore;

  beforeEach(() => {
    store = new InMemoryGameSessionStore();
  });

  it("returns isCorrect: true when the correct option is selected", async () => {
    const session = await createGameSession(validRequest, store, "user-1");
    const question = session.questions[0]!;
    const correctText = fakeTerms[0]!.targetText;
    const correctOption = question.options.find((o) => o.text === correctText)!;

    const result = await evaluateAnswer(
      {
        sessionId: session.sessionId,
        questionId: question.questionId,
        selectedOptionId: correctOption.optionId,
      },
      store,
      "user-1",
    );

    expect(result.isCorrect).toBe(true);
    expect(result.correctOptionId).toBe(correctOption.optionId);
    expect(result.selectedOptionId).toBe(correctOption.optionId);
  });

  it("returns isCorrect: false when a wrong option is selected", async () => {
    const session = await createGameSession(validRequest, store, "user-1");
    const question = session.questions[0]!;
    const correctText = fakeTerms[0]!.targetText;
    const correctOption = question.options.find((o) => o.text === correctText)!;
    const wrongOption = question.options.find((o) => o.text !== correctText)!;

    const result = await evaluateAnswer(
      {
        sessionId: session.sessionId,
        questionId: question.questionId,
        selectedOptionId: wrongOption.optionId,
      },
      store,
      "user-1",
    );

    expect(result.isCorrect).toBe(false);
    expect(result.correctOptionId).toBe(correctOption.optionId);
    expect(result.selectedOptionId).toBe(wrongOption.optionId);
  });

  it("throws NotFoundError for a non-existent session", async () => {
    const submission: AnswerSubmission = {
      sessionId: "00000000-0000-0000-0000-000000000000",
      questionId: "00000000-0000-0000-0000-000000000000",
      selectedOptionId: 0,
    };

    await expect(evaluateAnswer(submission, store, "user-1")).rejects.toThrow(
      "Game session not found",
    );
  });

  it("throws ConflictError for a non-existent question", async () => {
    const session = await createGameSession(validRequest, store, "user-1");

    const submission: AnswerSubmission = {
      sessionId: session.sessionId,
      questionId: "00000000-0000-0000-0000-000000000000",
      selectedOptionId: 0,
    };

    await expect(
      evaluateAnswer(submission, store, "user-1"),
    ).rejects.toMatchObject({ statusCode: 409 });
  });

  it("throws ConflictError when the same question is submitted twice", async () => {
    const session = await createGameSession(validRequest, store, "user-1");
    const question = session.questions[0]!;

    await evaluateAnswer(
      {
        sessionId: session.sessionId,
        questionId: question.questionId,
        selectedOptionId: 0,
      },
      store,
      "user-1",
    );

    await expect(
      evaluateAnswer(
        {
          sessionId: session.sessionId,
          questionId: question.questionId,
          selectedOptionId: 0,
        },
        store,
        "user-1",
      ),
    ).rejects.toMatchObject({ statusCode: 409 });
  });

  it("deletes the session after the last question is answered", async () => {
    const session = await createGameSession(validRequest, store, "user-1");

    for (const question of session.questions) {
      await evaluateAnswer(
        {
          sessionId: session.sessionId,
          questionId: question.questionId,
          selectedOptionId: 0,
        },
        store,
        "user-1",
      );
    }

    await expect(
      evaluateAnswer(
        {
          sessionId: session.sessionId,
          questionId: session.questions[0]!.questionId,
          selectedOptionId: 0,
        },
        store,
        "user-1",
      ),
    ).rejects.toThrow("Game session not found");
  });

  it("throws UnprocessableEntityError when getGameTerms returns no terms", async () => {
    mockGetGameTerms.mockResolvedValue([]);

    await expect(
      createGameSession(validRequest, store, "user-1"),
    ).rejects.toMatchObject({ statusCode: 422 });
  });
});
