import { WebhookAuthorizationError } from 'n8n-nodes-base/dist/nodes/Webhook/error';
import { validateWebhookAuthentication } from 'n8n-nodes-base/dist/nodes/Webhook/utils';
import type {
	CredentialCheckResult,
	IDataObject,
	INodeTypeDescription,
	IUser,
	IWebhookFunctions,
	IWebhookResponseData,
} from 'n8n-workflow';
import {
	NodeConnectionTypes,
	Node,
	nodeNameToToolName,
	n8nOAuth2Auth,
	redactedHeaders,
} from 'n8n-workflow';

import { getConnectedTools } from '@utils/helpers';

import { createCredentialGateTool } from './CredentialGateTool';
import { McpServer, MCP_LIST_TOOLS_REQUEST_MARKER } from './McpServer';
import { MessageParser } from './protocol/MessageParser';
import type { CompressionResponse } from './transport';

/**
 * Builds the trigger's tool list, checking the triggering user's private-credential
 * status first. Building eagerly connects MCP Client sub-nodes, which fails while
 * the caller's end-user credentials are unconnected; in that case a placeholder
 * connect-credentials tool is returned instead of failing the whole request, so
 * session setup and tools/list keep working and the credential gate can hand out
 * the personal connection links on the subsequent tool call.
 */
async function getConnectedToolsRespectingCredentialGate(
	context: IWebhookFunctions,
	toolInput: IDataObject | undefined,
) {
	// Undefined unless an OAuth2 identity was established and the
	// dynamic-credentials module is enabled.
	const gateResult = await context.checkTriggerCredentialStatus();

	try {
		return {
			tools: await getConnectedTools(context, true, undefined, undefined, {
				inputData: toolInput,
			}),
			gateResult,
		};
	} catch (error) {
		if (!gateResult || gateResult.readyToExecute) throw error;

		context.logger.warn(
			`MCP Trigger: could not build the tool list while the caller has unconnected credentials, exposing the connect-credentials tool instead: ${
				error instanceof Error ? error.message : String(error)
			}`,
		);
		return { tools: [createCredentialGateTool(gateResult)], gateResult };
	}
}

const MCP_SSE_SETUP_PATH = 'sse';
const MCP_SSE_MESSAGES_PATH = 'messages';

