import { describe, expect, it } from "bun:test";
import { Effort } from "../src/model-thinking";
import { encodeResponse, encodeStream, parseRequest } from "../src/providers/openai-responses-server";
import type { AssistantMessage } from "../src/types";
import { AssistantMessageEventStream } from "../src/utils/event-stream";

function zeroUsage(): AssistantMessage["usage"] {
	return {
		input: 0,
		output: 0,
		cacheRead: 0,
		cacheWrite: 0,
		totalTokens: 0,
		cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
	};
}

async function collectStream(stream: ReadableStream<Uint8Array>): Promise<string> {
	const reader = stream.getReader();
	const decoder = new TextDecoder();
	let out = "";
	while (true) {
		const { value, done } = await reader.read();
		if (done) break;
		out += decoder.decode(value);
	}
	return out;
}

interface SseFrame {
	event: string;
	data: Record<string, unknown> | string;
}

function parseSse(raw: string): SseFrame[] {
	const frames: SseFrame[] = [];
	for (const chunk of raw.split("\n\n")) {
		if (!chunk.trim()) continue;
		let event = "";
		let dataLine = "";
		for (const line of chunk.split("\n")) {
			if (line.startsWith("event: ")) event = line.slice("event: ".length);
			else if (line.startsWith("data: ")) dataLine = line.slice("data: ".length);
		}
		if (dataLine === "[DONE]") {
			frames.push({ event: event || "done_sentinel", data: "[DONE]" });
		} else if (dataLine) {
			const parsed: unknown = JSON.parse(dataLine);
			if (parsed && typeof parsed === "object") {
				frames.push({ event, data: parsed as Record<string, unknown> });
			}
		}
	}
	return frames;
}

