import type { EvalMockHttpResponse } from 'n8n-core';
import type { IHttpRequestOptions } from 'n8n-workflow';
import { randomUUID } from 'node:crypto';

import { extractToolCalls, type NormalizedToolCall } from './openai-envelope';

// Translation between the OpenAI Responses API (`/v1/responses`) wire format
// and the shape `createLlmMockHandler` consumes/emits. The Responses API is
// what `@langchain/openai` v1.3+ auto-routes to for newer chat models — the
// chat-completions path covered by `openai-envelope.ts` is no longer the
// default for v1.3+ Agent workflows.

const OPENAI_RESPONSES_SYNTHETIC_URL = 'https://api.openai.com/v1/responses';

const DEFAULT_MODEL = 'gpt-4o-mini';

/** Same as `reverseTranslateOpenAiRequest` but for the Responses API endpoint. */
export function reverseTranslateOpenAiResponsesRequest(body: unknown): IHttpRequestOptions {
	return {
		url: OPENAI_RESPONSES_SYNTHETIC_URL,
		method: 'POST',
		body: body ?? {},
	};
}

/** Pull `.model` from the body; identical fallback to the chat-completions translator. */
export function extractResponsesRequestModel(body: unknown): string {
	if (typeof body !== 'object' || body === null) return DEFAULT_MODEL;
	const model = (body as { model?: unknown }).model;
	return typeof model === 'string' && model.length > 0 ? model : DEFAULT_MODEL;
}

/** True when the inbound Responses API request opted into streaming via `stream: true`. */
export function isResponsesStreamRequested(body: unknown): boolean {
	if (typeof body !== 'object' || body === null) return false;
	return (body as { stream?: unknown }).stream === true;
}

/**
 * Wrap the mock handler's response in a canonical `response` envelope.
 * The Responses API uses a single `output` array — each entry is either a
 * `message` (assistant text) or a `function_call` (tool call). Mixing both
 * in one response is legal but rare; tool-call mode replaces the message.
 */
export function forwardTranslateToResponsesEnvelope(
	mockResponse: EvalMockHttpResponse | undefined,
	model: string,
): Record<string, unknown> {
	const toolCalls = extractToolCalls(mockResponse?.body);
	const responseId = `resp_${randomUUID().replace(/-/g, '').slice(0, 32)}`;
	const now = Math.floor(Date.now() / 1000);

	const output =
		toolCalls.length > 0
			? toolCallsToResponsesOutput(toolCalls)
			: [buildAssistantMessage(extractResponsesContent(mockResponse?.body))];

	return {
		id: responseId,
		object: 'response',
		created_at: now,
		status: 'completed',
		model,
		output,
		// Mirror chat-completions: zero counts make eval cost trackers happy.
		usage: {
			input_tokens: 0,
			output_tokens: 0,
			total_tokens: 0,
		},
		// `previous_response_id`, `instructions`, `metadata` are intentionally
		// omitted — the SDK tolerates missing optional fields, and a stub
		// fingerprint isn't part of the Responses API envelope.
	};
}

/**
 * Stream the mock response as Responses API SSE events. Non-tool-call turn:
 * created → in_progress → output_item.added → content_part.added →
 * output_text.delta → output_text.done → content_part.done →
 * output_item.done → completed. Tool calls swap the message item for a
 * `function_call` item with `function_call_arguments.delta`/`.done`.
 */
