import { describe, expect, test } from "bun:test";
import { createCaptchaEngine } from "../src/engine.ts";
import { MemoryCaptchaStore } from "../src/stores/memory.ts";
import type { CaptchaChallengeGenerator } from "../src/types.ts";

function fixture() {
  let now = 1_700_000_000_000;
  let seed = 7;
  const generator: CaptchaChallengeGenerator = {
    type: "number",
    generate: () => ({
      type: "number",
      presentation: "visual",
      prompt: "Enter 42",
      answer: "42",
      answerKind: "text",
      inputMode: "numeric",
      audioSequence: ["four", "two"],
    }),
  };
  const engine = createCaptchaEngine({
    secret: "captcha-test-secret-with-at-least-thirty-two-characters",
    store: new MemoryCaptchaStore(),
    generators: [generator],
    defaultType: "number",
    minCompletionMs: 0,
    now: () => now,
    randomBytes(length) {
      const bytes = new Uint8Array(length);
      for (let index = 0; index < length; index += 1) {
        seed = (seed * 1664525 + 1013904223) >>> 0;
        bytes[index] = seed & 255;
      }
      return bytes;
    },
    audioRenderer: {
      contentType: "audio/wav",
      async render() {
        return new Uint8Array([82, 73, 70, 70]);
      },
    },
  });
  return {
    engine,
    advance: (milliseconds: number) => {
      now += milliseconds;
    },
  };
}

