import { Logger } from '@n8n/backend-common';
import { Memoized } from '@n8n/decorators';
import { Container } from '@n8n/di';
import get from 'lodash/get';
import type {
	FunctionsBase,
	ICredentialDataDecryptedObject,
	ICredentialsExpressionResolveValues,
	IExecuteData,
	IGetNodeParameterOptions,
	INode,
	INodeCredentialDescription,
	INodeCredentialsDetails,
	INodeExecutionData,
	INodeInputConfiguration,
	INodeOutputConfiguration,
	IRunExecutionData,
	IWorkflowExecuteAdditionalData,
	NodeConnectionType,
	NodeFeatures,
	NodeInputConnections,
	NodeParameterValueType,
	NodeTypeAndVersion,
	Workflow,
	WorkflowExecuteMode,
} from 'n8n-workflow';
import {
	ApplicationError,
	CHAT_TRIGGER_NODE_TYPE,
	deepCopy,
	ExpressionError,
	NodeHelpers,
	NodeOperationError,
	UnexpectedError,
} from 'n8n-workflow';

import {
	HTTP_REQUEST_AS_TOOL_NODE_TYPE,
	HTTP_REQUEST_NODE_TYPE,
	HTTP_REQUEST_TOOL_NODE_TYPE,
	WAITING_TOKEN_QUERY_PARAM,
} from '@/constants';
import { InstanceSettings } from '@/instance-settings';

import { cleanupParameterData } from './utils/cleanup-parameter-data';
import { ensureType } from './utils/ensure-type';
import { extractValue } from './utils/extract-value';
import { getAdditionalKeys } from './utils/get-additional-keys';
import { validateValueAgainstSchema } from './utils/validate-value-against-schema';
import { generateUrlSignature, prepareUrlForSigning } from '../../utils/signature-helpers';

