import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentConfigInfo, AgentEvent, AgentInfo } from "./agents";
import { agentsApi, subscribeAgentStream } from "./agents";

const mockFetch = vi.fn();

beforeEach(() => {
	vi.stubGlobal("fetch", mockFetch);
});

afterEach(() => {
	vi.restoreAllMocks();
});

function okResponse(body: unknown) {
	return new Response(JSON.stringify(body), {
		status: 200,
		headers: { "Content-Type": "application/json" },
	});
}

function errorResponse(status: number, text: string) {
	return new Response(text, { status });
}

const sampleAgent: AgentInfo = {
	story_id: "42_story_test",
	agent_name: "coder",
	status: "running",
	session_id: null,
	worktree_path: null,
	base_branch: null,
	log_session_id: null,
};

const sampleConfig: AgentConfigInfo = {
	name: "coder",
	role: "engineer",
	stage: "coder",
	model: "claude-sonnet-4-6",
	allowed_tools: null,
	max_turns: null,
	max_budget_usd: null,
};

// ── agentsApi ────────────────────────────────────────────────────────────────

describe("agentsApi", () => {
	describe("startAgent", () => {
		it("sends POST to /agents/start with story_id", async () => {
			mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));

			const result = await agentsApi.startAgent("42_story_test");

			expect(mockFetch).toHaveBeenCalledWith(
				"/api/agents/start",
				expect.objectContaining({
					method: "POST",
					body: JSON.stringify({
						story_id: "42_story_test",
						agent_name: undefined,
					}),
				}),
			);
			expect(result).toEqual(sampleAgent);
		});

		it("sends POST with optional agent_name", async () => {
			mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));

			await agentsApi.startAgent("42_story_test", "coder");

			expect(mockFetch).toHaveBeenCalledWith(
				"/api/agents/start",
				expect.objectContaining({
					body: JSON.stringify({
						story_id: "42_story_test",
						agent_name: "coder",
					}),
				}),
			);
		});

		it("uses custom baseUrl when provided", async () => {
			mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));

			await agentsApi.startAgent(
				"42_story_test",
				undefined,
				"http://localhost:3002/api",
			);

			expect(mockFetch).toHaveBeenCalledWith(
				"http://localhost:3002/api/agents/start",
				expect.objectContaining({ method: "POST" }),
			);
		});
	});

	describe("stopAgent", () => {
		it("sends POST to /agents/stop with story_id and agent_name", async () => {
			mockFetch.mockResolvedValueOnce(okResponse(true));

			const result = await agentsApi.stopAgent("42_story_test", "coder");

			expect(mockFetch).toHaveBeenCalledWith(
				"/api/agents/stop",
				expect.objectContaining({
					method: "POST",
					body: JSON.stringify({
						story_id: "42_story_test",
						agent_name: "coder",
					}),
				}),
			);
			expect(result).toBe(true);
		});

		it("uses custom baseUrl when provided", async () => {
			mockFetch.mockResolvedValueOnce(okResponse(false));

			await agentsApi.stopAgent(
				"42_story_test",
				"coder",
				"http://localhost:3002/api",
			);

			expect(mockFetch).toHaveBeenCalledWith(
				"http://localhost:3002/api/agents/stop",
				expect.objectContaining({ method: "POST" }),
			);
		});
	});

	describe("listAgents", () => {
		it("sends GET to /agents and returns agent list", async () => {
			mockFetch.mockResolvedValueOnce(okResponse([sampleAgent]));

			const result = await agentsApi.listAgents();

			expect(mockFetch).toHaveBeenCalledWith(
				"/api/agents",
				expect.objectContaining({}),
			);
			expect(result).toEqual([sampleAgent]);
		});

		it("returns empty array when no agents running", async () => {
			mockFetch.mockResolvedValueOnce(okResponse([]));

			const result = await agentsApi.listAgents();
			expect(result).toEqual([]);
		});

		it("uses custom baseUrl when provided", async () => {
			mockFetch.mockResolvedValueOnce(okResponse([]));

			await agentsApi.listAgents("http://localhost:3002/api");

			expect(mockFetch).toHaveBeenCalledWith(
				"http://localhost:3002/api/agents",
				expect.objectContaining({}),
			);
		});
	});

	describe("getAgentConfig", () => {
		it("sends GET to /agents/config and returns config list", async () => {
			mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));

			const result = await agentsApi.getAgentConfig();

			expect(mockFetch).toHaveBeenCalledWith(
				"/api/agents/config",
				expect.objectContaining({}),
			);
			expect(result).toEqual([sampleConfig]);
		});

		it("uses custom baseUrl when provided", async () => {
			mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));

			await agentsApi.getAgentConfig("http://localhost:3002/api");

			expect(mockFetch).toHaveBeenCalledWith(
				"http://localhost:3002/api/agents/config",
				expect.objectContaining({}),
			);
		});
	});

	describe("reloadConfig", () => {
		it("sends POST to /agents/config/reload", async () => {
			mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));

			const result = await agentsApi.reloadConfig();

			expect(mockFetch).toHaveBeenCalledWith(
				"/api/agents/config/reload",
				expect.objectContaining({ method: "POST" }),
			);
			expect(result).toEqual([sampleConfig]);
		});

		it("uses custom baseUrl when provided", async () => {
			mockFetch.mockResolvedValueOnce(okResponse([]));

			await agentsApi.reloadConfig("http://localhost:3002/api");

			expect(mockFetch).toHaveBeenCalledWith(
				"http://localhost:3002/api/agents/config/reload",
				expect.objectContaining({ method: "POST" }),
			);
		});
	});

	describe("error handling", () => {
		it("throws on non-ok response with body text", async () => {
			mockFetch.mockResolvedValueOnce(errorResponse(404, "agent not found"));

			await expect(agentsApi.listAgents()).rejects.toThrow("agent not found");
		});

		it("throws with status code when no body", async () => {
			mockFetch.mockResolvedValueOnce(errorResponse(500, ""));

			await expect(agentsApi.listAgents()).rejects.toThrow(
				"Request failed (500)",
			);
		});
	});
});