describe("self-hosted CAPTCHA engine", () => {
  test("random challenges resolve to an interactive challenge type", async () => {
    const engine = createCaptchaEngine({
      secret: "captcha-test-secret-with-at-least-thirty-two-characters",
      store: new MemoryCaptchaStore(),
      defaultType: "random",
      minCompletionMs: 0,
    });
    const supported = new Set(["number", "alpha", "alphanumeric", "calculation", "image"]);

    for (let index = 0; index < 12; index += 1) {
      const challenge = await engine.create({ action: "random-check" });
      expect(supported.has(challenge.type)).toBe(true);
      expect(challenge.type).not.toBe("random");
    }
  });

  test("creates, solves, and consumes a challenge and response token", async () => {
    const { engine } = fixture();
    const challenge = await engine.create({
      action: "signup",
      hostname: "example.test",
      sessionId: "s1",
    });
    const solved = await engine.verify({
      challengeId: challenge.id,
      action: "signup",
      answer: "42",
      hostname: "example.test",
      sessionId: "s1",
    });
    expect(solved.success).toBe(true);
    expect(solved.responseToken).toBeString();

    const accepted = await engine.verifyResponseToken({
      responseToken: solved.responseToken,
      action: "signup",
      hostname: "example.test",
      sessionId: "s1",
    });
    expect(accepted.success).toBe(true);

    const replay = await engine.verifyResponseToken({
      responseToken: solved.responseToken,
      action: "signup",
      hostname: "example.test",
      sessionId: "s1",
    });
    expect(replay).toMatchObject({ success: false, code: "already-used" });
  });

  test("rejects wrong answers and enforces attempt limits", async () => {
    const { engine } = fixture();
    const challenge = await engine.create({ action: "login", maxAttempts: 2 });
    expect(
      await engine.verify({ challengeId: challenge.id, action: "login", answer: "1" }),
    ).toMatchObject({ success: false, code: "incorrect-answer" });
    expect(
      await engine.verify({ challengeId: challenge.id, action: "login", answer: "2" }),
    ).toMatchObject({ success: false, code: "attempts-exhausted" });
  });

  test("binds challenges to actions, hosts, and sessions", async () => {
    const { engine } = fixture();
    const challenge = await engine.create({
      action: "checkout",
      hostname: "shop.test",
      sessionId: "abc",
    });
    expect(
      await engine.verify({
        challengeId: challenge.id,
        action: "login",
        answer: "42",
        hostname: "shop.test",
        sessionId: "abc",
      }),
    ).toMatchObject({ code: "action-mismatch" });
    expect(
      await engine.verify({
        challengeId: challenge.id,
        action: "checkout",
        answer: "42",
        hostname: "other.test",
        sessionId: "abc",
      }),
    ).toMatchObject({ code: "hostname-mismatch" });
    expect(
      await engine.verify({
        challengeId: challenge.id,
        action: "checkout",
        answer: "42",
        hostname: "shop.test",
        sessionId: "wrong",
      }),
    ).toMatchObject({ code: "session-mismatch" });
  });

  test("expires challenges", async () => {
    const { engine, advance } = fixture();
    const challenge = await engine.create({ action: "contact", expiresInMs: 1000 });
    advance(1001);
    expect(
      await engine.verify({ challengeId: challenge.id, action: "contact", answer: "42" }),
    ).toMatchObject({ success: false, code: "expired" });
  });

  test("protects audio with an unguessable challenge key", async () => {
    const { engine } = fixture();
    const challenge = await engine.create({ action: "contact", presentation: "audio" });
    expect(challenge.audioUrl).toContain("/audio/");
    const url = new URL(challenge.audioUrl!, "https://example.test");
    expect(await engine.renderAudio(challenge.id, "wrong")).toBeUndefined();
    const rendered = await engine.renderAudio(challenge.id, url.searchParams.get("key")!);
    expect(rendered?.contentType).toBe("audio/wav");
  });
  test("creates and verifies the not-robot checkbox challenge after the minimum completion time", async () => {
    let now = 1_700_000_000_000;
    let seed = 19;
    const engine = createCaptchaEngine({
      secret: "not-robot-test-secret-with-at-least-thirty-two-characters",
      store: new MemoryCaptchaStore(),
      minCompletionMs: 800,
      now: () => now,
      randomBytes(length) {
        const bytes = new Uint8Array(length);
        for (let index = 0; index < length; index += 1) {
          seed = (seed * 1664525 + 1013904223) >>> 0;
          bytes[index] = seed & 255;
        }
        return bytes;
      },
    });

    const challenge = await engine.create({ action: "not-robot-demo", type: "not-robot" });
    expect(challenge).toMatchObject({
      type: "not-robot",
      presentation: "invisible",
      inputMode: "none",
    });
    expect(challenge.metadata).toMatchObject({
      interaction: "checkbox",
      minCompletionMs: 800,
    });

    const tooFast = await engine.verify({
      challengeId: challenge.id,
      action: "not-robot-demo",
      honeypot: "",
      timingToken: challenge.timingToken,
    });
    expect(tooFast).toMatchObject({ success: false, code: "risk-rejected" });

    now += 800;
    const solved = await engine.verify({
      challengeId: challenge.id,
      action: "not-robot-demo",
      honeypot: "",
      timingToken: challenge.timingToken,
    });
    expect(solved.success).toBe(true);
    expect(solved.responseToken).toBeString();
  });

  test("normalizes and validates visual disturbance percentages", async () => {
    const { engine } = fixture();

    const easy = await engine.create({ action: "image-easy", disturbance: 25 });
    expect(easy.metadata?.disturbance).toBe(25);

    const hard = await engine.create({ action: "image-hard", disturbance: 75 });
    expect(hard.metadata?.disturbance).toBe(75);

    await expect(engine.create({ action: "too-easy", disturbance: 24 })).rejects.toThrow(
      "disturbance must be between 25 and 75",
    );
    await expect(engine.create({ action: "too-hard", disturbance: 76 })).rejects.toThrow(
      "disturbance must be between 25 and 75",
    );
  });

  test("resolves explicit and random image renderer styles", async () => {
    const { engine } = fixture();

    const explicit = await engine.create({
      action: "styled-explicit",
      imageStyle: "spiderweb",
    });
    expect(explicit.metadata).toMatchObject({
      requestedImageStyle: "spiderweb",
      imageStyle: "spiderweb",
    });

    const pooled = await engine.create({
      action: "styled-random",
      imageStyle: "random",
      allowedStyles: ["snow", "wave"],
    });
    const pooledStyle = pooled.metadata?.imageStyle;
    expect(typeof pooledStyle).toBe("string");
    expect(["snow", "wave"]).toContain(pooledStyle as string);
    expect(pooled.metadata?.imageStylePool).toEqual(["snow", "wave"]);

    const forced = await engine.create({
      action: "styled-forced-random",
      imageStyle: "classic",
      randomizeStyle: true,
      allowedStyles: "cut,striped",
    });
    const forcedStyle = forced.metadata?.imageStyle;
    expect(typeof forcedStyle).toBe("string");
    expect(["cut", "striped"]).toContain(forcedStyle as string);
    expect(forced.metadata?.requestedImageStyle).toBe("classic");
  });

  test("validates image renderer style pools", async () => {
    const { engine } = fixture();

    await expect(
      engine.create({
        action: "unknown-style",
        imageStyle: "unknown" as never,
      }),
    ).rejects.toThrow("imageStyle must be one of");

    await expect(
      engine.create({
        action: "empty-style-pool",
        allowedStyles: ["snow"],
        excludedStyles: ["snow"],
      }),
    ).rejects.toThrow("No CAPTCHA image styles remain");

    await expect(
      engine.create({
        action: "excluded-explicit-style",
        imageStyle: "classic",
        excludedStyles: ["classic"],
      }),
    ).rejects.toThrow("is not available in the configured style pool");
  });
});