export function forwardTranslateToResponsesSseEvents(
	mockResponse: EvalMockHttpResponse | undefined,
	model: string,
): Array<{ event: string; data: Record<string, unknown> }> {
	const responseId = `resp_${randomUUID().replace(/-/g, '').slice(0, 32)}`;
	const createdAt = Math.floor(Date.now() / 1000);
	const toolCalls = extractToolCalls(mockResponse?.body);

	const baseResponse = (status: string, output: unknown[]) => ({
		id: responseId,
		object: 'response',
		created_at: createdAt,
		status,
		model,
		output,
		usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
	});

	const events: Array<{ event: string; data: Record<string, unknown> }> = [];

	events.push({ event: 'response.created', data: { response: baseResponse('in_progress', []) } });
	events.push({
		event: 'response.in_progress',
		data: { response: baseResponse('in_progress', []) },
	});

	if (toolCalls.length > 0) {
		// Pre-build final items so `id` stays stable across every event the SDK
		// reconciles (added / delta / done / terminal completed.output[i]).
		const finalItems = toolCallsToResponsesOutput(toolCalls);
		toolCalls.forEach((tc, callIndex) => {
			const finalItem = finalItems[callIndex];
			const itemId = finalItem.id as string;
			const initialItem = { ...finalItem, arguments: '' };
			events.push({
				event: 'response.output_item.added',
				data: { output_index: callIndex, item: initialItem },
			});
			if (tc.arguments.length > 0) {
				events.push({
					event: 'response.function_call_arguments.delta',
					data: {
						item_id: itemId,
						output_index: callIndex,
						delta: tc.arguments,
					},
				});
			}
			events.push({
				event: 'response.function_call_arguments.done',
				data: {
					item_id: itemId,
					output_index: callIndex,
					arguments: tc.arguments,
				},
			});
			events.push({
				event: 'response.output_item.done',
				data: { output_index: callIndex, item: finalItem },
			});
		});
		events.push({
			event: 'response.completed',
			data: { response: baseResponse('completed', finalItems) },
		});
		return events;
	}

	// Plain message mode.
	const content = extractResponsesContent(mockResponse?.body);
	const messageId = `msg_${randomUUID().replace(/-/g, '').slice(0, 16)}`;
	// `annotations: []` is required — LangChain's extractor calls `.annotations.map(...)`.
	const messageItem = {
		id: messageId,
		type: 'message' as const,
		role: 'assistant' as const,
		content: [{ type: 'output_text' as const, text: content, annotations: [] }],
		status: 'completed' as const,
	};
	events.push({
		event: 'response.output_item.added',
		data: {
			output_index: 0,
			item: {
				...messageItem,
				content: [{ type: 'output_text', text: '', annotations: [] }],
				status: 'in_progress',
			},
		},
	});
	events.push({
		event: 'response.content_part.added',
		data: {
			item_id: messageId,
			output_index: 0,
			content_index: 0,
			part: { type: 'output_text', text: '', annotations: [] },
		},
	});
	if (content.length > 0) {
		events.push({
			event: 'response.output_text.delta',
			data: {
				item_id: messageId,
				output_index: 0,
				content_index: 0,
				delta: content,
			},
		});
	}
	events.push({
		event: 'response.output_text.done',
		data: {
			item_id: messageId,
			output_index: 0,
			content_index: 0,
			text: content,
		},
	});
	events.push({
		event: 'response.content_part.done',
		data: {
			item_id: messageId,
			output_index: 0,
			content_index: 0,
			part: { type: 'output_text', text: content, annotations: [] },
		},
	});
	events.push({
		event: 'response.output_item.done',
		data: { output_index: 0, item: messageItem },
	});
	events.push({
		event: 'response.completed',
		data: { response: baseResponse('completed', [messageItem]) },
	});

	return events;
}

/** True for the OpenAI Responses endpoint the openAi node calls directly over the wire. */
export function isOpenAiResponsesUrl(url: string): boolean {
	try {
		const parsed = new URL(url);
		return parsed.hostname === 'api.openai.com' && /^\/v\d+\/responses\/?$/.test(parsed.pathname);
	} catch {
		return false;
	}
}