export class McpTrigger extends Node {
	description: INodeTypeDescription = {
		displayName: 'MCP Server Trigger',
		name: 'mcpTrigger',
		icon: {
			light: 'file:../mcp.svg',
			dark: 'file:../mcp.dark.svg',
		},
		group: ['trigger'],
		version: [1, 1.1, 2, 2.1],
		description: 'Expose n8n tools as an MCP Server endpoint',
		activationMessage:
			'You can now connect your MCP Clients to the URL, using SSE or Streamable HTTP transports.',
		defaults: {
			name: 'MCP Server Trigger',
		},
		codex: {
			categories: ['AI', 'Core Nodes'],
			subcategories: {
				AI: ['Root Nodes', 'Model Context Protocol'],
				'Core Nodes': ['Other Trigger Nodes'],
			},
			alias: ['Model Context Protocol', 'MCP Server'],
			resources: {
				primaryDocumentation: [
					{
						url: 'https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.mcptrigger/',
					},
				],
			},
		},
		triggerPanel: {
			header: 'Listen for MCP events',
			executionsHelp: {
				inactive:
					"This trigger has two modes: test and production.<br /><br /><b>Use test mode while you build your workflow</b>. Click the 'execute step' button, then make an MCP request to the test URL. The executions will show up in the editor.<br /><br /><b>Use production mode to run your workflow automatically</b>. Publish the workflow, then make requests to the production URL. These executions will show up in the <a data-key='executions'>executions list</a>, but not the editor.",
				active:
					"This trigger has two modes: test and production.<br /><br /><b>Use test mode while you build your workflow</b>. Click the 'execute step' button, then make an MCP request to the test URL. The executions will show up in the editor.<br /><br /><b>Use production mode to run your workflow automatically</b>. Since your workflow is activated, you can make requests to the production URL. These executions will show up in the <a data-key='executions'>executions list</a>, but not the editor.",
			},
			activationHint:
				"Once you've finished building your workflow, run it without having to click this button by using the production URL.",
		},
		inputs: [
			{
				type: NodeConnectionTypes.AiTool,
				displayName: 'Tools',
			},
		],
		outputs: [],
		sensitiveOutputFields: ['headers.authorization', 'headers.cookie'],
		credentials: [
			{
				// eslint-disable-next-line n8n-nodes-base/node-class-description-credentials-name-unsuffixed
				name: 'httpBearerAuth',
				required: true,
				displayOptions: {
					show: {
						authentication: ['bearerAuth'],
					},
				},
			},
			{
				name: 'httpHeaderAuth',
				required: true,
				displayOptions: {
					show: {
						authentication: ['headerAuth'],
					},
				},
			},
		],
		properties: [
			{
				displayName: 'Authentication',
				name: 'authentication',
				type: 'options',
				options: [
					{ name: 'None', value: 'none' },
					{
						// n8n is a brand name and should be lowercase
						// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
						name: 'n8n User Auth (OAuth2)',
						value: 'n8nOAuth2',
						description: 'Require user to give consent to use their n8n account',
						displayOptions: { show: { '@version': [{ _cnd: { gte: 2 } }] } },
					},
					{ name: 'Bearer Auth', value: 'bearerAuth' },
					{ name: 'Header Auth', value: 'headerAuth' },
				],
				default: 'none',
				description: 'The way to authenticate',
				builderHint: {
					propertyHint:
						"Default to 'none'. n8n exposes inbound trigger URLs publicly by design. Only select an authentication method when the user explicitly asks to authenticate inbound traffic.",
				},
			},
			{
				displayName: 'Require Workflow Execute Permission',
				name: 'requireExecuteAccess',
				type: 'boolean',
				default: true,
				displayOptions: { show: { authentication: ['n8nOAuth2'] } }, // n8nOAuth2 is v2+ only
				description:
					'Whether the triggering user must also have permission to execute the workflow in the project it belongs to',
			},
			{
				displayName: 'Include User in Output',
				name: 'includeUserInOutput',
				type: 'boolean',
				default: true,
				displayOptions: {
					show: {
						authentication: ['n8nOAuth2'],
						'@version': [{ _cnd: { gte: 2.1 } }],
					},
				},
				description:
					"Whether to include the calling user's ID, email and name in the trigger output and in the request the connected tools receive",
			},
			{
				displayName: 'Path',
				name: 'path',
				type: 'string',
				default: '',
				placeholder: 'webhook',
				required: true,
				description: 'The base path for this MCP server',
			},
			{
				displayName: 'Instructions',
				name: 'instructions',
				type: 'string',
				typeOptions: { rows: 4 },
				default: '',
				description:
					"Sent to MCP clients when they connect. Clients that support server instructions typically add them to the model's system prompt — use for guidance that spans multiple tools, such as tool-choice rules or multi-step workflows.",
			},
		],
		webhooks: [
			{
				name: 'setup',
				httpMethod: 'GET',
				responseMode: 'onReceived',
				isFullPath: true,
				path: `={{$parameter["path"]}}{{parseFloat($nodeVersion)<2 ? '/${MCP_SSE_SETUP_PATH}' : ''}}`,
				nodeType: 'mcp',
				ndvHideMethod: true,
				ndvHideUrl: false,
			},
			{
				name: 'default',
				httpMethod: 'POST',
				responseMode: 'onReceived',
				isFullPath: true,
				path: `={{$parameter["path"]}}{{parseFloat($nodeVersion)<2 ? '/${MCP_SSE_MESSAGES_PATH}' : ''}}`,
				nodeType: 'mcp',
				ndvHideMethod: true,
				ndvHideUrl: true,
			},
			{
				name: 'default',
				httpMethod: 'DELETE',
				responseMode: 'onReceived',
				isFullPath: true,
				path: '={{$parameter["path"]}}',
				nodeType: 'mcp',
				ndvHideMethod: true,
				ndvHideUrl: true,
			},
		],
	};

