import {
	Tool,
	type AgentMessage,
	type BuiltTool,
	hasMcpMediaContent,
	mcpContentToMessageParts,
	mcpContentToModelParts,
} from '@n8n/agents';
import {
	GATEWAY_CONFIRMATION_REQUIRED_PREFIX,
	gatewayConfirmationRequiredPayloadSchema,
	instanceAiConfirmationSeveritySchema,
	type GatewayConfirmationRequiredPayload,
	type McpToolCallResult,
} from '@n8n/api-types';
import type * as McpBrowserCredentialMod from '@n8n/mcp-browser/dist/tools/credential';
import { isRecord } from '@n8n/utils/is-record';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { convertJsonSchemaToZod } from 'zod-from-json-schema-v3';
import type { JSONSchema } from 'zod-from-json-schema-v3';

let _mcpBrowserCredentialMod: typeof McpBrowserCredentialMod | undefined;
function loadMcpBrowserCredential(): typeof McpBrowserCredentialMod {
	if (!_mcpBrowserCredentialMod) {
		// eslint-disable-next-line @typescript-eslint/no-require-imports
		const mod = require('@n8n/mcp-browser/dist/tools/credential') as typeof McpBrowserCredentialMod;
		_mcpBrowserCredentialMod = mod;
	}
	return _mcpBrowserCredentialMod;
}

import {
	addSafeMcpTools,
	createClaimedToolNames,
	McpToolNameValidationError,
	validateMcpToolName,
} from '../../agent/mcp-tool-name-validation';
import type { McpDescriptionTruncation } from '../../agent/sanitize-mcp-descriptions';
import {
	assertMcpJsonSchemaWithinLimits,
	McpSchemaSanitizationError,
	sanitizeMcpToolSchemas,
} from '../../agent/sanitize-mcp-schemas';
import type { Logger } from '../../logger';
import { createToolRegistry } from '../../tool-registry';
import type { InstanceAiToolRegistry, LocalMcpServer } from '../../types';

type McpContentBlock = McpToolCallResult['content'][number];

// ---------------------------------------------------------------------------
// Schemas shared across all gateway-gated tools
// ---------------------------------------------------------------------------

const gatewayConfirmationSuspendSchema = z.object({
	requestId: z.string(),
	message: z.string(),
	severity: instanceAiConfirmationSeveritySchema,
	inputType: z.literal('resource-decision'),
	resourceDecision: gatewayConfirmationRequiredPayloadSchema,
});

const gatewayResourceDecisionSchema = z.enum(['denyOnce', 'allowOnce', 'allowForSession']);

const gatewayConfirmationRequiredWirePayloadSchema =
	gatewayConfirmationRequiredPayloadSchema.extend({
		options: z.array(z.string()),
	});

export const gatewayConfirmationResumeSchema = z.object({
	approved: z.boolean(),
	resourceDecision: gatewayResourceDecisionSchema.optional(),
});

// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------

function isGatewayResourceDecision(
	option: string,
): option is z.infer<typeof gatewayResourceDecisionSchema> {
	return gatewayResourceDecisionSchema.safeParse(option).success;
}

function isMcpContentBlock(value: unknown): value is McpContentBlock {
	if (!isRecord(value)) return false;
	if (value.type === 'text') return typeof value.text === 'string';
	if (value.type === 'image') {
		return typeof value.data === 'string' && typeof value.mimeType === 'string';
	}
	if (value.type === 'resource') {
		if (!isRecord(value.resource)) return false;
		return typeof value.resource.uri === 'string' && typeof value.resource.blob === 'string';
	}
	return false;
}

function unwrapMcpToolResult(result: unknown): McpToolCallResult | undefined {
	const raw = isRecord(result) && 'output' in result ? result.output : result;
	if (!isRecord(raw) || !Array.isArray(raw.content)) return undefined;
	if (!raw.content.every(isMcpContentBlock)) return undefined;

	return {
		content: raw.content,
		...(isRecord(raw.structuredContent) ? { structuredContent: raw.structuredContent } : {}),
		...(typeof raw.isError === 'boolean' ? { isError: raw.isError } : {}),
	};
}

function tryParseGatewayConfirmationRequired(
	result: McpToolCallResult,
): GatewayConfirmationRequiredPayload | null {
	if (!result.isError) return null;
	const raw = result.content
		.filter((c) => c.type === 'text')
		.map((c) => c.text ?? '')
		.join('');

	// Unwrap JSON envelope `{"error":"GATEWAY_CONFIRMATION_REQUIRED::..."}` if present
	let candidate = raw;
	try {
		const parsed = JSON.parse(raw) as unknown;
		if (
			typeof parsed === 'object' &&
			parsed !== null &&
			'error' in parsed &&
			typeof (parsed as Record<string, unknown>).error === 'string'
		) {
			candidate = (parsed as Record<string, unknown>).error as string;
		}
	} catch {
		// Not JSON — use raw text as-is
	}

	if (!candidate.startsWith(GATEWAY_CONFIRMATION_REQUIRED_PREFIX)) return null;
	try {
		const json = JSON.parse(
			candidate.slice(GATEWAY_CONFIRMATION_REQUIRED_PREFIX.length),
		) as unknown;
		const parsed = gatewayConfirmationRequiredWirePayloadSchema.safeParse(json);
		if (!parsed.success) return null;

		const options = parsed.data.options.filter(isGatewayResourceDecision);
		if (options.length === 0) return null;

		return { ...parsed.data, options };
	} catch {
		return null;
	}
}