describe("openai-responses parseRequest", () => {
	it("parses an input array with mixed message + reasoning + function_call + function_call_output", () => {
		const reasoningItem = {
			type: "reasoning",
			id: "rs_abc",
			summary: [{ type: "summary_text", text: "The user wants arithmetic." }],
		};
		const parsed = parseRequest({
			model: "gpt-5.3-codex-spark",
			instructions: "You are X",
			input: [
				{ type: "message", role: "user", content: [{ type: "input_text", text: "what's 2+2?" }] },
				{
					type: "message",
					role: "assistant",
					content: [{ type: "output_text", text: "Let me think." }],
				},
				reasoningItem,
				{
					type: "function_call",
					id: "fc_item_999",
					call_id: "call_42",
					name: "math",
					arguments: '{"a":2,"b":2}',
				},
				{ type: "function_call_output", call_id: "call_42", output: "4" },
			],
			tools: [
				{
					type: "function",
					name: "math",
					description: "Do arithmetic",
					parameters: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } } },
					strict: true,
				},
			],
			tool_choice: { type: "function", name: "math" },
			max_output_tokens: 1024,
			temperature: 0.1,
			top_p: 0.9,
			reasoning: { effort: "high", summary: "detailed" },
			store: true,
			previous_response_id: "resp_prev",
			stream: true,
		});

		expect(parsed.modelId).toBe("gpt-5.3-codex-spark");
		expect(parsed.stream).toBe(true);
		expect(parsed.context.systemPrompt).toEqual(["You are X"]);

		const msgs = parsed.context.messages;
		expect(msgs).toHaveLength(3);

		// 1. user
		expect(msgs[0]!.role).toBe("user");
		const u = msgs[0]!;
		if (u.role !== "user") throw new Error("expected user");
		expect(u.content).toBe("what's 2+2?");

		// 2. assistant with text + reasoning + toolCall
		const a = msgs[1]!;
		if (a.role !== "assistant") throw new Error("expected assistant");
		expect(a.api).toBe("openai-responses");
		expect(a.provider).toBe("openai");
		expect(a.model).toBe("gpt-5.3-codex-spark");
		expect(a.content).toHaveLength(3);
		expect(a.content[0]).toMatchObject({ type: "text", text: "Let me think." });
		expect(a.content[1]).toMatchObject({
			type: "thinking",
			thinking: "The user wants arithmetic.",
			thinkingSignature: JSON.stringify(reasoningItem),
			itemId: "rs_abc",
		});
		// Critical: call_id and item id are distinct.
		expect(a.content[2]).toMatchObject({
			type: "toolCall",
			id: "call_42",
			name: "math",
			arguments: { a: 2, b: 2 },
			thoughtSignature: "fc_item_999",
		});

		// 3. toolResult
		const tr = msgs[2]!;
		if (tr.role !== "toolResult") throw new Error("expected toolResult");
		expect(tr.toolCallId).toBe("call_42");
		expect(tr.toolName).toBe("math");
		expect(tr.content).toEqual([{ type: "text", text: "4" }]);
		expect(tr.isError).toBe(false);

		expect(parsed.context.tools).toHaveLength(1);
		expect(parsed.context.tools![0]).toMatchObject({ name: "math", strict: true });

		expect(parsed.options.maxOutputTokens).toBe(1024);
		expect(parsed.options.temperature).toBe(0.1);
		expect(parsed.options.topP).toBe(0.9);
		expect(parsed.options.toolChoice).toEqual({ name: "math" });
		expect(parsed.options.reasoning).toBe(Effort.High);
		// `reasoning.summary: "detailed"` is treated as the default visible-summary
		// case (only "none" toggles hideThinkingSummary).
		expect(parsed.options.hideThinkingSummary).toBeUndefined();
		// `store` and `previous_response_id` are accepted by the schema but not
		// plumbed through pi-ai — they no longer leak into options.extra.
		expect(parsed.options.extra).toBeUndefined();
	});

	it("accepts a bare string input and rejects a missing model", () => {
		const parsed = parseRequest({ model: "m", input: "hi" });
		expect(parsed.context.messages).toHaveLength(1);
		const m = parsed.context.messages[0]!;
		if (m.role !== "user") throw new Error("expected user");
		expect(m.content).toBe("hi");

		expect(() => parseRequest({ input: "hi" })).toThrow(/model/);
	});

	it("preserves string message content and system input items", () => {
		const parsed = parseRequest({
			model: "m",
			instructions: "top-level instructions",
			input: [
				{ role: "system", content: "system from easy input" },
				{ role: "user", content: "hello" },
				{ role: "assistant", content: "hi there" },
				{
					type: "message",
					role: "system",
					content: [{ type: "input_text", text: "structured system" }],
				},
			],
		});

		expect(parsed.context.systemPrompt).toEqual([
			"top-level instructions",
			"system from easy input",
			"structured system",
		]);
		expect(parsed.context.messages).toHaveLength(2);
		const user = parsed.context.messages[0]!;
		const assistant = parsed.context.messages[1]!;
		if (user.role !== "user") throw new Error("expected user");
		if (assistant.role !== "assistant") throw new Error("expected assistant");
		expect(user.content).toBe("hello");
		expect(assistant.content).toEqual([{ type: "text", text: "hi there" }]);
	});

	it("creates a synthetic assistant when reasoning comes before any assistant message", () => {
		const reasoningItem = {
			type: "reasoning",
			id: "rs_x",
			content: [{ type: "reasoning_text", text: "hmm" }],
		};
		const parsed = parseRequest({
			model: "m",
			input: [reasoningItem],
		});
		expect(parsed.context.messages).toHaveLength(1);
		const a = parsed.context.messages[0]!;
		if (a.role !== "assistant") throw new Error("expected synthetic assistant");
		expect(a.content).toHaveLength(1);
		expect(a.content[0]).toMatchObject({
			type: "thinking",
			thinking: "hmm",
			thinkingSignature: JSON.stringify(reasoningItem),
			itemId: "rs_x",
		});
	});
});

