import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { api, ChatWebSocket, resolveWsHost } from "./client";
import { installRpcMock } from "./__test_utils__/mockRpcWebSocket";

const mockFetch = vi.fn();

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

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

describe("api client", () => {
	describe("getCurrentProject", () => {
		it("dispatches project.current RPC and returns the path", async () => {
			const rpc = installRpcMock();
			rpc.respond("project.current", "/home/user/project");

			const result = await api.getCurrentProject();

			expect(rpc.calls).toEqual([{ method: "project.current", params: {} }]);
			expect(result).toBe("/home/user/project");
		});

		it("returns null when no project open", async () => {
			const rpc = installRpcMock();
			rpc.respond("project.current", null);

			const result = await api.getCurrentProject();
			expect(result).toBeNull();
		});
	});

	describe("openProject", () => {
		it("dispatches project.open RPC with path and returns the canonical path", async () => {
			const rpc = installRpcMock();
			rpc.respond("project.open", { path: "/home/user/project" });

			const result = await api.openProject("/home/user/project");

			expect(rpc.calls).toEqual([
				{
					method: "project.open",
					params: { path: "/home/user/project" },
				},
			]);
			expect(result).toBe("/home/user/project");
		});
	});

	describe("closeProject", () => {
		it("dispatches project.close RPC and returns ok", async () => {
			const rpc = installRpcMock();
			rpc.respond("project.close", { ok: true });

			const result = await api.closeProject();

			expect(rpc.calls).toEqual([{ method: "project.close", params: {} }]);
			expect(result).toBe(true);
		});
	});

	describe("forgetKnownProject", () => {
		it("dispatches project.forget RPC with path", async () => {
			const rpc = installRpcMock();
			rpc.respond("project.forget", { ok: true });

			const result = await api.forgetKnownProject("/some/path");

			expect(rpc.calls).toEqual([
				{ method: "project.forget", params: { path: "/some/path" } },
			]);
			expect(result).toBe(true);
		});
	});

	describe("setModelPreference", () => {
		it("dispatches model.set_preference RPC", async () => {
			const rpc = installRpcMock();
			rpc.respond("model.set_preference", { ok: true });

			await api.setModelPreference("claude-sonnet-4-6");

			expect(rpc.calls).toEqual([
				{
					method: "model.set_preference",
					params: { model: "claude-sonnet-4-6" },
				},
			]);
		});
	});

	describe("setAnthropicApiKey", () => {
		it("dispatches anthropic.set_api_key RPC", async () => {
			const rpc = installRpcMock();
			rpc.respond("anthropic.set_api_key", { ok: true });

			await api.setAnthropicApiKey("sk-ant-xxx");

			expect(rpc.calls).toEqual([
				{
					method: "anthropic.set_api_key",
					params: { api_key: "sk-ant-xxx" },
				},
			]);
		});
	});

	describe("cancelChat", () => {
		it("dispatches chat.cancel RPC", async () => {
			const rpc = installRpcMock();
			rpc.respond("chat.cancel", { ok: true });

			await api.cancelChat();

			expect(rpc.calls).toEqual([{ method: "chat.cancel", params: {} }]);
		});
	});

	describe("getKnownProjects", () => {
		it("dispatches project.known RPC and returns the path list", async () => {
			const rpc = installRpcMock();
			rpc.respond("project.known", ["/a", "/b"]);

			const result = await api.getKnownProjects();
			expect(rpc.calls).toEqual([{ method: "project.known", params: {} }]);
			expect(result).toEqual(["/a", "/b"]);
		});
	});

	describe("error handling", () => {
		it("surfaces RPC errors visibly", async () => {
			const rpc = installRpcMock();
			rpc.respondError("project.current", "store offline", "INTERNAL");

			await expect(api.getCurrentProject()).rejects.toThrow("store offline");
		});

		it("surfaces RPC errors visibly for write methods", async () => {
			const rpc = installRpcMock();
			rpc.respondError("project.open", "No such directory", "INTERNAL");

			await expect(api.openProject("/some/path")).rejects.toThrow(
				"No such directory",
			);
		});

	});

	describe("resolveWsHost", () => {
		it("uses env port in dev mode", () => {
			expect(resolveWsHost(true, "4200", "example.com")).toBe("127.0.0.1:4200");
		});

		it("defaults to 3001 in dev mode when no env port", () => {
			expect(resolveWsHost(true, undefined, "example.com")).toBe(
				"127.0.0.1:3001",
			);
		});

		it("uses location host in production", () => {
			expect(resolveWsHost(false, "4200", "myapp.com:8080")).toBe(
				"myapp.com:8080",
			);
		});
	});
});