export type BrowserCredentialCreateOutcome = { ok: true } | { ok: false; errorCode: string };

/**
 * Map a failed `browser_create_credential` result to a stable, non-sensitive
 * error code. The matched substrings are the error messages thrown by
 * `@n8n/mcp-browser`'s credential tool before it reaches credential
 * persistence; anything else means the host's create-credential call failed.
 */
function classifyCredentialCreateError(result: McpToolCallResult): string {
	const text = result.content
		.filter((block) => block.type === 'text')
		.map((block) => block.text ?? '')
		.join(' ');
	if (text.includes('No captured fields found')) return 'missing_captured_fields';
	if (text.includes('resolveData')) return 'unresolved_field';
	if (text.includes('was not found') || text.includes('Secret capturing failed')) {
		return 'missing_captured_fields';
	}
	if (text.includes('gateway context')) return 'gateway_context_missing';
	if (text.includes('Access denied by user')) return 'user_denied';
	return 'credential_create_failed';
}

function buildNativeMcpMediaMessage(result: unknown): AgentMessage | undefined {
	const raw = unwrapMcpToolResult(result);
	if (!raw || !hasMcpMediaContent(raw.content)) return undefined;

	const content = mcpContentToMessageParts(raw.content);
	if (content.length === 0) return undefined;

	return { role: 'assistant', content };
}

// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------

const LOCAL_GATEWAY_MCP_SOURCE = 'local gateway MCP';

function warnTruncatedLocalMcpDescription(logger: Logger) {
	return (truncation: McpDescriptionTruncation) => {
		logger.warn('Truncated overlong local gateway MCP description', {
			toolName: truncation.toolName,
			source: LOCAL_GATEWAY_MCP_SOURCE,
			path: truncation.path,
			originalLength: truncation.originalLength,
			limit: truncation.limit,
		});
	};
}

function warnSkippedLocalMcpSchema(logger: Logger) {
	return (error: McpSchemaSanitizationError) => {
		logger.warn('Skipped local gateway MCP tool with unsupported schema', {
			toolName: error.details.toolName,
			source: LOCAL_GATEWAY_MCP_SOURCE,
			path: error.details.path,
			depth: error.details.depth,
			maxDepth: error.details.maxDepth,
			limitType: error.details.limitType,
			limit: error.details.limit,
			reason: error.message,
		});
	};
}

function warnSkippedLocalMcpTool(logger: Logger) {
	return (error: McpToolNameValidationError) => {
		logger.warn('Skipped local gateway MCP tool with unsafe name', {
			toolName: error.toolName,
			source: error.source,
			reason: error.message,
		});
	};
}

/**
 * Build native tools dynamically from the MCP tools advertised by a connected
 * local MCP server (e.g. the computer-use daemon).
 *
 * Each tool's input schema is converted from the daemon's JSON Schema definition
 * to a Zod schema so the LLM receives accurate parameter information. Falls back
 * to `z.record(z.unknown())` if conversion fails for a particular tool.
 *
 * When the daemon responds with `GATEWAY_CONFIRMATION_REQUIRED`, the tool
 * suspends the agent via the native `suspend()` mechanism. This persists
 * the confirmation request to the database, so it survives page reloads and
 * server restarts. On resume, the tool re-calls the daemon with the selected
 * decision token.
 *
 * The `toModelOutput` callback converts MCP image blocks into AI SDK content
 * output parts so the LLM receives gateway screenshots as real multimodal input.
 */
