import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import {
	ICredentialContext,
	IExecuteData,
	IExecutionContext,
	IN8NOAuthMetadata,
	INodeExecutionData,
	N8NOAuthMetadataSchema,
	OAuthResourceGrant,
	PlaintextExecutionContext,
	toCredentialContext,
	toExecutionContextEstablishmentHookParameter,
	toSecureArtifacts,
	Workflow,
} from 'n8n-workflow';

import { Cipher } from '@/encryption';
import { deepMerge } from '@/utils/deep-merge';

import { ExecutionContextHookRegistry } from './execution-context-hook-registry.service';

@Service()
export class ExecutionContextService {
	constructor(
		private readonly logger: Logger,
		private readonly executionContextHookRegistry: ExecutionContextHookRegistry,
		private readonly cipher: Cipher,
	) {}

	async decryptCredentialContext(encrypted: string): Promise<ICredentialContext> {
		const decrypted = await this.cipher.decryptV2(encrypted);
		return toCredentialContext(decrypted);
	}

	async decryptExecutionContext(context: IExecutionContext): Promise<PlaintextExecutionContext> {
		const { credentials: encCredentials, secureArtifacts: encSecureArtifacts, ...rest } = context;
		const result: PlaintextExecutionContext = { ...rest };
		if (encCredentials) {
			result.credentials = await this.decryptCredentialContext(encCredentials);
		}
		if (encSecureArtifacts) {
			const decrypted = await this.cipher.decryptV2(encSecureArtifacts);
			result.secureArtifacts = toSecureArtifacts(decrypted);
		}
		return result;
	}

	/**
	 * Builds and encrypts a credential context for a manual editor-triggered execution.
	 *
	 * @param n8nAuthCookie - The JWT string extracted from the `n8n-auth` browser cookie.
	 * @returns Encrypted credential context string for storage in `IExecutionContext.credentials`.
	 */
	async buildManualExecutionCredentials(n8nAuthCookie: string): Promise<string> {
		const payload: ICredentialContext = {
			version: 1,
			identity: n8nAuthCookie,
			metadata: { source: 'manual-execution' },
		};
		return await this.cipher.encryptV2(payload);
	}

	/**
	 * Builds a credential context for work done on behalf of a live HTTP request, whose
	 * identity is the caller's own n8n session cookie.
	 *
	 * Preferred over {@link buildManualExecutionCredentials} whenever there is a request:
	 * the `cookie-source` metadata is re-validated with the request context (browser id,
	 * method, endpoint), which the `manual-execution` shape deliberately skips because a
	 * running execution no longer has a request to check against. The single definition
	 * of that shape lives here, so it cannot drift from what the resolver validates.
	 *
	 * @param n8nAuthCookie - The JWT string extracted from the `n8n-auth` browser cookie.
	 * @param request - Method, endpoint and browser id of the originating request.
	 */
	buildRequestBoundCredentialContext(
		n8nAuthCookie: string,
		{ method, endpoint, browserId }: { method: string; endpoint: string; browserId?: string },
	): ICredentialContext {
		return {
			version: 1,
			identity: n8nAuthCookie,
			// Listed field by field rather than spread, so the context can only ever carry
			// what the resolver re-validates.
			metadata: { source: 'cookie-source', method, endpoint, browserId },
		};
	}

	/**
	 * {@link buildRequestBoundCredentialContext}, encrypted for storage in
	 * `IExecutionContext.credentials`. Callers that hand the context straight to a
	 * resolver want the plaintext builder instead.
	 */
	async buildRequestBoundCredentials(
		n8nAuthCookie: string,
		request: { method: string; endpoint: string; browserId?: string },
	): Promise<string> {
		return await this.cipher.encryptV2(
			this.buildRequestBoundCredentialContext(n8nAuthCookie, request),
		);
	}