// ── ChatWebSocket reconnect tests ───────────────────────────────────────────

interface MockWsInstance {
	onopen: (() => void) | null;
	onclose: (() => void) | null;
	onmessage: ((e: { data: string }) => void) | null;
	onerror: (() => void) | null;
	readyState: number;
	sentMessages: string[];
	send: (data: string) => void;
	close: () => void;
	simulateClose: () => void;
	simulateMessage: (data: Record<string, unknown>) => void;
}

function makeMockWebSocket() {
	const instances: MockWsInstance[] = [];

	class MockWebSocket {
		static readonly CONNECTING = 0;
		static readonly OPEN = 1;
		static readonly CLOSING = 2;
		static readonly CLOSED = 3;

		onopen: (() => void) | null = null;
		onclose: (() => void) | null = null;
		onmessage: ((e: { data: string }) => void) | null = null;
		onerror: (() => void) | null = null;
		readyState = 0;
		sentMessages: string[] = [];

		constructor(_url: string) {
			instances.push(this as unknown as MockWsInstance);
		}

		send(data: string) {
			this.sentMessages.push(data);
		}

		close() {
			this.readyState = 3;
			this.onclose?.();
		}

		simulateClose() {
			this.readyState = 3;
			this.onclose?.();
		}

		simulateMessage(data: Record<string, unknown>) {
			this.onmessage?.({ data: JSON.stringify(data) });
		}
	}

	return { MockWebSocket, instances };
}

describe("ChatWebSocket", () => {
	beforeEach(() => {
		vi.useFakeTimers();
		const { MockWebSocket } = makeMockWebSocket();
		vi.stubGlobal("WebSocket", MockWebSocket);
		// Reset shared static state between tests
		(ChatWebSocket as unknown as { sharedSocket: null }).sharedSocket = null;
		(ChatWebSocket as unknown as { refCount: number }).refCount = 0;
	});

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

	it("schedules reconnect after socket closes unexpectedly", () => {
		const { MockWebSocket, instances } = makeMockWebSocket();
		vi.stubGlobal("WebSocket", MockWebSocket);

		const ws = new ChatWebSocket();
		ws.connect({});

		expect(instances).toHaveLength(1);

		instances[0].simulateClose();

		// No new socket created yet
		expect(instances).toHaveLength(1);

		// Advance past the initial 1s reconnect delay
		vi.advanceTimersByTime(1001);

		// A new socket should now have been created
		expect(instances).toHaveLength(2);
	});

	it("delivers pipeline_state after reconnect", () => {
		const { MockWebSocket, instances } = makeMockWebSocket();
		vi.stubGlobal("WebSocket", MockWebSocket);

		const onPipelineState = vi.fn();
		const ws = new ChatWebSocket();
		ws.connect({ onPipelineState });

		// Simulate server restart
		instances[0].simulateClose();
		vi.advanceTimersByTime(1001);

		// Server pushes pipeline_state on fresh connection
		const freshState = {
			backlog: [{ story_id: "1_story_test", name: "Test", error: null }],
			current: [],
			qa: [],
			merge: [],
			done: [],
			deterministic_merges_in_flight: [],
		};
		instances[1].simulateMessage({ type: "pipeline_state", ...freshState });

		expect(onPipelineState).toHaveBeenCalledWith(freshState);
	});

	it("does not reconnect after explicit close()", () => {
		const { MockWebSocket, instances } = makeMockWebSocket();
		vi.stubGlobal("WebSocket", MockWebSocket);

		const ws = new ChatWebSocket();
		ws.connect({});

		// Explicit close disables reconnect
		ws.close();

		// Advance through both the DEV close-defer (250ms) and reconnect window
		vi.advanceTimersByTime(2000);

		// No new socket should be created
		expect(instances).toHaveLength(1);
	});

	it("uses exponential backoff on repeated failures", () => {
		const { MockWebSocket, instances } = makeMockWebSocket();
		vi.stubGlobal("WebSocket", MockWebSocket);

		const ws = new ChatWebSocket();
		ws.connect({});

		// First close → reconnects after 1s
		instances[0].simulateClose();
		vi.advanceTimersByTime(1001);
		expect(instances).toHaveLength(2);

		// Second close → reconnects after 2s (doubled)
		instances[1].simulateClose();
		vi.advanceTimersByTime(1500);
		// Not yet (delay is now 2s)
		expect(instances).toHaveLength(2);
		vi.advanceTimersByTime(600);
		expect(instances).toHaveLength(3);
	});

	it("resets reconnect delay after successful open", () => {
		const { MockWebSocket, instances } = makeMockWebSocket();
		vi.stubGlobal("WebSocket", MockWebSocket);

		const ws = new ChatWebSocket();
		ws.connect({});

		// Disconnect and reconnect twice to raise the delay
		instances[0].simulateClose();
		vi.advanceTimersByTime(1001);

		instances[1].simulateClose();
		vi.advanceTimersByTime(2001);

		// Simulate a successful open on third socket — resets delay to 1s
		instances[2].onopen?.();

		// Close again — should use the reset 1s delay
		instances[2].simulateClose();
		vi.advanceTimersByTime(1001);

		expect(instances).toHaveLength(4);
	});
});

