import {
	type CodexAppServerNotification,
	getCodexAppServerClient,
} from "@singularity-forge/openai-codex-provider";
import { resolveWireModelId } from "../model-identity.js";
import { supportsXhigh } from "../models.js";
import type {
	Api,
	AssistantMessage,
	Context,
	ImageContent,
	Model,
	SimpleStreamOptions,
	StreamFunction,
	StreamOptions,
	ToolCall,
	Usage,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { parseStreamingJson } from "../utils/json-parse.js";
import { convertResponsesMessages } from "./openai-responses-shared.js";
import {
	buildBaseOptions,
	clampReasoning,
	resolveReasoningLevel,
} from "./simple-options.js";

export interface OpenAICodexResponsesOptions extends StreamOptions {
	reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
	reasoningSummary?: "auto" | "concise" | "detailed" | "off" | "on" | null;
	textVerbosity?: "low" | "medium" | "high";
	network_access?: boolean;
	web_search?: boolean;
}

type AppServerReasoningSummary = "auto" | "concise" | "detailed" | "none";
type JsonObject = Record<string, unknown>;

interface ThreadStartResponse {
	thread: { id: string };
}

interface TurnStartResponse {
	turn: { id: string };
}

interface AppServerItem {
	type: string;
	id?: string;
	text?: string;
	summary?: string[];
	content?: string[];
	server?: string;
	tool?: string;
	namespace?: string | null;
	arguments?: unknown;
	query?: string;
}

interface TokenUsageBreakdown {
	totalTokens: number;
	inputTokens: number;
	cachedInputTokens: number;
	outputTokens: number;
	reasoningOutputTokens: number;
}

const CODEX_TOOL_CALL_PROVIDERS = new Set([
	"openai",
	"openai-codex",
	"opencode",
]);

/**
 * Stream a Codex turn through the installed `codex app-server`.
 *
 * Purpose: reuse Codex CLI's authenticated JSON-RPC backend instead of maintaining a hand-rolled ChatGPT transport.
 * Consumer: built-in provider registry for the `openai-codex-responses` API.
 */
export const streamOpenAICodexResponses: StreamFunction<
	"openai-codex-responses",
	OpenAICodexResponsesOptions
> = (
	model: Model<"openai-codex-responses">,
	context: Context,
	options?: OpenAICodexResponsesOptions,
): AssistantMessageEventStream => {
	const stream = new AssistantMessageEventStream();

	(async () => {
		const output = createAssistantMessage(model);
		let client: Awaited<ReturnType<typeof getCodexAppServerClient>> | undefined;
		try {
			client = await getCodexAppServerClient({
				cwd: process.cwd(),
				extraArgs: buildProcessConfig(model, options),
			});
			const thread = await client.request(
				"thread/start",
				buildThreadStartParams(model, context, options),
				options?.signal,
			);
			const threadId = readThreadId(thread);
			await injectPriorContext(
				client,
				threadId,
				model,
				context,
				options?.signal,
			);

			const turnInput = buildTurnInput(context, model);
			stream.push({ type: "start", partial: output });

			let activeTurnId: string | undefined;
			let cleanupTurnNotifications: (() => void) | undefined;
			const turnDone = new Promise<void>((resolve, reject) => {
				const mapper = new CodexNotificationMapper(
					threadId,
					output,
					stream,
					resolve,
					reject,
				);
				const unsubscribe = client!.onNotification((notification) =>
					mapper.handle(notification),
				);
				const onAbort = () => {
					if (activeTurnId) {
						client!.interruptTurn(threadId, activeTurnId).catch(() => {});
					}
					reject(new Error("Request was aborted"));
				};
				options?.signal?.addEventListener("abort", onAbort, { once: true });
				mapper.onDispose = () => {
					unsubscribe();
					options?.signal?.removeEventListener("abort", onAbort);
				};
				cleanupTurnNotifications = mapper.onDispose;
			});

			let turn: unknown;
			try {
				turn = await client.request(
					"turn/start",
					buildTurnStartParams(threadId, turnInput, model, options),
					options?.signal,
				);
			} catch (error) {
				cleanupTurnNotifications?.();
				throw error;
			}
			activeTurnId = readTurnId(turn);
			await turnDone;

			if (options?.signal?.aborted) {
				throw new Error("Request was aborted");
			}
			stream.push({
				type: "done",
				reason: output.stopReason === "toolUse" ? "toolUse" : "stop",
				message: output,
			});
			stream.end();
		} catch (error) {
			output.stopReason = options?.signal?.aborted ? "aborted" : "error";
			output.errorMessage =
				error instanceof Error ? error.message : String(error);
			stream.push({ type: "error", reason: output.stopReason, error: output });
			stream.end(output);
		} finally {
			client?.releaseIfIdle().catch(() => {});
		}
	})();

	return stream;
};

/**
 * Stream a simple Codex request while preserving the shared pi-ai option surface.
 *
 * Purpose: map simple reasoning options to Codex app-server turn parameters without requiring callers to know app-server details.
 * Consumer: built-in provider registry `streamSimple` calls.
 */
export const streamSimpleOpenAICodexResponses: StreamFunction<
	"openai-codex-responses",
	SimpleStreamOptions
> = (
	model: Model<"openai-codex-responses">,
	context: Context,
	options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
	const base = buildBaseOptions(model, options);
	const effectiveReasoning = resolveReasoningLevel(model, options?.reasoning);
	const reasoningEffort = supportsXhigh(model)
		? effectiveReasoning
		: clampReasoning(effectiveReasoning);

	return streamOpenAICodexResponses(model, context, {
		...base,
		reasoningEffort,
	} satisfies OpenAICodexResponsesOptions);
};

class CodexNotificationMapper {
	private readonly blocksByItemId = new Map<string, number>();
	private usage: Usage | undefined;
	onDispose?: () => void;

	constructor(
		private readonly threadId: string,
		private readonly output: AssistantMessage,
		private readonly stream: AssistantMessageEventStream,
		private readonly resolve: () => void,
		private readonly reject: (reason: Error) => void,
	) {}

	handle(notification: CodexAppServerNotification): void {
		try {
			const params = asObject(notification.params);
			const notificationThreadId = readString(params?.threadId);
			if (
				notificationThreadId !== undefined &&
				notificationThreadId !== this.threadId
			)
				return;

			if (notification.method === "item/started")
				this.handleItemStarted(params);
			else if (notification.method === "item/agent_message/delta")
				this.handleAgentMessageDelta(params);
			else if (
				notification.method === "item/reasoning/text_delta" ||
				notification.method === "item/reasoning/summary_text_delta"
			)
				this.handleReasoningDelta(params);
			else if (notification.method === "item/completed")
				this.handleItemCompleted(params);
			else if (notification.method === "response/item/completed")
				this.handleRawResponseItemCompleted(params);
			else if (notification.method === "thread/token_usage/updated")
				this.handleUsage(params);
			else if (notification.method === "turn/completed")
				this.handleTurnCompleted(params);
			else if (notification.method === "turn/failed")
				this.reject(new Error(readErrorMessage(params) ?? "Codex turn failed"));
		} catch (error) {
			this.dispose();
			this.reject(error instanceof Error ? error : new Error(String(error)));
		}
	}

	private handleItemStarted(params: JsonObject | undefined): void {
		const item = asObject(params?.item) as unknown as AppServerItem | undefined;
		if (!item?.id) return;
		if (item.type === "agentMessage") this.startText(item.id);
		else if (item.type === "reasoning") this.startThinking(item.id);
	}

	private handleAgentMessageDelta(params: JsonObject | undefined): void {
		const itemId = readString(params?.itemId);
		const delta = readString(params?.delta);
		if (!itemId || delta === undefined) return;
		const index = this.startText(itemId);
		const block = this.output.content[index];
		if (block?.type !== "text") return;
		block.text += delta;
		this.stream.push({
			type: "text_delta",
			contentIndex: index,
			delta,
			partial: this.output,
		});
	}

	private handleReasoningDelta(params: JsonObject | undefined): void {
		const itemId = readString(params?.itemId);
		const delta = readString(params?.delta);
		if (!itemId || delta === undefined) return;
		const index = this.startThinking(itemId);
		const block = this.output.content[index];
		if (block?.type !== "thinking") return;
		block.thinking += delta;
		this.stream.push({
			type: "thinking_delta",
			contentIndex: index,
			delta,
			partial: this.output,
		});
	}

	private handleItemCompleted(params: JsonObject | undefined): void {
		const item = asObject(params?.item) as unknown as AppServerItem | undefined;
		if (!item?.id) return;
		if (item.type === "agentMessage") this.endText(item.id, item.text ?? "");
		else if (item.type === "reasoning")
			this.endThinking(
				item.id,
				[...(item.summary ?? []), ...(item.content ?? [])].join("\n\n"),
			);
		else if (
			item.type === "dynamicToolCall" ||
			item.type === "mcpToolCall" ||
			item.type === "webSearch"
		) {
			this.emitToolCall(item);
		}
	}

	private handleRawResponseItemCompleted(params: JsonObject | undefined): void {
		const item = asObject(params?.item);
		if (readString(item?.type) !== "function_call") return;
		const callId = readString(item?.call_id);
		const name = readString(item?.name);
		if (!callId || !name) return;
		this.emitToolCall({
			type: "function_call",
			id: callId,
			tool: name,
			arguments: readString(item?.arguments) ?? "{}",
		});
	}

	private handleUsage(params: JsonObject | undefined): void {
		const tokenUsage = asObject(params?.tokenUsage);
		const last = asObject(tokenUsage?.last) as unknown as
			| TokenUsageBreakdown
			| undefined;
		if (!last) return;
		this.usage = {
			input: Math.max(0, last.inputTokens - last.cachedInputTokens),
			output: last.outputTokens,
			cacheRead: last.cachedInputTokens,
			cacheWrite: 0,
			totalTokens: last.totalTokens,
			cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
		};
	}

	private handleTurnCompleted(params: JsonObject | undefined): void {
		const turn = asObject(params?.turn);
		const status = readString(turn?.status);
		if (this.usage) this.output.usage = this.usage;
		this.output.stopReason =
			status === "interrupted"
				? "aborted"
				: status === "failed"
					? "error"
					: this.output.stopReason;
		this.dispose();
		if (this.output.stopReason === "aborted") {
			this.reject(new Error("Request was aborted"));
			return;
		}
		if (this.output.stopReason === "error") {
			this.reject(new Error(readErrorMessage(turn) ?? "Codex turn failed"));
			return;
		}
		this.resolve();
	}

	private startText(itemId: string): number {
		const existing = this.blocksByItemId.get(itemId);
		if (existing !== undefined) return existing;
		const index = this.output.content.push({ type: "text", text: "" }) - 1;
		this.blocksByItemId.set(itemId, index);
		this.stream.push({
			type: "text_start",
			contentIndex: index,
			partial: this.output,
		});
		return index;
	}

	private endText(itemId: string, text: string): void {
		const index = this.startText(itemId);
		const block = this.output.content[index];
		if (block?.type !== "text") return;
		if (text) block.text = text;
		block.textSignature = itemId;
		this.stream.push({
			type: "text_end",
			contentIndex: index,
			content: block.text,
			partial: this.output,
		});
	}

	private startThinking(itemId: string): number {
		const existing = this.blocksByItemId.get(itemId);
		if (existing !== undefined) return existing;
		const index =
			this.output.content.push({ type: "thinking", thinking: "" }) - 1;
		this.blocksByItemId.set(itemId, index);
		this.stream.push({
			type: "thinking_start",
			contentIndex: index,
			partial: this.output,
		});
		return index;
	}

	private endThinking(itemId: string, thinking: string): void {
		const index = this.startThinking(itemId);
		const block = this.output.content[index];
		if (block?.type !== "thinking") return;
		if (thinking) block.thinking = thinking;
		block.thinkingSignature = itemId;
		this.stream.push({
			type: "thinking_end",
			contentIndex: index,
			content: block.thinking,
			partial: this.output,
		});
	}

	private emitToolCall(item: AppServerItem): void {
		const name =
			item.type === "webSearch"
				? "web_search"
				: [item.namespace, item.server, item.tool].filter(Boolean).join(".");
		if (!name) return;
		const toolCall: ToolCall = {
			type: "toolCall",
			id: item.id ?? `${name}-${Date.now()}`,
			name,
			arguments: normalizeArguments(
				item.type === "webSearch"
					? { query: item.query ?? "" }
					: item.arguments,
			),
		};
		const index = this.output.content.push(toolCall) - 1;
		this.stream.push({
			type: "toolcall_start",
			contentIndex: index,
			partial: this.output,
		});
		this.stream.push({
			type: "toolcall_end",
			contentIndex: index,
			toolCall,
			partial: this.output,
		});
		this.output.stopReason = "toolUse";
	}

	private dispose(): void {
		this.onDispose?.();
		this.onDispose = undefined;
	}
}

function createAssistantMessage(
	model: Model<"openai-codex-responses">,
): AssistantMessage {
	return {
		role: "assistant",
		content: [],
		api: "openai-codex-responses" as Api,
		provider: model.provider,
		model: model.id,
		usage: {
			input: 0,
			output: 0,
			cacheRead: 0,
			cacheWrite: 0,
			totalTokens: 0,
			cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
		},
		stopReason: "stop",
		timestamp: Date.now(),
	};
}

function buildThreadStartParams(
	model: Model<"openai-codex-responses">,
	context: Context,
	options?: OpenAICodexResponsesOptions,
): JsonObject {
	const params: JsonObject = {
		model: resolveWireModelId(model),
		cwd: process.cwd(),
		baseInstructions: context.systemPrompt ?? null,
		approvalPolicy: "never",
		sandbox: "workspace-write",
		experimentalRawEvents: true,
		persistExtendedHistory: true,
	};
	const config = buildConfig(model, options);
	if (Object.keys(config).length > 0) params.config = config;
	return params;
}

function buildTurnStartParams(
	threadId: string,
	input: JsonObject[],
	model: Model<"openai-codex-responses">,
	options?: OpenAICodexResponsesOptions,
): JsonObject {
	return {
		threadId,
		input,
		cwd: process.cwd(),
		model: resolveWireModelId(model),
		effort: options?.reasoningEffort
			? clampReasoningEffort(model.id, options.reasoningEffort)
			: null,
		summary: normalizeReasoningSummary(options?.reasoningSummary),
	};
}

function buildProcessConfig(
	model: Model<"openai-codex-responses">,
	options?: OpenAICodexResponsesOptions,
): string[] {
	const config = buildConfig(model, options);
	return Object.entries(config).flatMap(([key, value]) => [
		"-c",
		`${key}=${JSON.stringify(value)}`,
	]);
}

function buildConfig(
	model: Model<"openai-codex-responses">,
	options?: OpenAICodexResponsesOptions,
): JsonObject {
	const config: JsonObject = { model: resolveWireModelId(model) };
	if (options?.reasoningEffort)
		config.model_reasoning_effort = clampReasoningEffort(
			model.id,
			options.reasoningEffort,
		);
	if (typeof options?.network_access === "boolean")
		config.network_access = options.network_access;
	if (typeof options?.web_search === "boolean")
		config.web_search = options.web_search;
	return config;
}

async function injectPriorContext(
	client: Awaited<ReturnType<typeof getCodexAppServerClient>>,
	threadId: string,
	model: Model<"openai-codex-responses">,
	context: Context,
	signal?: AbortSignal,
): Promise<void> {
	const lastUserIndex = findLastUserMessageIndex(context);
	if (lastUserIndex <= 0) return;
	const priorContext = {
		...context,
		messages: context.messages.slice(0, lastUserIndex),
	};
	const items = convertResponsesMessages(
		model,
		priorContext,
		CODEX_TOOL_CALL_PROVIDERS,
		{ includeSystemPrompt: false },
	);
	if (items.length === 0) return;
	await client.request("thread/inject_items", { threadId, items }, signal);
}

function buildTurnInput(
	context: Context,
	model: Model<"openai-codex-responses">,
): JsonObject[] {
	const lastUserIndex = findLastUserMessageIndex(context);
	const message =
		lastUserIndex >= 0 ? context.messages[lastUserIndex] : undefined;
	if (!message || message.role !== "user") {
		return [{ type: "text", text: "", text_elements: [] }];
	}
	if (typeof message.content === "string") {
		return [{ type: "text", text: message.content, text_elements: [] }];
	}
	const input: JsonObject[] = [];
	for (const block of message.content) {
		if (block.type === "text") {
			input.push({ type: "text", text: block.text, text_elements: [] });
		} else if (model.input.includes("image")) {
			input.push(imageBlockToUserInput(block));
		}
	}
	return input.length > 0
		? input
		: [{ type: "text", text: "", text_elements: [] }];
}

function imageBlockToUserInput(block: ImageContent): JsonObject {
	return { type: "image", url: `data:${block.mimeType};base64,${block.data}` };
}

function findLastUserMessageIndex(context: Context): number {
	for (let i = context.messages.length - 1; i >= 0; i--) {
		if (context.messages[i]?.role === "user") return i;
	}
	return -1;
}

function clampReasoningEffort(modelId: string, effort: string): string {
	const id = modelId.includes("/") ? modelId.split("/").pop()! : modelId;
	if (
		(id.startsWith("gpt-5.2") ||
			id.startsWith("gpt-5.3") ||
			id.startsWith("gpt-5.4")) &&
		effort === "minimal"
	)
		return "low";
	if (id === "gpt-5.1" && effort === "xhigh") return "high";
	if (id === "gpt-5.1-codex-mini")
		return effort === "high" || effort === "xhigh" ? "high" : "medium";
	return effort;
}

function normalizeReasoningSummary(
	value: OpenAICodexResponsesOptions["reasoningSummary"],
): AppServerReasoningSummary | null {
	if (value === "off") return "none";
	if (value === "on") return "auto";
	return value ?? null;
}

function readThreadId(value: unknown): string {
	const response = value as ThreadStartResponse;
	if (!response.thread?.id)
		throw new Error(
			"Codex app-server thread/start response did not include thread.id",
		);
	return response.thread.id;
}

function readTurnId(value: unknown): string {
	const response = value as TurnStartResponse;
	if (!response.turn?.id)
		throw new Error(
			"Codex app-server turn/start response did not include turn.id",
		);
	return response.turn.id;
}

function normalizeArguments(value: unknown): Record<string, unknown> {
	if (typeof value === "string") return parseStreamingJson(value);
	if (value && typeof value === "object" && !Array.isArray(value))
		return value as Record<string, unknown>;
	return {};
}

function asObject(value: unknown): JsonObject | undefined {
	return value && typeof value === "object" && !Array.isArray(value)
		? (value as JsonObject)
		: undefined;
}

function readString(value: unknown): string | undefined {
	return typeof value === "string" ? value : undefined;
}

function readErrorMessage(value: unknown): string | undefined {
	const object = asObject(value);
	const _error = asObject(object?.error);
	return readNestedCodexErrorMessage(object) ?? readString(object?.message);
}

function readNestedCodexErrorMessage(
	event: JsonObject | undefined,
): string | undefined {
	const errorObj = asObject(event?.error);
	const message = readString(errorObj?.message);
	const type = readString(errorObj?.type);
	if (message && type) return `${type}: ${message}`;
	return message ?? type;
}