describe("openai-responses encodeResponse", () => {
	it("encodes reasoning + message + function_call output items", () => {
		const reasoningItem = {
			type: "reasoning",
			id: "rs_signed",
			summary: [{ type: "summary_text", text: "thinking aloud" }],
		};
		const message: AssistantMessage = {
			role: "assistant",
			api: "openai-responses",
			provider: "openai",
			model: "gpt-5",
			content: [
				{
					type: "thinking",
					thinking: "thinking aloud",
					thinkingSignature: JSON.stringify(reasoningItem),
					itemId: "rs_signed",
				},
				{ type: "text", text: "Hello " },
				{ type: "text", text: "world" },
				{
					type: "toolCall",
					id: "call_t1",
					name: "math",
					arguments: { a: 1, b: 2 },
					thoughtSignature: "fc_item_t1",
				},
			],
			usage: {
				...zeroUsage(),
				input: 10,
				output: 20,
				cacheRead: 4,
				cacheWrite: 6,
				reasoningTokens: 5,
			},
			stopReason: "toolUse",
			timestamp: 1_700_000_000_000,
		};

		const body = encodeResponse(message, "gpt-5-requested");

		expect(body.object).toBe("response");
		expect(body.status).toBe("completed");
		expect(body.model).toBe("gpt-5-requested");
		expect(body.created_at).toBe(1_700_000_000);
		expect(typeof body.id).toBe("string");
		expect((body.id as string).startsWith("resp_")).toBe(true);

		const output = body.output as Array<Record<string, unknown>>;
		expect(output).toHaveLength(3);

		expect(output[0]).toEqual(reasoningItem);

		// Consecutive text collapses into one message item with two parts.
		expect(output[1]!.type).toBe("message");
		expect(output[1]!.role).toBe("assistant");
		const parts = output[1]!.content as Array<{ type: string; text: string; annotations: never[] }>;
		expect(parts).toEqual([
			{ type: "output_text", text: "Hello ", annotations: [] },
			{ type: "output_text", text: "world", annotations: [] },
		]);

		// function_call: wire id (thoughtSignature) and call_id are distinct.
		expect(output[2]).toMatchObject({
			type: "function_call",
			id: "fc_item_t1",
			call_id: "call_t1",
			name: "math",
			arguments: '{"a":1,"b":2}',
			status: "completed",
		});

		expect(body.usage).toEqual({
			input_tokens: 20,
			input_tokens_details: { cached_tokens: 4 },
			output_tokens: 20,
			output_tokens_details: { reasoning_tokens: 5 },
			total_tokens: 40,
		});
	});

	it("marks length-limited responses incomplete", () => {
		const message: AssistantMessage = {
			role: "assistant",
			api: "openai-responses",
			provider: "openai",
			model: "gpt-5",
			content: [{ type: "text", text: "partial" }],
			usage: zeroUsage(),
			stopReason: "length",
			timestamp: 1_700_000_000_000,
		};

		const body = encodeResponse(message, "gpt-5-requested");

		expect(body.status).toBe("incomplete");
		expect(body.incomplete_details).toEqual({ reason: "max_output_tokens" });
	});
});