	async maybeBindExecutionId(
		context: IExecutionContext,
		executionId: string | undefined,
		{ allowInherit = false }: { allowInherit?: boolean } = {},
	): Promise<IExecutionContext> {
		if (!executionId) {
			return context;
		}
		const decryptedContext = await this.decryptExecutionContext(context);
		if (decryptedContext.credentials) {
			if (decryptedContext.credentials.metadata) {
				const metadata = N8NOAuthMetadataSchema.safeParse(decryptedContext.credentials.metadata);
				// Only sealed carriers (a resolved subject) need execution binding; a
				// non-sealed n8n-oauth carrier has nothing that reads its executionPath.
				if (metadata.success && metadata.data.subject) {
					const executionPath = metadata.data.executionPath ?? [];
					// Seed an empty path at mint, or extend it only for a legitimate re-run of
					// this carrier's own execution data — an inherited child (sub-workflow /
					// error workflow) or a retry. A non-empty path that lacks this id on a
					// non-inherit bind means the carrier was attached to an execution it was
					// not minted for — leave it, so credential resolution rejects it.
					const mayBind =
						!executionPath.includes(executionId) && (executionPath.length === 0 || allowInherit);
					if (mayBind) {
						metadata.data.executionPath = [...executionPath, executionId];
						decryptedContext.credentials.metadata = metadata.data;
						return await this.encryptExecutionContext(decryptedContext);
					}
				}
			}
		}
		return context;
	}

	/**
	 * Seals the identity a trigger authenticated its caller with. The token stays in
	 * `identity` as evidence; `grant` lets the run re-verify that token after the
	 * protected resource stops resolving (see {@link OAuthResourceGrant}), and `subject`
	 * seals the resolved n8n user so a bound run resolves without re-verifying the token.
	 */
	async buildTriggerIdentityCredentials(
		token: string,
		resource: string,
		grant?: OAuthResourceGrant,
		subject?: string,
	): Promise<string> {
		const metadata: IN8NOAuthMetadata = {
			source: 'n8n-oauth',
			resource,
			establishedAt: Date.now(),
			executionPath: [],
			...(grant ? { grant } : {}),
			...(subject ? { subject } : {}),
		};
		const payload: ICredentialContext = {
			version: 1,
			identity: token,
			metadata,
		};
		return await this.cipher.encryptV2(payload);
	}

	async encryptExecutionContext(context: PlaintextExecutionContext): Promise<IExecutionContext> {
		const { credentials, secureArtifacts, ...rest } = context;
		const result: IExecutionContext = { ...rest };
		if (credentials) {
			result.credentials = await this.cipher.encryptV2(credentials);
		}
		if (secureArtifacts) {
			result.secureArtifacts = await this.cipher.encryptV2(secureArtifacts);
		}
		return result;
	}

	mergeExecutionContexts(
		baseContext: PlaintextExecutionContext,
		contextToMerge: Partial<PlaintextExecutionContext>,
	): PlaintextExecutionContext {
		return deepMerge(baseContext, contextToMerge);
	}

	/**
	 * Re-runs the sub-execution context hooks for a sub-workflow that inherited its
	 * parent's context, so the child's own execution record reflects context
	 * derived from the child workflow (e.g. its redaction policy) instead of only
	 * the parent's.
	 *
	 * Only hooks that opted in via `runForSubExecution` run here — not every global
	 * hook — so a hook must consciously declare that it is safe to re-run for
	 * children. Trigger items are not re-processed: the child inherits the parent's
	 * (already stripped) input, so hooks run with `triggerItems: null` and any items
	 * they return are ignored. Node-specific hooks are not run.
	 *
	 * Returns the (re-encrypted) context, or the inherited context untouched when
	 * no sub-execution hooks are registered.
	 */
	async augmentSubExecutionContext(
		workflow: Workflow,
		startItem: IExecuteData,
		contextToAugment: IExecutionContext,
	): Promise<IExecutionContext> {
		const subExecutionHooks = this.executionContextHookRegistry.getSubExecutionHooks();
		if (subExecutionHooks.length === 0) return contextToAugment;

		let context = await this.decryptExecutionContext(contextToAugment);

		for (const subExecutionHook of subExecutionHooks) {
			const result = await subExecutionHook.execute({
				triggerNode: startItem.node,
				workflow,
				triggerItems: null,
				context,
				options: {},
			});

			if (result.contextUpdate) {
				context = this.mergeExecutionContexts(context, result.contextUpdate);
			}
		}

		return await this.encryptExecutionContext(context);
	}