export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCredentials'> {
	protected readonly instanceSettings = Container.get(InstanceSettings);

	constructor(
		readonly workflow: Workflow,
		readonly node: INode,
		readonly additionalData: IWorkflowExecuteAdditionalData,
		readonly mode: WorkflowExecuteMode,
		readonly runExecutionData: IRunExecutionData | null = null,
		readonly runIndex = 0,
		readonly connectionInputData: INodeExecutionData[] = [],
		readonly executeData?: IExecuteData,
	) {}

	@Memoized
	get logger() {
		return Container.get(Logger);
	}

	getExecutionContext() {
		return this.runExecutionData?.executionData?.runtimeData;
	}

	getExecutionId() {
		return this.additionalData.executionId!;
	}

	getNode(): INode {
		return deepCopy(this.node);
	}

	getWorkflow() {
		const { id, name, active } = this.workflow;
		return { id, name, active };
	}

	getMode() {
		return this.mode;
	}

	getWorkflowStaticData(type: string) {
		return this.workflow.getStaticData(type, this.node);
	}

	getChildNodes(nodeName: string, options?: { includeNodeParameters?: boolean }) {
		const output: NodeTypeAndVersion[] = [];
		const nodeNames = this.workflow.getChildNodes(nodeName);

		for (const n of nodeNames) {
			const node = this.workflow.nodes[n];
			const entry: NodeTypeAndVersion = {
				name: node.name,
				type: node.type,
				typeVersion: node.typeVersion,
				disabled: node.disabled ?? false,
			};

			if (options?.includeNodeParameters) {
				entry.parameters = node.parameters;
			}

			output.push(entry);
		}
		return output;
	}

	getParentNodes(
		nodeName: string,
		options?: {
			includeNodeParameters?: boolean;
			connectionType?: NodeConnectionType;
			depth?: number;
		},
	) {
		const output: NodeTypeAndVersion[] = [];
		const nodeNames = this.workflow.getParentNodes(
			nodeName,
			options?.connectionType,
			options?.depth,
		);

		for (const n of nodeNames) {
			const node = this.workflow.nodes[n];
			const entry: NodeTypeAndVersion = {
				name: node.name,
				type: node.type,
				typeVersion: node.typeVersion,
				disabled: node.disabled ?? false,
			};

			if (options?.includeNodeParameters) {
				entry.parameters = node.parameters;
			}

			output.push(entry);
		}
		return output;
	}

	/**
	 * Gets the chat trigger node
	 *
	 * this is needed for sub-nodes where the parent nodes are not available
	 */
	getChatTrigger() {
		for (const node of Object.values(this.workflow.nodes)) {
			if (this.workflow.nodes[node.name].type === CHAT_TRIGGER_NODE_TYPE) {
				return this.workflow.nodes[node.name];
			}
		}

		return null;
	}

	@Memoized
	get nodeType() {
		const { type, typeVersion } = this.node;
		return this.workflow.nodeTypes.getByNameAndVersion(type, typeVersion);
	}

	/**
	 * Gets the feature flags for the current node version.
	 * Uses declarative features from the node type description.
	 * @private
	 */
	@Memoized
	private get nodeFeatures(): NodeFeatures {
		const description = this.nodeType.description;
		return NodeHelpers.getNodeFeatures(description.features, this.node.typeVersion);
	}

	/**
	 * Checks if a feature is enabled for the current node version.
	 * @param featureName - The name of the feature to check
	 * @returns true if the feature is enabled, false otherwise
	 */
	isNodeFeatureEnabled(featureName: string): boolean {
		return this.nodeFeatures[featureName] ?? false;
	}

	@Memoized
	get nodeInputs() {
		return NodeHelpers.getNodeInputs(this.workflow, this.node, this.nodeType.description).map(
			(input) => (typeof input === 'string' ? { type: input } : input),
		);
	}

	getNodeInputs(): INodeInputConfiguration[] {
		return this.nodeInputs;
	}

	@Memoized
	get nodeOutputs() {
		return NodeHelpers.getNodeOutputs(this.workflow, this.node, this.nodeType.description).map(
			(output) => (typeof output === 'string' ? { type: output } : output),
		);
	}

	getConnectedNodes(connectionType: NodeConnectionType): INode[] {
		return this.workflow
			.getParentNodes(this.node.name, connectionType, 1)
			.map((nodeName) => this.workflow.getNode(nodeName))
			.filter((node) => !!node)
			.filter((node) => node.disabled !== true);
	}

	getConnections(destination: INode, connectionType: NodeConnectionType): NodeInputConnections {
		return this.workflow.connectionsByDestinationNode[destination.name]?.[connectionType] ?? [];
	}

	getNodeOutputs(): INodeOutputConfiguration[] {
		return this.nodeOutputs;
	}

	getKnownNodeTypes() {
		return this.workflow.nodeTypes.getKnownTypes();
	}

	getRestApiUrl() {
		return this.additionalData.restApiUrl;
	}

	getInstanceBaseUrl() {
		return this.additionalData.instanceBaseUrl;
	}

	getInstanceId() {
		return this.instanceSettings.instanceId;
	}

	setSignatureValidationRequired() {
		if (this.runExecutionData) this.runExecutionData.validateSignature = true;
	}

	getSignedResumeUrl(parameters: Record<string, string> = {}) {
		const { webhookWaitingBaseUrl, executionId } = this.additionalData;

		if (typeof executionId !== 'string') {
			throw new UnexpectedError('Execution id is missing');
		}

		const baseURL = new URL(`${webhookWaitingBaseUrl}/${executionId}/${this.node.id}`);

		for (const [key, value] of Object.entries(parameters)) {
			baseURL.searchParams.set(key, value);
		}

		const urlForSigning = prepareUrlForSigning(baseURL);

		const token = generateUrlSignature(urlForSigning, this.instanceSettings.hmacSignatureSecret);

		baseURL.searchParams.set(WAITING_TOKEN_QUERY_PARAM, token);

		return baseURL.toString();
	}

	getTimezone() {
		return this.workflow.timezone;
	}

	getCredentialsProperties(type: string) {
		return this.additionalData.credentialsHelper.getCredentialsProperties(type);
	}

	/** Returns the requested decrypted credentials if the node has access to them */
	protected async _getCredentials<T extends object = ICredentialDataDecryptedObject>(
		type: string,
		executeData?: IExecuteData,
		connectionInputData?: INodeExecutionData[],
		itemIndex?: number,
	): Promise<T> {
		const { workflow, node, additionalData, mode, runExecutionData, runIndex } = this;
		// Get the NodeType as it has the information if the credentials are required
		const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);

		// Hardcode for now for security reasons that only a single node can access
		// all credentials
		const fullAccess = [
			HTTP_REQUEST_NODE_TYPE,
			HTTP_REQUEST_TOOL_NODE_TYPE,
			HTTP_REQUEST_AS_TOOL_NODE_TYPE,
		].includes(node.type);

		let nodeCredentialDescription: INodeCredentialDescription | undefined;
		if (!fullAccess) {
			if (nodeType.description.credentials === undefined) {
				throw new NodeOperationError(
					node,
					`Node type "${node.type}" does not have any credentials defined`,
					{ level: 'warning' },
				);
			}

			nodeCredentialDescription = nodeType.description.credentials.find(
				(credentialTypeDescription) => credentialTypeDescription.name === type,
			);
			if (nodeCredentialDescription === undefined) {
				throw new NodeOperationError(
					node,
					`Node type "${node.type}" does not have any credentials of type "${type}" defined`,
					{ level: 'warning' },
				);
			}

			if (
				!NodeHelpers.displayParameter(
					// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
					additionalData.currentNodeParameters || node.parameters,
					nodeCredentialDescription,
					node,
					nodeType.description,
					node.parameters,
				)
			) {
				// Credentials should not be displayed even if they would be defined
				throw new NodeOperationError(node, 'Credentials not found');
			}
		}

		// Check if node has any credentials defined
		if (!fullAccess && !node.credentials?.[type]) {
			// If none are defined check if the credentials are required or not

			if (nodeCredentialDescription?.required === true) {
				// Credentials are required so error
				if (!node.credentials) {
					throw new NodeOperationError(node, 'Node does not have any credentials set', {
						level: 'warning',
					});
				}
				if (!node.credentials[type]) {
					throw new NodeOperationError(
						node,
						`Node does not have any credentials set for "${type}"`,
						{
							level: 'warning',
						},
					);
				}
			} else {
				// Credentials are not required
				throw new NodeOperationError(node, 'Node does not require credentials');
			}
		}

		if (fullAccess && !node.credentials?.[type]) {
			// Make sure that fullAccess nodes still behave like before that if they
			// request access to credentials that are currently not set it returns undefined
			throw new NodeOperationError(node, 'Credentials not found');
		}

		let expressionResolveValues: ICredentialsExpressionResolveValues | undefined;
		if (connectionInputData && runExecutionData && runIndex !== undefined) {
			expressionResolveValues = {
				connectionInputData,
				// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
				itemIndex: itemIndex || 0,
				node,
				runExecutionData,
				runIndex,
				workflow,
			} as ICredentialsExpressionResolveValues;
		}

		const nodeCredentials = node.credentials
			? node.credentials[type]
			: ({} as INodeCredentialsDetails);

		// TODO: solve using credentials via expression
		// if (name.charAt(0) === '=') {
		// 	// If the credential name is an expression resolve it
		// 	const additionalKeys = getAdditionalKeys(additionalData, mode);
		// 	name = workflow.expression.getParameterValue(
		// 		name,
		// 		runExecutionData || null,
		// 		runIndex || 0,
		// 		itemIndex || 0,
		// 		node.name,
		// 		connectionInputData || [],
		// 		mode,
		// 		additionalKeys,
		// 	) as string;
		// }

		additionalData.executionContext = this.getExecutionContext();
		const decryptedDataObject = await additionalData.credentialsHelper.getDecrypted(
			additionalData,
			nodeCredentials,
			type,
			mode,
			executeData,
			false,
			expressionResolveValues,
		);

		return decryptedDataObject as T;
	}

	@Memoized
	protected get additionalKeys() {
		return getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData);
	}

	/** Returns the requested resolved (all expressions replaced) node parameters. */
	getNodeParameter(
		parameterName: string,
		// eslint-disable-next-line @typescript-eslint/no-explicit-any
		fallbackValue?: any,
		options?: IGetNodeParameterOptions,
	): NodeParameterValueType | object {
		const itemIndex = 0;
		return this._getNodeParameter(parameterName, itemIndex, fallbackValue, options);
	}

	protected _getNodeParameter(
		parameterName: string,
		itemIndex: number,
		// eslint-disable-next-line @typescript-eslint/no-explicit-any
		fallbackValue?: any,
		options?: IGetNodeParameterOptions,
	): NodeParameterValueType | object {
		const { workflow, node, mode, runExecutionData, runIndex, connectionInputData, executeData } =
			this;

		const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);

		// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
		const value = get(node.parameters, parameterName, fallbackValue);

		if (value === undefined) {
			throw new ApplicationError('Could not get parameter', { extra: { parameterName } });
		}

		if (options?.rawExpressions) {
			// eslint-disable-next-line @typescript-eslint/no-unsafe-return
			return value;
		}

		const { additionalKeys } = this;

		let returnData;

		try {
			returnData = workflow.expression.getParameterValue(
				// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
				value,
				runExecutionData,
				runIndex,
				itemIndex,
				node.name,
				connectionInputData,
				mode,
				additionalKeys,
				executeData,
				false,
				{},
				options?.contextNode?.name,
			);
			cleanupParameterData(returnData);
		} catch (e) {
			if (
				e instanceof ExpressionError &&
				node.continueOnFail &&
				node.type === 'n8n-nodes-base.set'
			) {
				// https://linear.app/n8n/issue/PAY-684
				returnData = [{ name: undefined, value: undefined }];
			} else {
				// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
				if (e.context) e.context.parameter = parameterName;
				// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
				e.cause = value;
				throw e;
			}
		}

		// This is outside the try/catch because it throws errors with proper messages
		if (options?.extractValue) {
			returnData = extractValue(returnData, parameterName, node, nodeType, itemIndex);
		}

		// Make sure parameter value is the type specified in the ensureType option, if needed convert it
		if (options?.ensureType) {
			returnData = ensureType(options.ensureType, returnData, parameterName, {
				itemIndex,
				runIndex,
				nodeCause: node.name,
			});
		}

		if (options?.skipValidation) return returnData;

		// Validate parameter value if it has a schema defined(RMC) or validateType defined
		// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
		returnData = validateValueAgainstSchema(
			node,
			nodeType,
			returnData,
			parameterName,
			runIndex,
			itemIndex,
		);

		// eslint-disable-next-line @typescript-eslint/no-unsafe-return
		return returnData;
	}

	evaluateExpression(expression: string, itemIndex: number = 0) {
		return this.workflow.expression.resolveSimpleParameterValue(
			`=${expression}`,
			{},
			this.runExecutionData,
			this.runIndex,
			itemIndex,
			this.node.name,
			this.connectionInputData,
			this.mode,
			this.additionalKeys,
			this.executeData,
		);
	}

	async prepareOutputData(outputData: INodeExecutionData[]) {
		return [outputData];
	}
}