describe("openai-responses encodeStream", () => {
	it("emits response.created, reasoning_summary_text.delta, output_text.delta, function_call_arguments.delta, response.completed, [DONE]", async () => {
		const stream = new AssistantMessageEventStream();

		const partial: AssistantMessage = {
			role: "assistant",
			api: "openai-responses",
			provider: "openai",
			model: "gpt-5",
			content: [],
			usage: zeroUsage(),
			stopReason: "stop",
			timestamp: 1_700_000_000_000,
		};

		const finalMessage: AssistantMessage = {
			role: "assistant",
			api: "openai-responses",
			provider: "openai",
			model: "gpt-5",
			content: [
				{ type: "thinking", thinking: "step 1", thinkingSignature: "rs_s1", itemId: "rs_s1" },
				{ type: "text", text: "Hi!" },
				{
					type: "toolCall",
					id: "call_x",
					name: "math",
					arguments: { a: 1 },
					thoughtSignature: "fc_x",
				},
			],
			usage: { ...zeroUsage(), input: 1, output: 2 },
			stopReason: "toolUse",
			timestamp: 1_700_000_000_000,
		};

		// Push events asynchronously while consumer reads.
		const partialWithThinking: AssistantMessage = {
			...partial,
			content: [{ type: "thinking", thinking: "", thinkingSignature: "rs_s1", itemId: "rs_s1" }],
		};
		const partialWithToolCall: AssistantMessage = {
			...partial,
			content: [
				{ type: "thinking", thinking: "step 1", thinkingSignature: "rs_s1", itemId: "rs_s1" },
				{ type: "text", text: "Hi!" },
				{ type: "toolCall", id: "call_x", name: "math", arguments: {}, thoughtSignature: "fc_x" },
			],
		};

		queueMicrotask(() => {
			stream.push({ type: "start", partial });
			stream.push({ type: "thinking_start", contentIndex: 0, partial: partialWithThinking });
			stream.push({ type: "thinking_delta", contentIndex: 0, delta: "step ", partial: partialWithThinking });
			stream.push({ type: "thinking_delta", contentIndex: 0, delta: "1", partial: partialWithThinking });
			stream.push({ type: "thinking_end", contentIndex: 0, content: "step 1", partial: partialWithThinking });
			stream.push({ type: "text_start", contentIndex: 1, partial });
			stream.push({ type: "text_delta", contentIndex: 1, delta: "Hi", partial });
			stream.push({ type: "text_delta", contentIndex: 1, delta: "!", partial });
			stream.push({ type: "text_end", contentIndex: 1, content: "Hi!", partial });
			stream.push({ type: "toolcall_start", contentIndex: 2, partial: partialWithToolCall });
			stream.push({ type: "toolcall_delta", contentIndex: 2, delta: '{"a":', partial: partialWithToolCall });
			stream.push({ type: "toolcall_delta", contentIndex: 2, delta: "1}", partial: partialWithToolCall });
			stream.push({
				type: "toolcall_end",
				contentIndex: 2,
				toolCall: {
					type: "toolCall",
					id: "call_x",
					name: "math",
					arguments: { a: 1 },
					thoughtSignature: "fc_x",
				},
				partial: partialWithToolCall,
			});
			stream.push({ type: "done", reason: "toolUse", message: finalMessage });
		});

		const raw = await collectStream(encodeStream(stream, "gpt-5-requested"));
		const frames = parseSse(raw);
		const names = frames.map(f => f.event);

		// Ordering: created → thinking flow → message flow → tool-call flow → completed → [DONE]
		expect(names[0]).toBe("response.created");
		expect(names[names.length - 1]).toBe("done_sentinel");
		expect(frames[frames.length - 1]!.data).toBe("[DONE]");

		// Spot-check critical events appear in the expected order.
		const idxCreated = names.indexOf("response.created");
		const idxReasoningDelta = names.indexOf("response.reasoning_summary_text.delta");
		const idxReasoningDone = names.indexOf("response.reasoning_summary_text.done");
		const idxTextDelta = names.indexOf("response.output_text.delta");
		const idxTextDone = names.indexOf("response.output_text.done");
		const idxArgsDelta = names.indexOf("response.function_call_arguments.delta");
		const idxMessageDone = frames.findIndex(
			f =>
				f.event === "response.output_item.done" &&
				(f.data as Record<string, unknown>).item &&
				((f.data as Record<string, unknown>).item as Record<string, unknown>).type === "message",
		);
		const idxArgsDone = names.indexOf("response.function_call_arguments.done");
		const idxCompleted = names.indexOf("response.completed");

		expect(idxCreated).toBeGreaterThanOrEqual(0);
		expect(idxReasoningDelta).toBeGreaterThan(idxCreated);
		expect(idxReasoningDone).toBeGreaterThan(idxReasoningDelta);
		expect(idxTextDelta).toBeGreaterThan(idxReasoningDone);
		expect(idxTextDone).toBeGreaterThan(idxTextDelta);
		expect(idxArgsDelta).toBeGreaterThan(idxTextDone);
		expect(idxArgsDone).toBeGreaterThan(idxArgsDelta);
		expect(idxCompleted).toBeGreaterThan(idxArgsDone);

		// reasoning_summary_text.delta must carry item_id matching the signature, and output_index 0.
		const reasoningDelta = frames[idxReasoningDelta]!.data as Record<string, unknown>;
		expect(reasoningDelta.item_id).toBe("rs_s1");
		expect(reasoningDelta.output_index).toBe(0);
		expect(reasoningDelta.delta).toBe("step ");

		// output_text.delta's item_id is a new msg_*, output_index moved on past the reasoning item.
		const textDelta = frames[idxTextDelta]!.data as Record<string, unknown>;
		expect(typeof textDelta.item_id).toBe("string");
		expect((textDelta.item_id as string).startsWith("msg_")).toBe(true);
		expect(textDelta.output_index).toBe(1);
		expect(textDelta.delta).toBe("Hi");
		expect(textDelta.logprobs).toEqual([]);

		const textDone = frames[idxTextDone]!.data as Record<string, unknown>;
		expect(textDone.text).toBe("Hi!");
		expect(textDone.logprobs).toEqual([]);

		const messageDone = frames[idxMessageDone]!.data as Record<string, unknown>;
		expect(messageDone.output_index).toBe(1);
		expect(messageDone.item).toMatchObject({
			type: "message",
			status: "completed",
			content: [{ type: "output_text", text: "Hi!", annotations: [] }],
		});

		// function_call_arguments.delta uses the fc_* wire id, NOT call_x.
		const argsDelta = frames[idxArgsDelta]!.data as Record<string, unknown>;
		expect(argsDelta.item_id).toBe("fc_x");
		expect(argsDelta.output_index).toBe(2);
		expect(argsDelta.delta).toBe('{"a":');

		const argsDone = frames[idxArgsDone]!.data as Record<string, unknown>;
		expect(argsDone.item_id).toBe("fc_x");
		expect(argsDone.arguments).toBe('{"a":1}');
		expect(argsDone.name).toBe("math");

		// response.completed: assert the final response object carries the full output items
		// and that call_id ≠ id for the function_call item.
		const completed = frames[idxCompleted]!.data as Record<string, unknown>;
		const response = completed.response as Record<string, unknown>;
		expect(response.status).toBe("completed");
		expect(response.model).toBe("gpt-5-requested");
		const output = response.output as Array<Record<string, unknown>>;
		expect(output).toHaveLength(3);
		expect(output[0]!.type).toBe("reasoning");
		expect(output[1]!.type).toBe("message");
		expect(output[2]).toMatchObject({
			type: "function_call",
			id: "fc_x",
			call_id: "call_x",
			name: "math",
			arguments: '{"a":1}',
		});
		// Critical gotcha: id and call_id are distinct.
		expect(output[2]!.id).not.toBe(output[2]!.call_id);
	});

	it("emits response.incomplete for length-limited streams", async () => {
		const stream = new AssistantMessageEventStream();
		const message: AssistantMessage = {
			role: "assistant",
			api: "openai-responses",
			provider: "openai",
			model: "gpt-5",
			content: [{ type: "text", text: "partial" }],
			usage: { ...zeroUsage(), output: 1 },
			stopReason: "length",
			timestamp: 1_700_000_000_000,
		};

		queueMicrotask(() => {
			stream.push({ type: "start", partial: { ...message, content: [] } });
			stream.push({ type: "text_start", contentIndex: 0, partial: message });
			stream.push({ type: "text_delta", contentIndex: 0, delta: "partial", partial: message });
			stream.push({ type: "text_end", contentIndex: 0, content: "partial", partial: message });
			stream.push({ type: "done", reason: "length", message });
		});

		const raw = await collectStream(encodeStream(stream, "gpt-5-requested"));
		const frames = parseSse(raw);
		const names = frames.map(f => f.event);
		const idxIncomplete = names.indexOf("response.incomplete");

		expect(idxIncomplete).toBeGreaterThan(-1);
		expect(names).not.toContain("response.completed");
		const incomplete = frames[idxIncomplete]!.data as Record<string, unknown>;
		const response = incomplete.response as Record<string, unknown>;
		expect(response.status).toBe("incomplete");
		expect(response.incomplete_details).toEqual({ reason: "max_output_tokens" });
	});
});