/**
 * Single-source declaration of webhook-description fields.
 *
 * A webhook description needs values derived from the node instance's
 * parameters, historically encoded as expression templates such as
 * `={{$parameter["path"]}}`. The editor still needs those strings (they are
 * what survives the JSON serialization of node descriptions), but on the
 * backend they force the expression engine (under `N8N_EXPRESSION_ENGINE=vm`,
 * a V8 isolate) into every webhook request even when the user authored no
 * expression.
 *
 * The helpers here generate the template string and a native resolver from one
 * declaration, so the two cannot drift. Backend resolution sites call
 * {@link resolveWebhookDescriptionField} first and only fall back to the engine
 * when the user's parameters actually contain expressions.
 */

import type {
	INode,
	INodeParameters,
	IWebhookDescription,
	NativeParameterResolvers,
	NodeParameterValueType,
} from './interfaces';
import { WEBHOOK_RESOLVERS } from './interfaces';
import { isResourceLocatorValue } from './type-guards';

export type WebhookDescriptionField = NativeParameterResolvers[string];

/**
 * A field that reads a node parameter, e.g. `fromParameter('httpMethod', 'GET')`
 * → template `={{$parameter["httpMethod"] || "GET"}}` and a resolver applying
 * the same truthiness fallback. Nested reads take a path array. Walking into a
 * missing parent yields `undefined`, matching what the engine returns for the
 * same reference.
 */
export function fromParameter(path: string | string[], fallback?: string): WebhookDescriptionField {
	const segments = Array.isArray(path) ? path : [path];
	const accessor = segments.map((segment) => `["${segment}"]`).join('');
	const tail = fallback === undefined ? '' : ` || ${JSON.stringify(fallback)}`;

	return {
		template: `={{$parameter${accessor}${tail}}}`,
		resolve: (parameters) => {
			let current: unknown = parameters;
			for (const key of segments) {
				if (current === null || current === undefined) return fallback;
				current = (current as INodeParameters)[key];
			}
			// `||` in the template is plain JS truthiness, not a nullish check.
			if (fallback !== undefined && !current) return fallback;
			return current as NodeParameterValueType | undefined;
		},
	};
}

/**
 * A field computed by a function of the parameters: the template inlines the
 * function's source and the resolver is the function itself, so both
 * representations run the same code.
 *
 * The function MUST be self-contained: closing over an outer identifier (an
 * import, a module constant) produces a template the expression sandbox cannot
 * evaluate. The parity test in the Webhook node's description.test.ts catches
 * this; add one for any node that declares `fromFunction` fields.
 */
export function fromFunction<P extends INodeParameters>(
	fn: (parameters: P) => NodeParameterValueType | undefined,
): WebhookDescriptionField {
	return {
		template: `={{(${String(fn)})($parameter)}}`,
		resolve: fn as WebhookDescriptionField['resolve'],
	};
}

/**
 * Spreads into an {@link IWebhookDescription}: the template string per field,
 * plus the resolver map under the backend-only {@link WEBHOOK_RESOLVERS} key.
 */
export function webhookDescriptionFields<K extends string>(
	fields: Record<K, WebhookDescriptionField>,
): Record<K, string> & { [WEBHOOK_RESOLVERS]: NativeParameterResolvers } {
	const templates = {} as Record<K, string>;
	const resolvers: NativeParameterResolvers = {};
	for (const [field, entry] of Object.entries<WebhookDescriptionField>(fields)) {
		templates[field as K] = entry.template;
		resolvers[field] = entry;
	}
	return { ...templates, [WEBHOOK_RESOLVERS]: resolvers };
}

export type NativeResolution =
	| { resolved: true; value: NodeParameterValueType | undefined }
	| { resolved: false };

const NOT_RESOLVED: NativeResolution = { resolved: false };

/**
 * Resolves a description field without the expression engine when the field
 * declares a resolver, still carries the exact template that resolver was
 * generated from (a description built by spreading another inherits resolvers
 * for fields it overrides), and the node's parameters are static.
 *
 * Pass the workflow's own node (`workflow.nodes[name]`): its parameters
 * include the defaults the `Workflow` constructor applies.
 */
export function resolveWebhookDescriptionField(
	node: Pick<INode, 'parameters'>,
	description: IWebhookDescription,
	field: string,
): NativeResolution {
	const entry = description[WEBHOOK_RESOLVERS]?.[field];
	if (entry === undefined || description[field] !== entry.template) return NOT_RESOLVED;
	if (!nodeParametersAreStatic(node)) return NOT_RESOLVED;
	return { resolved: true, value: entry.resolve(node.parameters) };
}

/**
 * Whether every field of the description is engine-free for a static-parameter
 * node: either a resolver is declared for it, or the value contains no
 * expression template at any depth. A template added later without a resolver
 * makes this false, so callers skipping isolate acquisition on the strength of
 * this stay correct automatically.
 */
export function webhookDescriptionIsNativelyResolvable(description: IWebhookDescription): boolean {
	const resolvers = description[WEBHOOK_RESOLVERS];
	return Object.entries(description).every(
		([field, value]) => resolvers?.[field]?.template === value || !containsDynamicValue(value),
	);
}

/**
 * Whether a node's parameters can stand in for the `$parameter` proxy: beyond a
 * plain property read, the proxy only resolves `=` values, unwraps
 * resource-locator values and handles `&sibling` lookups — all of which require
 * a dynamic value somewhere in the parameters.
 */
export function nodeParametersAreStatic(node: Pick<INode, 'parameters'>): boolean {
	return !containsDynamicValue(node.parameters);
}

function containsDynamicValue(value: unknown): boolean {
	if (typeof value === 'string') return value.startsWith('=');

	if (Array.isArray(value)) return value.some(containsDynamicValue);

	if (value !== null && typeof value === 'object') {
		if (isResourceLocatorValue(value)) return true;
		return Object.keys(value).some((key) => containsDynamicValue(Reflect.get(value, key)));
	}

	return false;
}