describe("ChatWebSocket heartbeat", () => {
	beforeEach(() => {
		vi.useFakeTimers();
		const { MockWebSocket } = makeMockWebSocket();
		vi.stubGlobal("WebSocket", MockWebSocket);
		(ChatWebSocket as unknown as { sharedSocket: null }).sharedSocket = null;
		(ChatWebSocket as unknown as { refCount: number }).refCount = 0;
	});

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

	it("sends ping after heartbeat interval", () => {
		const { MockWebSocket, instances } = makeMockWebSocket();
		vi.stubGlobal("WebSocket", MockWebSocket);

		const ws = new ChatWebSocket();
		ws.connect({});
		instances[0].readyState = 1; // OPEN
		instances[0].onopen?.(); // starts heartbeat

		vi.advanceTimersByTime(29_999);
		expect(instances[0].sentMessages).toHaveLength(0);

		vi.advanceTimersByTime(1);
		expect(instances[0].sentMessages).toHaveLength(1);
		expect(JSON.parse(instances[0].sentMessages[0])).toEqual({ type: "ping" });

		ws.close();
	});

	it("closes stale connection when pong is not received", () => {
		const { MockWebSocket, instances } = makeMockWebSocket();
		vi.stubGlobal("WebSocket", MockWebSocket);

		const ws = new ChatWebSocket();
		ws.connect({});
		instances[0].readyState = 1; // OPEN
		instances[0].onopen?.(); // starts heartbeat

		// Fire heartbeat — sends ping and starts pong timeout
		vi.advanceTimersByTime(30_000);

		// No pong received; advance past pong timeout → socket closed → reconnect scheduled
		vi.advanceTimersByTime(5_000);

		// Advance past reconnect delay
		vi.advanceTimersByTime(1_001);

		expect(instances).toHaveLength(2);
		ws.close();
	});

	it("does not close when pong is received before timeout", () => {
		const { MockWebSocket, instances } = makeMockWebSocket();
		vi.stubGlobal("WebSocket", MockWebSocket);

		const ws = new ChatWebSocket();
		ws.connect({});
		instances[0].readyState = 1; // OPEN
		instances[0].onopen?.(); // starts heartbeat

		// Fire heartbeat
		vi.advanceTimersByTime(30_000);

		// Server responds with pong — clears the pong timeout
		instances[0].simulateMessage({ type: "pong" });

		// Advance past where pong timeout would have fired
		vi.advanceTimersByTime(5_001);

		// No reconnect triggered
		expect(instances).toHaveLength(1);
		ws.close();
	});

	it("stops sending pings after explicit close", () => {
		const { MockWebSocket, instances } = makeMockWebSocket();
		vi.stubGlobal("WebSocket", MockWebSocket);

		const ws = new ChatWebSocket();
		ws.connect({});
		instances[0].readyState = 1; // OPEN
		instances[0].onopen?.(); // starts heartbeat

		ws.close();

		// Advance well past multiple heartbeat intervals
		vi.advanceTimersByTime(90_000);

		expect(instances[0].sentMessages).toHaveLength(0);
	});
});