	// startItem is mutated to reflect any changes to trigger items made by the hooks
	async augmentExecutionContextWithHooks(
		workflow: Workflow,
		startItem: IExecuteData,
		contextToAugment: IExecutionContext,
	): Promise<{
		context: IExecutionContext;
		triggerItems: INodeExecutionData[] | null;
	}> {
		// Main input data is an array of items, each item represents an event that triggers the workflow execution
		// The 'main' selector selects the input name of the nodes, and the 0 index represents the runIndex,
		// 0 being the first run of this node in the workflow.

		let currentTriggerItems = startItem.data['main'][0];

		const contextEstablishmentHookParameters = {
			...(workflow.getNode(startItem.node.name)?.parameters ?? {}),
			...startItem.node.parameters,
		};

		// decrypt the context to work with plaintext data
		let context = await this.decryptExecutionContext(contextToAugment);

		// Run global hooks!
		for (const globalHook of this.executionContextHookRegistry.getGlobalHooks()) {
			// call the hook to let it modify the context and/or the main input data
			const result = await globalHook.execute({
				triggerNode: startItem.node,
				workflow,
				triggerItems: currentTriggerItems,
				context,
				options: {},
			});

			if (result.triggerItems !== undefined) {
				// Update trigger items in case they were modified by the hook
				currentTriggerItems = result.triggerItems;
			}

			if (result.contextUpdate) {
				// Merge any returned context fields into the execution context
				context = this.mergeExecutionContexts(context, result.contextUpdate);
			}
		}

		const startNodeParametersResult = toExecutionContextEstablishmentHookParameter(
			contextEstablishmentHookParameters,
		);

		if (!startNodeParametersResult || startNodeParametersResult.error) {
			if (startNodeParametersResult?.error) {
				this.logger.warn(
					`Failed to parse execution context establishment hook parameters for node ${startItem.node.name}: ${startNodeParametersResult.error.message}`,
				);
			}
			// no node specific execution establishment hooks found, we return early
			return {
				context: await this.encryptExecutionContext(context),
				triggerItems: currentTriggerItems,
			};
		}

		// startNodeParameters will hold the parameters of the start node
		// this can be the settings for the different hooks to be executed
		// for example to extract the bearer token from the start node data.
		const startNodeParameters = startNodeParametersResult.data;

		// based on startNodeParameters, startNodeType and currentTriggerItems we can now
		// iterate over the different hooks to extract specific data for the runtime context
		for (const hookParameters of startNodeParameters.contextEstablishmentHooks.hooks) {
			const hook = this.executionContextHookRegistry.getHookByName(hookParameters.hookName);

			if (!hook) {
				this.logger.warn(
					`Execution context establishment hook ${hookParameters.hookName} not found, skipping this hook`,
				);
				continue;
			}
			try {
				// call the hook to let it modify the context and/or the main input data
				const result = await hook.execute({
					triggerNode: startItem.node,
					workflow,
					triggerItems: currentTriggerItems,
					context,
					options: hookParameters,
				});

				if (result.triggerItems !== undefined) {
					// Update trigger items in case they were modified by the hook
					currentTriggerItems = result.triggerItems;
				}

				if (result.contextUpdate) {
					// Merge any returned context fields into the execution context
					context = this.mergeExecutionContexts(context, result.contextUpdate);
				}
			} catch (error) {
				this.logger.warn(
					`Failed to execute context establishment hook ${hookParameters.hookName}`,
					{ error },
				);
				if (!hookParameters.isAllowedToFail) {
					// If the hook is not allowed to fail, rethrow the error
					throw error;
				}
			}
		}

		return {
			context: await this.encryptExecutionContext(context),
			triggerItems: currentTriggerItems,
		};
	}
}
