import { describe, test, expect, afterEach, spyOn, type Mock } from "bun:test";
import { CursorCloudClient } from "@/agent/cursor-cloud-client.js";
import { AgentError, JobTimeoutError } from "@/core/errors.js";
import { CURSOR_CLOUD_BASE_URL } from "@/core/constants.js";
import { createFakeFetch, jsonResponse } from "../_helpers/fake-fetch.js";
import { mockLogger } from "../_helpers/mock-logger.js";
import type { CursorAgent, AgentConversation } from "@/types/index.js";

const logger = mockLogger();

const API_KEY = "test-api-key";
const MODEL = "gpt-4o";
const BASE_URL = CURSOR_CLOUD_BASE_URL;

function makeClient(): CursorCloudClient {
  return new CursorCloudClient(API_KEY, MODEL, logger);
}

function makeAgent(status: CursorAgent["status"] = "FINISHED"): CursorAgent {
  return {
    id: "agent-123",
    name: "test-agent",
    status,
    source: { repository: "owner/repo", ref: "main" },
    target: { branchName: "fix/test", autoCreatePr: true, openAsCursorGithubApp: false },
    summary: "done",
    createdAt: new Date().toISOString(),
  };
}

describe("CursorCloudClient", () => {
  let fetchSpy: Mock<typeof globalThis.fetch>;

  afterEach(() => {
    fetchSpy?.mockRestore();
  });

  describe("launchAgent", () => {
    test("sends POST /v0/agents with correct body and returns agent ID", async () => {
      const { fakeFetch, calls } = createFakeFetch([
        {
          urlPattern: /\/v0\/agents$/,
          response: jsonResponse({ ...makeAgent(), id: "agent-abc" }),
        },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      const client = makeClient();
      const id = await client.launchAgent(
        "fix the bug",
        "https://github.com/owner/repo.git",
        "main",
        "fix/bug",
      );

      expect(id).toBe("agent-abc");
      expect(calls).toHaveLength(1);
      const [call] = calls;
      expect(call!.url).toBe(`${BASE_URL}/v0/agents`);
      expect(call!.init?.method).toBe("POST");

      const body = JSON.parse(call!.init?.body as string);
      expect(body.prompt.text).toBe("fix the bug");
      expect(body.model).toBe(MODEL);
      expect(body.source.repository).toBe("owner/repo");
      expect(body.source.ref).toBe("main");
      expect(body.target.branchName).toBe("fix/bug");
      expect(body.target.autoCreatePr).toBe(true);
    });

    test("extracts repo path from github.com/owner/repo.git URL", async () => {
      const { fakeFetch, calls, reset } = createFakeFetch([
        { urlPattern: /\/v0\/agents$/, response: () => jsonResponse(makeAgent()) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      await makeClient().launchAgent(
        "prompt",
        "https://github.com/my-org/my-repo.git",
        "develop",
        "fix/x",
      );

      const body = JSON.parse(calls[0]!.init?.body as string);
      expect(body.source.repository).toBe("my-org/my-repo");
      reset();
      expect(calls).toHaveLength(0);
    });

    test("throws AgentError for malformed repo URL", async () => {
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation((() =>
        Promise.resolve(new Response())) as unknown as typeof fetch);

      await expect(
        makeClient().launchAgent("prompt", "https://bitbucket.org/owner/repo", "main", "fix/x"),
      ).rejects.toThrow(AgentError);

      expect(fetchSpy).not.toHaveBeenCalled();
    });

    test("includes images in body when provided", async () => {
      const { fakeFetch, calls } = createFakeFetch([
        { urlPattern: /\/v0\/agents$/, response: jsonResponse(makeAgent()) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      const images = [{ data: "base64data", dimension: { width: 100, height: 100 } }];
      await makeClient().launchAgent(
        "prompt",
        "https://github.com/owner/repo",
        "main",
        "fix/x",
        images,
      );

      const body = JSON.parse(calls[0]!.init?.body as string);
      expect(body.prompt.images).toEqual(images);
    });
  });

  describe("pollUntilDone", () => {
    test("returns FINISHED status immediately when agent is FINISHED", async () => {
      const { fakeFetch } = createFakeFetch([
        { urlPattern: /\/v0\/agents\//, response: jsonResponse(makeAgent("FINISHED")) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      const status = await makeClient().pollUntilDone("agent-123", 60_000);
      expect(status).toBe("FINISHED");
    });

    test("returns FAILED status when agent is FAILED", async () => {
      const { fakeFetch } = createFakeFetch([
        { urlPattern: /\/v0\/agents\//, response: jsonResponse(makeAgent("FAILED")) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      const status = await makeClient().pollUntilDone("agent-123", 60_000);
      expect(status).toBe("FAILED");
    });

    test("returns STOPPED status when agent is STOPPED", async () => {
      const { fakeFetch } = createFakeFetch([
        { urlPattern: /\/v0\/agents\//, response: jsonResponse(makeAgent("STOPPED")) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      const status = await makeClient().pollUntilDone("agent-123", 60_000);
      expect(status).toBe("STOPPED");
    });

    test("throws JobTimeoutError when deadline exceeded before first poll", async () => {
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation((() =>
        Promise.resolve(
          new Response(JSON.stringify(makeAgent("RUNNING")), { status: 200 }),
        )) as unknown as typeof fetch);

      // timeoutMs=0 → deadline = Date.now()+0, which is immediately in the past
      await expect(makeClient().pollUntilDone("agent-123", 0)).rejects.toThrow(JobTimeoutError);
      // fetch should not have been called since the while loop never runs
      expect(fetchSpy).not.toHaveBeenCalled();
    });
  });

  describe("sendFollowup", () => {
    test("sends POST /v0/agents/:id/followup with prompt body", async () => {
      const { fakeFetch, calls } = createFakeFetch([
        { urlPattern: /\/followup$/, response: jsonResponse({ id: "f-1" }) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      await makeClient().sendFollowup("agent-123", "please fix lint");

      expect(calls).toHaveLength(1);
      expect(calls[0]!.url).toBe(`${BASE_URL}/v0/agents/agent-123/followup`);
      expect(calls[0]!.init?.method).toBe("POST");
      const body = JSON.parse(calls[0]!.init?.body as string);
      expect(body.prompt.text).toBe("please fix lint");
    });
  });

  describe("stopAgent", () => {
    test("sends POST /v0/agents/:id/stop", async () => {
      const { fakeFetch, calls } = createFakeFetch([
        { urlPattern: /\/stop$/, response: jsonResponse({ id: "agent-123" }) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      await makeClient().stopAgent("agent-123");

      expect(calls).toHaveLength(1);
      expect(calls[0]!.url).toBe(`${BASE_URL}/v0/agents/agent-123/stop`);
      expect(calls[0]!.init?.method).toBe("POST");
    });
  });

  describe("deleteAgent", () => {
    test("sends DELETE /v0/agents/:id", async () => {
      const { fakeFetch, calls } = createFakeFetch([
        { urlPattern: /\/v0\/agents\/agent-123$/, response: new Response(null, { status: 204 }) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      await makeClient().deleteAgent("agent-123");

      expect(calls).toHaveLength(1);
      expect(calls[0]!.url).toBe(`${BASE_URL}/v0/agents/agent-123`);
      expect(calls[0]!.init?.method).toBe("DELETE");
    });
  });

  describe("getConversation", () => {
    function makeConversation(messages: AgentConversation["messages"]): AgentConversation {
      return { id: "conv-1", messages };
    }

    test("returns joined assistant_message texts", async () => {
      const conv = makeConversation([
        { id: "1", type: "user_message", text: "user says hi" },
        { id: "2", type: "assistant_message", text: "hello there" },
        { id: "3", type: "assistant_message", text: "how can I help?" },
      ]);
      const { fakeFetch } = createFakeFetch([
        { urlPattern: /\/conversation$/, response: jsonResponse(conv) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      const result = await makeClient().getConversation("agent-123");
      expect(result).toBe("hello there\n\nhow can I help?");
    });

    test("filters out non-assistant_message types", async () => {
      const conv = makeConversation([
        { id: "1", type: "user_message", text: "ignored" },
        { id: "2", type: "assistant_message", text: "only this" },
      ]);
      const { fakeFetch } = createFakeFetch([
        { urlPattern: /\/conversation$/, response: jsonResponse(conv) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      const result = await makeClient().getConversation("agent-123");
      expect(result).toBe("only this");
    });

    test("returns empty string when no assistant messages", async () => {
      const conv = makeConversation([{ id: "1", type: "user_message", text: "hello" }]);
      const { fakeFetch } = createFakeFetch([
        { urlPattern: /\/conversation$/, response: jsonResponse(conv) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      const result = await makeClient().getConversation("agent-123");
      expect(result).toBe("");
    });
  });

  describe("getAgentDetails", () => {
    test("GETs /v0/agents/:id and returns parsed response", async () => {
      const agent = makeAgent();
      const { fakeFetch, calls } = createFakeFetch([
        { urlPattern: /\/v0\/agents\/agent-123$/, response: jsonResponse(agent) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      const result = await makeClient().getAgentDetails("agent-123");

      expect(result).toEqual(agent);
      expect(calls[0]!.url).toBe(`${BASE_URL}/v0/agents/agent-123`);
      expect(calls[0]!.init?.method).toBe("GET");
    });
  });

  describe("listModels", () => {
    test("GETs /v0/models and returns models list", async () => {
      const { fakeFetch, calls } = createFakeFetch([
        { urlPattern: /\/v0\/models$/, response: jsonResponse({ models: ["model-a", "model-b"] }) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      const result = await makeClient().listModels();

      expect(result).toEqual({ models: ["model-a", "model-b"] });
      expect(calls[0]!.url).toBe(`${BASE_URL}/v0/models`);
      expect(calls[0]!.init?.method).toBe("GET");
    });
  });

  describe("error handling", () => {
    test("throws AgentError with isRetryable=true for 429", async () => {
      const { fakeFetch } = createFakeFetch([
        { urlPattern: /\/v0\/models$/, response: new Response("{}", { status: 429 }) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      await expect(makeClient().listModels()).rejects.toMatchObject({
        name: "AgentError",
        isRetryable: true,
      });
    });

    test("throws AgentError with isRetryable=true for 500", async () => {
      const { fakeFetch } = createFakeFetch([
        { urlPattern: /\/v0\/models$/, response: new Response("{}", { status: 500 }) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      await expect(makeClient().listModels()).rejects.toMatchObject({
        name: "AgentError",
        isRetryable: true,
      });
    });

    test("throws AgentError with isRetryable=false for 400", async () => {
      const { fakeFetch } = createFakeFetch([
        { urlPattern: /\/v0\/models$/, response: new Response("{}", { status: 400 }) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      await expect(makeClient().listModels()).rejects.toMatchObject({
        name: "AgentError",
        isRetryable: false,
      });
    });

    test("handles 204 response as empty object", async () => {
      const { fakeFetch } = createFakeFetch([
        { urlPattern: /\/stop$/, response: new Response(null, { status: 204 }) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      // stopAgent calls request<{id:string}> — 204 returns {} without throwing
      await expect(makeClient().stopAgent("agent-123")).resolves.toBeUndefined();
    });

    test("uses error message from response JSON if available", async () => {
      const { fakeFetch } = createFakeFetch([
        {
          urlPattern: /\/v0\/models$/,
          response: new Response(JSON.stringify({ message: "rate limit exceeded" }), {
            status: 429,
          }),
        },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      await expect(makeClient().listModels()).rejects.toMatchObject({
        message: "rate limit exceeded",
      });
    });

    test("includes correct Authorization header in requests", async () => {
      const { fakeFetch, calls } = createFakeFetch([
        { urlPattern: /\/v0\/models$/, response: jsonResponse({ models: [] }) },
      ]);
      fetchSpy = spyOn(globalThis, "fetch").mockImplementation(fakeFetch as typeof fetch);

      await makeClient().listModels();

      const expectedAuth = "Basic " + Buffer.from(`${API_KEY}:`).toString("base64");
      const headers = calls[0]!.init?.headers as Record<string, string>;
      expect(headers["Authorization"]).toBe(expectedAuth);
    });
  });
});