function isPlainRecord(value: unknown): value is Record<string, unknown> {
	return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/** Coerce one content part to `{ type: 'output_text', text, annotations }`. */
function normalizeContentPart(part: unknown): Record<string, unknown> | undefined {
	if (typeof part === 'string') return { type: 'output_text', text: part, annotations: [] };
	if (!isPlainRecord(part)) return undefined;
	// The real API carries text as a STRING even for JSON output (json_object
	// format returns a JSON-encoded string); a generated part sometimes embeds
	// the parsed object — serialize it instead of dropping the part (dropping
	// used to collapse the item and rebuild the envelope around stringified junk).
	const text =
		typeof part.text === 'string'
			? part.text
			: isPlainRecord(part.text) || Array.isArray(part.text)
				? JSON.stringify(part.text)
				: undefined;
	if (text === undefined) return undefined;
	return {
		...part,
		type: 'output_text',
		text,
		annotations: Array.isArray(part.annotations) ? part.annotations : [],
	};
}

/**
 * Canonicalize one output item. Responses-native `function_call` items are
 * preserved (only missing ids filled); content-bearing items are coerced to a
 * canonical assistant message; other typed items pass through untouched.
 * Returns undefined for unsalvageable entries.
 */
function normalizeOutputItem(item: unknown, index: number): Record<string, unknown> | undefined {
	if (!isPlainRecord(item)) return undefined;
	if (item.type === 'function_call') {
		if (typeof item.name !== 'string' || typeof item.arguments !== 'string') return undefined;
		return {
			...item,
			id: typeof item.id === 'string' ? item.id : `fc_norm_${String(index)}`,
			call_id: typeof item.call_id === 'string' ? item.call_id : `call_norm_${String(index)}`,
		};
	}
	const content = item.content;
	if (Array.isArray(content)) {
		const parts = content
			.map(normalizeContentPart)
			.filter((part): part is Record<string, unknown> => part !== undefined);
		if (parts.length === 0) return undefined;
		return {
			id: typeof item.id === 'string' ? item.id : `msg_norm_${String(index)}`,
			type: 'message',
			role: 'assistant',
			status: 'completed',
			content: parts,
		};
	}
	// Unknown-but-typed items (e.g. reasoning): keep verbatim — the node ignores them.
	if (typeof item.type === 'string') return { ...item };
	return undefined;
}

/**
 * Coerce a freeform LLM-generated mock body into the canonical Responses
 * envelope. The openAi node's `response` operation is wire-intercepted (no
 * protocol adapter runs), and generated bodies routinely miss the strict item
 * shape (`type: 'message'`, `content[].type: 'output_text'`) the node's parser
 * filters on — collapsing `output` to `[]`.
 *
 * Structure-preserving: bodies with an `output` array are fixed item-by-item so
 * Responses-native tool calls survive; only shapeless bodies go through the
 * full text-extraction rebuild. Error bodies pass through untouched (the real
 * envelope carries `error: null`, so only a NON-null error marks a failure).
 */
export function normalizeOpenAiResponsesMockResponse(
	mockResponse: EvalMockHttpResponse,
	model: string,
): EvalMockHttpResponse {
	const body = mockResponse.body;
	if (
		isPlainRecord(body) &&
		('_evalMockError' in body || (body.error !== null && body.error !== undefined))
	) {
		return mockResponse;
	}

	if (isPlainRecord(body) && Array.isArray(body.output)) {
		const output = body.output
			.map(normalizeOutputItem)
			.filter((item): item is Record<string, unknown> => item !== undefined);
		if (output.length > 0) {
			return {
				...mockResponse,
				body: {
					...body,
					id:
						typeof body.id === 'string'
							? body.id
							: `resp_${randomUUID().replace(/-/g, '').slice(0, 32)}`,
					object: 'response',
					status: 'completed',
					model: typeof body.model === 'string' && body.model.length > 0 ? body.model : model,
					created_at:
						typeof body.created_at === 'number' ? body.created_at : Math.floor(Date.now() / 1000),
					output,
					usage: isPlainRecord(body.usage)
						? body.usage
						: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
				},
			};
		}
	}

	return { ...mockResponse, body: forwardTranslateToResponsesEnvelope(mockResponse, model) };
}

/** Responses API uses the same error envelope as chat-completions, with `error.type` describing the failure. */
export function buildResponsesErrorEnvelope(message: string): Record<string, unknown> {
	return {
		error: {
			message,
			type: 'eval_wire_server_error',
			code: 'eval_mock_generation_failed',
			param: null,
		},
	};
}

function toolCallsToResponsesOutput(
	toolCalls: NormalizedToolCall[],
): Array<Record<string, unknown>> {
	return toolCalls.map((tc) => ({
		id: `fc_${randomUUID().replace(/-/g, '').slice(0, 16)}`,
		type: 'function_call',
		call_id: tc.id,
		name: tc.name,
		arguments: tc.arguments,
	}));
}

function buildAssistantMessage(text: string): Record<string, unknown> {
	return {
		id: `msg_${randomUUID().replace(/-/g, '').slice(0, 16)}`,
		type: 'message',
		role: 'assistant',
		status: 'completed',
		// `annotations: []` is required — LangChain's extractor calls `.annotations.map(...)`.
		content: [{ type: 'output_text', text, annotations: [] }],
	};
}

/** Tolerant content extractor: handles `output[].content[].text`, `output_text`, `{ content }`, `{ message }`, bare strings. */
function extractResponsesContent(body: unknown): string {
	if (body === null || body === undefined) return '';
	if (typeof body === 'string') return body;
	if (typeof body !== 'object') return String(body as number | boolean | bigint);

	const obj = body as Record<string, unknown>;

	if (typeof obj.output_text === 'string') return obj.output_text;
	if (isPlainRecord(obj.output_text) || Array.isArray(obj.output_text)) {
		return JSON.stringify(obj.output_text);
	}

	const output = obj.output;
	if (Array.isArray(output) && output.length > 0) {
		for (const item of output) {
			if (typeof item !== 'object' || item === null) continue;
			const content = (item as { content?: unknown }).content;
			if (!Array.isArray(content) || content.length === 0) continue;
			const first: unknown = content[0];
			if (typeof first === 'object' && first !== null) {
				const text = (first as { text?: unknown }).text;
				if (typeof text === 'string') return text;
				// Object-embedded JSON: the real API would carry it JSON-encoded.
				if (isPlainRecord(text) || Array.isArray(text)) return JSON.stringify(text);
			}
		}
	}

	if (typeof obj.content === 'string') return obj.content;
	if (typeof obj.message === 'string') return obj.message;

	return JSON.stringify(body);
}