export function createToolsFromLocalMcpServer({
	server,
	logger,
	onCredentialCreateResult,
}: {
	server: LocalMcpServer;
	logger: Logger;
	onCredentialCreateResult?: (
		credentialType: string,
		outcome: BrowserCredentialCreateOutcome,
	) => void;
}): InstanceAiToolRegistry {
	const tools = createToolRegistry();
	const claimedToolNames = createClaimedToolNames([]);
	const warnTool = warnSkippedLocalMcpTool(logger);
	const warnSchema = warnSkippedLocalMcpSchema(logger);

	for (const mcpTool of server.getAvailableTools()) {
		const toolName = mcpTool.name;
		const description = mcpTool.description ?? toolName;

		try {
			const normalizedName = validateMcpToolName(toolName, LOCAL_GATEWAY_MCP_SOURCE);
			const claimedBy = claimedToolNames.get(normalizedName);
			if (claimedBy) {
				throw new McpToolNameValidationError(
					`MCP tool "${toolName}" from ${LOCAL_GATEWAY_MCP_SOURCE} conflicts with "${claimedBy}"`,
					toolName,
					LOCAL_GATEWAY_MCP_SOURCE,
				);
			}
			assertMcpJsonSchemaWithinLimits(mcpTool.inputSchema, { toolName });
			claimedToolNames.set(normalizedName, toolName);
		} catch (error) {
			if (error instanceof McpToolNameValidationError) {
				warnTool(error);
				continue;
			}
			if (error instanceof McpSchemaSanitizationError) {
				warnSchema(error);
				continue;
			}
			throw error;
		}

		let inputSchema: z.ZodTypeAny;
		try {
			if (toolName === 'browser_create_credential') {
				// when converting json schema the `inputSchema` has the correct shape and parsed to correct output
				// but during execution all unspecified keys from `data` and `resolveData` are stripped,
				// because the schema is converted back and forth and transformed to jsonSchema with
				// `additionalProperties=false`. Passing the schema directly avoids this.
				inputSchema = loadMcpBrowserCredential().browserCreateCredentialSchema;
			} else {
				// Convert JSON Schema → Zod (v3) so the LLM sees the actual parameter shapes.
				// McpTool.inputSchema properties are typed as Record<string, unknown> to
				// accommodate arbitrary JSON Schema values; the cast is safe here because
				// the daemon always sends valid JSON Schema objects.
				inputSchema = convertJsonSchemaToZod(mcpTool.inputSchema as JSONSchema);
			}
		} catch {
			// Fallback: accept any object if conversion fails
			inputSchema = z.record(z.unknown());
		}

		const baseTool = new Tool(toolName)
			.description(description)
			.input(inputSchema)
			.suspend(gatewayConfirmationSuspendSchema)
			.resume(gatewayConfirmationResumeSchema)
			.handler(async (args: Record<string, unknown>, ctx) => {
				const resumeData = ctx.resumeData;
				const observeResult = (result: McpToolCallResult): McpToolCallResult => {
					if (toolName === 'browser_create_credential' && typeof args.type === 'string') {
						onCredentialCreateResult?.(
							args.type,
							result.isError
								? { ok: false, errorCode: classifyCredentialCreateError(result) }
								: { ok: true },
						);
					}
					return result;
				};
				// A rejected call must reach the observer too — otherwise a thrown
				// credential-create leaves no failure outcome. Skip on abort: the run's
				// terminal status reports user cancellation more accurately.
				const callServer = async (callArgs: Record<string, unknown>) => {
					try {
						return await server.callTool(
							{ name: toolName, arguments: callArgs },
							{ abortSignal: ctx.abortSignal },
						);
					} catch (error) {
						if (!ctx.abortSignal?.aborted) {
							observeResult({
								content: [
									{ type: 'text', text: error instanceof Error ? error.message : String(error) },
								],
								isError: true,
							});
						}
						throw error;
					}
				};

				// Resume path: user has made a resource-access decision
				if (resumeData !== undefined && resumeData !== null) {
					if (!resumeData.resourceDecision) {
						// User denied — no decision provided
						return observeResult({
							content: [{ type: 'text', text: JSON.stringify({ error: 'Access denied by user' }) }],
							isError: true,
						});
					}
					// Re-call the daemon with the user's decision
					return observeResult(
						await callServer({ ...args, _confirmation: resumeData.resourceDecision }),
					);
				}

				// First-call path: strip any LLM-provided _confirmation key so the agent
				// cannot bypass the human confirmation flow by supplying its own token.
				const { _confirmation: _stripped, ...safeArgs } = args;
				const result = await callServer(safeArgs);

				// If the daemon requires a resource-access confirmation, suspend the agent
				if (result.isError) {
					const payload = tryParseGatewayConfirmationRequired(result);
					if (payload && typeof ctx.suspend === 'function') {
						return await ctx.suspend({
							requestId: nanoid(),
							message: `${toolName}: ${payload.description}`,
							severity: 'warning',
							inputType: 'resource-decision',
							resourceDecision: payload,
						});
					}
				}

				return observeResult(result);
			})
			.toModelOutput((result: unknown) => {
				const raw = unwrapMcpToolResult(result);

				if (!raw?.content || !Array.isArray(raw.content)) {
					return { type: 'text', value: JSON.stringify(result) };
				}

				const hasMedia = hasMcpMediaContent(raw.content);

				// When we have structuredContent and no media, prefer it as compact text
				if (raw.structuredContent && !hasMedia) {
					return {
						type: 'content',
						value: [{ type: 'text' as const, text: JSON.stringify(raw.structuredContent) }],
					};
				}

				const value = mcpContentToModelParts(raw.content);
				return { type: 'content', value };
			})
			.build();
		const tool = {
			...baseTool,
			toMessage: buildNativeMcpMediaMessage,
		} satisfies BuiltTool;

		tools.set(toolName, tool);
	}

	const sanitizedTools = sanitizeMcpToolSchemas(tools, {
		onError: warnSkippedLocalMcpSchema(logger),
		onDescriptionTruncated: warnTruncatedLocalMcpDescription(logger),
	});
	const safeTools = createToolRegistry();
	addSafeMcpTools(safeTools, sanitizedTools, {
		source: LOCAL_GATEWAY_MCP_SOURCE,
		claimedToolNames: createClaimedToolNames([]),
		warn: warnTool,
	});
	return safeTools;
}
