import { Logger } from '@n8n/backend-common';
import type { IExecutionResponse } from '@n8n/db';
import { ExecutionRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { timingSafeEqual } from 'crypto';
import type express from 'express';
import { InstanceSettings, WAITING_TOKEN_QUERY_PARAM, validateUrlSignature } from 'n8n-core';
import {
	type INodes,
	type IWorkflowBase,
	NodeConnectionTypes,
	SEND_AND_WAIT_OPERATION,
	Workflow,
} from 'n8n-workflow';

import { sanitizeWebhookRequest } from './webhook-request-sanitizer';
import { WebhookService } from './webhook.service';
import type {
	IWebhookManager,
	IWebhookResponseCallbackData,
	WaitingWebhookRequest,
} from './webhook.types';

import { EventService } from '@/events/event.service';

import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { getWorkflowActiveStatusFromWorkflowData } from '@/executions/execution.utils';
import { NodeTypes } from '@/node-types';
import { applyCors } from '@/utils/cors.util';
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import { preserveInputOverride } from '@/workflow-helpers';

/**
 * Service for handling the execution of webhooks of Wait nodes that use the
 * [Resume On Webhook Call](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/#on-webhook-call)
 * feature.
 */
@Service()
export class WaitingWebhooks implements IWebhookManager {
	protected includeForms = false;

	constructor(
		protected readonly logger: Logger,
		protected readonly nodeTypes: NodeTypes,
		private readonly executionRepository: ExecutionRepository,
		private readonly webhookService: WebhookService,
		protected readonly instanceSettings: InstanceSettings,
		private readonly eventService: EventService,
	) {}

	// TODO: implement `getWebhookMethods` for CORS support

	async findAccessControlOptions() {
		// waiting webhooks do not support cors configuration options
		// allow all origins because waiting webhook forms are always submitted in cors mode due to sandbox CSP
		return {
			allowedOrigins: '*',
		};
	}

	protected logReceivedWebhook(method: string, executionId: string) {
		this.logger.debug(`Received waiting-webhook "${method}" for execution "${executionId}"`);
	}

	protected disableNode(execution: IExecutionResponse, _method?: string) {
		execution.data.executionData!.nodeExecutionStack[0].node.disabled = true;
	}

	private isSendAndWaitRequest(nodes: INodes, suffix: string | undefined) {
		return (
			suffix &&
			Object.keys(nodes).some(
				(node) =>
					nodes[node].id === suffix && nodes[node].parameters.operation === SEND_AND_WAIT_OPERATION,
			)
		);
	}

	// TODO: fix the type here - it should be execution workflowData
	protected createWorkflow(workflowData: IWorkflowBase) {
		return new Workflow({
			id: workflowData.id,
			name: workflowData.name,
			nodes: workflowData.nodes,
			connections: workflowData.connections,
			active: getWorkflowActiveStatusFromWorkflowData(workflowData),
			nodeTypes: this.nodeTypes,
			staticData: workflowData.staticData,
			settings: workflowData.settings,
		});
	}

	protected async getExecution(executionId: string) {
		return await this.executionRepository.findSingleExecution(executionId, {
			includeData: true,
			unflattenData: true,
		});
	}

	/**
	 * Extracts the `signature` query param and an optional webhook path
	 * appended after it (backwards compat: `?signature=token/my-suffix`).
	 */
	private parseSignatureParam(req: express.Request): {
		token: string | undefined;
		webhookPath: string | undefined;
	} {
		const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
		let token = url.searchParams.get(WAITING_TOKEN_QUERY_PARAM) ?? undefined;
		let webhookPath: string | undefined;

		// Handle backwards compat: extract webhook path if appended after the token
		// e.g., ?signature=abc123/my-suffix -> token is "abc123", webhookPath is "my-suffix"
		if (token?.includes('/')) {
			const slashIndex = token.indexOf('/');
			webhookPath = token.slice(slashIndex + 1);
			token = token.slice(0, slashIndex);
		}

		return { token, webhookPath };
	}

	/**
	 * Validates the request by comparing the provided token against the stored
	 * `resumeToken` using timing-safe comparison.
	 *
	 * Used for form and webhook waiting URLs, where the token is an opaque
	 * random value (no query params to tamper-proof).
	 */
	protected validateToken(
		req: express.Request,
		execution: IExecutionResponse,
	): { valid: boolean; webhookPath?: string } {
		const { token, webhookPath } = this.parseSignatureParam(req);
		const storedToken = execution.data.resumeToken;

		if (!token || !storedToken || token.length !== storedToken.length) {
			return { valid: false };
		}

		const valid = timingSafeEqual(Buffer.from(token), Buffer.from(storedToken));
		return { valid, webhookPath };
	}

	/**
	 * Validates the HMAC signature in the request URL.
	 *
	 * Used exclusively for send-and-wait URLs, where query params like
	 * `approved=true` must be tamper-proof.
	 */
	protected validateSignature(req: express.Request): { valid: boolean; webhookPath?: string } {
		const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
		const { token: providedSignature, webhookPath } = this.parseSignatureParam(req);

		if (!providedSignature) {
			return { valid: false };
		}

		// Restore the cleaned signature on the URL (without any appended webhook path)
		// so that `validateUrlSignature` can re-derive the expected HMAC from the full
		// URL including the node-id suffix and action params (e.g. approved=true).
		url.searchParams.set(WAITING_TOKEN_QUERY_PARAM, providedSignature);

		const valid = validateUrlSignature(
			providedSignature,
			url,
			this.instanceSettings.hmacSignatureSecret,
		);
		return { valid, webhookPath };
	}

	async executeWebhook(
		req: WaitingWebhookRequest,
		res: express.Response,
	): Promise<IWebhookResponseCallbackData> {
		const { path: executionId } = req.params;
		let { suffix } = req.params;

		this.logReceivedWebhook(req.method, executionId);

		sanitizeWebhookRequest(req);

		// Reset request parameters
		req.params = {} as WaitingWebhookRequest['params'];

		const execution = await this.getExecution(executionId);

		// Only validate for executions that have a resumeToken.
		// Old executions created before token validation are skipped (backwards compat).
		if (execution?.data.resumeToken) {
			const { workflowData } = execution;
			const { nodes } = this.createWorkflow(workflowData);
			const isSendAndWait = this.isSendAndWaitRequest(nodes, suffix);

			// Send-and-wait uses HMAC to protect tamper-sensitive query params (e.g. approved=true).
			// All other waiting URLs use a simple random token comparison.
			const { valid, webhookPath } = isSendAndWait
				? this.validateSignature(req)
				: this.validateToken(req, execution);

			if (!valid) {
				if (isSendAndWait) {
					res.status(401).render('form-invalid-token');
				} else {
					res.status(401).json({ error: 'Invalid token' });
				}
				return { noWebhookResponse: true };
			}
			// Use webhook path parsed from token if not in route (backwards compat for old URL format)
			if (!suffix && webhookPath) {
				suffix = webhookPath;
			}
		}

		if (!execution) {
			throw new NotFoundError(`The execution "${executionId}" does not exist.`);
		}

		if (execution.status === 'running') {
			throw new ConflictError(`The execution "${executionId}" is running already.`);
		}

		if (execution.data?.resultData?.error) {
			const message = `The execution "${executionId}" has finished with error.`;
			this.logger.debug(message, { error: execution.data.resultData.error });
			throw new ConflictError(message);
		}

		if (execution.finished) {
			const { workflowData } = execution;
			const { nodes } = this.createWorkflow(workflowData);
			if (this.isSendAndWaitRequest(nodes, suffix)) {
				res.render('send-and-wait-no-action-required', { isTestWebhook: false });
				return { noWebhookResponse: true };
			} else {
				throw new ConflictError(`The execution "${executionId} has finished already.`);
			}
		}

		const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string;

		applyCors(req, res);

		return await this.getWebhookExecutionData({
			execution,
			req,
			res,
			lastNodeExecuted,
			executionId,
			suffix,
		});
	}

	private emitExecutionResumedEvent(execution: IExecutionResponse, executionId: string) {
		this.eventService.emit('execution-resumed', {
			executionId,
			workflowId: execution.workflowData.id,
			resumeSource: 'webhook', // today, we only emit the 'execution-resumed' event for webhook wait nodes
			responseAt: new Date(),
		});
	}

	protected async getWebhookExecutionData({
		execution,
		req,
		res,
		lastNodeExecuted,
		executionId,
		suffix,
	}: {
		execution: IExecutionResponse;
		req: WaitingWebhookRequest;
		res: express.Response;
		lastNodeExecuted: string;
		executionId: string;
		suffix?: string;
	}): Promise<IWebhookResponseCallbackData> {
		// Set the node as disabled so that the data does not get executed again as it would result
		// in starting the wait all over again
		this.disableNode(execution, req.method);

		// Remove waitTill information else the execution would stop
		execution.data.waitTill = undefined;

		// For HITL nodes, preserve inputOverride and set rewireOutputLogTo before popping run data
		const nodeExecutionStack = execution.data.executionData?.nodeExecutionStack;
		const executionStackEntry = nodeExecutionStack?.[0];
		const isHitlNode = executionStackEntry?.node?.type?.endsWith('HitlTool') ?? false;

		if (isHitlNode && executionStackEntry) {
			// Set rewireOutputLogTo on the node so output is logged with ai_tool type
			executionStackEntry.node.rewireOutputLogTo = NodeConnectionTypes.AiTool;
		}

		const runDataArray = execution.data.resultData.runData[lastNodeExecuted];
		preserveInputOverride(runDataArray);

		const { workflowData } = execution;
		const workflow = this.createWorkflow(workflowData);

		const workflowStartNode = workflow.getNode(lastNodeExecuted);
		if (workflowStartNode === null) {
			throw new NotFoundError('Could not find node to process webhook.');
		}

		const additionalData = await WorkflowExecuteAdditionalData.getBase({
			workflowId: workflow.id,
		});
		await workflow.expression.acquireIsolate();
		try {
			const webhookData = this.webhookService
				.getNodeWebhooks(workflow, workflowStartNode, additionalData)
				.find(
					(webhook) =>
						webhook.httpMethod === req.method &&
						webhook.path === (suffix ?? '') &&
						webhook.webhookDescription.restartWebhook === true &&
						(webhook.webhookDescription.nodeType === 'form' || false) === this.includeForms,
				);

			if (webhookData === undefined) {
				// If no data got found it means that the execution can not be started via a webhook.
				// Return 404 because we do not want to give any data if the execution exists or not.
				const errorMessage = `The workflow for execution "${executionId}" does not contain a waiting webhook with a matching path/method.`;

				if (this.isSendAndWaitRequest(workflow.nodes, suffix)) {
					res.render('send-and-wait-no-action-required', { isTestWebhook: false });
					return { noWebhookResponse: true };
				}

				throw new NotFoundError(errorMessage);
			}

			const runExecutionData = execution.data;

			const isWaitingForWebhook =
				!this.includeForms && !this.isSendAndWaitRequest(workflow.nodes, suffix);
			if (isWaitingForWebhook) {
				this.emitExecutionResumedEvent(execution, executionId);
			}

			return await new Promise((resolve, reject) => {
				void WebhookHelpers.executeWebhook(
					workflow,
					webhookData,
					workflowData,
					workflowStartNode,
					execution.mode,
					runExecutionData.pushRef,
					runExecutionData,
					execution.id,
					req,
					res,

					(error: Error | null, data: object) => {
						if (error !== null) {
							return reject(error);
						}
						resolve(data);
					},
				);
			});
		} finally {
			await workflow.expression.releaseIsolate();
		}
	}
}