	async webhook(context: IWebhookFunctions): Promise<IWebhookResponseData> {
		const webhookName = context.getWebhookName();
		const req = context.getRequestObject();
		const resp = context.getResponseObject() as unknown as CompressionResponse;

		let authedUser: IUser | undefined;

		if (context.getNodeParameter('authentication') === 'n8nOAuth2') {
			if (context.getNode().typeVersion < 2) {
				resp.writeHead(401);
				resp.end('OAuth2 authentication requires mcp trigger node v2.0 or higher');
				return { noWebhookResponse: true };
			}
			const authResult = await n8nOAuth2Auth(context, { realm: 'n8n MCP Server' });
			if (authResult === 'handled') {
				return { noWebhookResponse: true };
			}
			await context.establishTriggerIdentity(
				authResult.token,
				authResult.resource,
				authResult.user.id,
			);
			authedUser = authResult.user;
		} else {
			try {
				await validateWebhookAuthentication(context, 'authentication');
			} catch (error) {
				if (error instanceof WebhookAuthorizationError) {
					resp.writeHead(error.responseCode);
					resp.end(error.message);
					return { noWebhookResponse: true };
				}
				throw error;
			}
		}

		const node = context.getNode();

		// n8n's own auth credential must never reach the tools — not here, and not on the
		// worker, which rebuilds their input from `toolInput`. The caller's identity is
		// surfaced as `user` instead, so tools never need the token to know who called.
		const headers = redactedHeaders(req);
		const user =
			authedUser && context.getNodeParameter('includeUserInOutput', true) !== false
				? {
						id: authedUser.id,
						email: authedUser.email,
						firstName: authedUser.firstName,
						lastName: authedUser.lastName,
					}
				: undefined;
		const exposesRequest = node.typeVersion >= 2.1;
		const toolInput: IDataObject | undefined = exposesRequest
			? { body: context.getBodyData(), headers, ...(user && { user }) }
			: undefined;

		const serverName = node.typeVersion > 1 ? nodeNameToToolName(node) : 'n8n-mcp-server';
		// Coerce, since an expression can resolve this to a non-string (e.g. `{{ 123 }}`),
		// which the MCP client would reject when validating the initialize result
		const instructions = String(context.getNodeParameter('instructions', '') ?? '') || undefined;
		const mcpServer = McpServer.instance(context.logger);

		if (webhookName === 'setup') {
			const postUrl =
				node.typeVersion < 2
					? req.path.replace(new RegExp(`/${MCP_SSE_SETUP_PATH}$`), `/${MCP_SSE_MESSAGES_PATH}`)
					: req.path;

			const { tools: connectedTools } = await getConnectedToolsRespectingCredentialGate(
				context,
				toolInput,
			);
			await mcpServer.handleSetupRequest(
				req,
				resp,
				serverName,
				postUrl,
				connectedTools,
				instructions,
			);

			return { noWebhookResponse: true };
		} else if (webhookName === 'default') {
			if (req.method === 'DELETE') {
				await mcpServer.handleDeleteRequest(req, resp);
			} else {
				const sessionId = mcpServer.getSessionId(req);

				context.logger.debug('MCP POST request received for existing session');

				if (sessionId) {
					const { tools: connectedTools, gateResult: credentialStatus } =
						await getConnectedToolsRespectingCredentialGate(context, toolInput);

					// For a tool call, gate on the triggering user's private-credential status
					// before executing: a not-ready gate makes the CallTool handler return the
					// connection links instead of running the workflow.
					let gateResult: CredentialCheckResult | undefined;
					if (MessageParser.isToolCall(req.rawBody.toString())) {
						gateResult = credentialStatus;
					}

					const { wasToolCall, toolCallInfo, messageId, relaySessionId, needsListToolsRelay } =
						await mcpServer.handlePostMessage(
							req,
							resp,
							connectedTools,
							serverName,
							gateResult,
							instructions,
						);

					if (wasToolCall) {
						const workflowData = {
							...(toolCallInfo && { mcpToolCall: toolCallInfo }),
							...(messageId && { mcpMessageId: messageId }),
							...(exposesRequest && { headers, ...(user && { user }) }),
						};
						return {
							noWebhookResponse: true,
							workflowData: [[{ json: workflowData }]],
							toolInput,
						};
					}

					if (needsListToolsRelay && relaySessionId && messageId) {
						const workflowData = {
							mcpListToolsRelay: {
								sessionId: relaySessionId,
								messageId,
								marker: MCP_LIST_TOOLS_REQUEST_MARKER,
							},
						};
						return {
							noWebhookResponse: true,
							workflowData: [[{ json: workflowData }]],
						};
					}
				} else {
					const { tools: connectedTools } = await getConnectedToolsRespectingCredentialGate(
						context,
						toolInput,
					);
					await mcpServer.handleStreamableHttpSetup(
						req,
						resp,
						serverName,
						connectedTools,
						instructions,
					);
				}
			}

			return { noWebhookResponse: true };
		}

		return { workflowData: [[{ json: {} }]] };
	}
}