// ── subscribeAgentStream ─────────────────────────────────────────────────────

interface MockESInstance {
	url: string;
	onmessage: ((e: { data: string }) => void) | null;
	onerror: ((e: Event) => void) | null;
	close: ReturnType<typeof vi.fn>;
	simulateMessage: (data: unknown) => void;
	simulateError: (e: Event) => void;
}

function makeMockEventSource() {
	const instances: MockESInstance[] = [];

	class MockEventSource {
		onmessage: ((e: { data: string }) => void) | null = null;
		onerror: ((e: Event) => void) | null = null;
		close = vi.fn();

		constructor(public url: string) {
			instances.push(this as unknown as MockESInstance);
		}

		simulateMessage(data: unknown) {
			this.onmessage?.({ data: JSON.stringify(data) });
		}

		simulateError(e: Event) {
			this.onerror?.(e);
		}
	}

	return { MockEventSource, instances };
}

describe("subscribeAgentStream", () => {
	let instances: MockESInstance[];

	beforeEach(() => {
		const { MockEventSource, instances: inst } = makeMockEventSource();
		instances = inst;
		vi.stubGlobal("EventSource", MockEventSource);
	});

	it("creates an EventSource with encoded story and agent in URL", () => {
		subscribeAgentStream("42_story_test", "coder", vi.fn());

		expect(instances).toHaveLength(1);
		expect(instances[0].url).toContain(
			`/agents/${encodeURIComponent("42_story_test")}/${encodeURIComponent("coder")}/stream`,
		);
	});

	it("calls onEvent when a message is received", () => {
		const onEvent = vi.fn();
		subscribeAgentStream("42_story_test", "coder", onEvent);

		const event: AgentEvent = { type: "output", text: "hello" };
		instances[0].simulateMessage(event);

		expect(onEvent).toHaveBeenCalledWith(event);
	});

	it("closes EventSource on 'done' type event", () => {
		subscribeAgentStream("42_story_test", "coder", vi.fn());

		instances[0].simulateMessage({ type: "done" });

		expect(instances[0].close).toHaveBeenCalled();
	});

	it("closes EventSource on 'error' type event", () => {
		subscribeAgentStream("42_story_test", "coder", vi.fn());

		instances[0].simulateMessage({
			type: "error",
			message: "something failed",
		});

		expect(instances[0].close).toHaveBeenCalled();
	});

	it("closes EventSource on status=stopped event", () => {
		subscribeAgentStream("42_story_test", "coder", vi.fn());

		instances[0].simulateMessage({ type: "status", status: "stopped" });

		expect(instances[0].close).toHaveBeenCalled();
	});

	it("does not close on status=running event", () => {
		subscribeAgentStream("42_story_test", "coder", vi.fn());

		instances[0].simulateMessage({ type: "status", status: "running" });

		expect(instances[0].close).not.toHaveBeenCalled();
	});

	it("does not close on 'output' event", () => {
		subscribeAgentStream("42_story_test", "coder", vi.fn());

		instances[0].simulateMessage({ type: "output", text: "building..." });

		expect(instances[0].close).not.toHaveBeenCalled();
	});

	it("calls onError and closes on EventSource onerror", () => {
		const onError = vi.fn();
		subscribeAgentStream("42_story_test", "coder", vi.fn(), onError);

		const err = new Event("error");
		instances[0].simulateError(err);

		expect(onError).toHaveBeenCalledWith(err);
		expect(instances[0].close).toHaveBeenCalled();
	});

	it("closes EventSource when onError is not provided", () => {
		subscribeAgentStream("42_story_test", "coder", vi.fn());

		const err = new Event("error");
		instances[0].simulateError(err);

		expect(instances[0].close).toHaveBeenCalled();
	});

	it("closes EventSource when cleanup function is called", () => {
		const cleanup = subscribeAgentStream("42_story_test", "coder", vi.fn());

		cleanup();

		expect(instances[0].close).toHaveBeenCalled();
	});

	it("handles malformed JSON without throwing", () => {
		subscribeAgentStream("42_story_test", "coder", vi.fn());

		expect(() => {
			instances[0].onmessage?.({ data: "{ not valid json" });
		}).not.toThrow();
	});

	it("delivers multiple events before a terminal event", () => {
		const onEvent = vi.fn();
		subscribeAgentStream("42_story_test", "coder", onEvent);

		instances[0].simulateMessage({ type: "output", text: "line 1" });
		instances[0].simulateMessage({ type: "output", text: "line 2" });
		instances[0].simulateMessage({ type: "done" });

		expect(onEvent).toHaveBeenCalledTimes(3);
		expect(instances[0].close).toHaveBeenCalledTimes(1);
	});
});
