/**
 * Consolidated executions tool — list, get, run, debug, get-node-output,
 * get-resolved-node-parameters, stop.
 */
import { Tool } from '@n8n/agents';
import {
	buildRunWorkflowSessionGrantKey,
	instanceAiConfirmationSeveritySchema,
} from '@n8n/api-types';
import { nanoid } from 'nanoid';
import { z } from 'zod';

import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas';
import type { InstanceAiContext } from '../types';

// ── Constants ──────────────────────────────────────────────────────────────

const MAX_TIMEOUT_MS = 600_000;

// ── Action schemas ─────────────────────────────────────────────────────────

const listAction = z.object({
	action: z.literal('list').describe('List recent workflow executions'),
	workflowId: z.string().optional().describe('Workflow ID'),
	status: z
		.string()
		.optional()
		.describe('Filter by status (e.g. "success", "error", "running", "waiting")'),
	limit: z
		.number()
		.int()
		.positive()
		.max(100)
		.optional()
		.describe('Max results to return (default 20)'),
});

const getAction = z.object({
	action: z.literal('get').describe('Get execution status without blocking (poll running ones)'),
	executionId: z.string().describe('Execution ID'),
});

const runAction = z.object({
	action: z.literal('run').describe('Execute a workflow and wait for completion'),
	workflowId: z.string().describe('Workflow ID'),
	inputData: z
		.record(z.unknown())
		.optional()
		.describe(
			'Input data passed to the workflow trigger. Works for ANY trigger type — ' +
				'the system injects inputData as the trigger node output, bypassing the need for a real event. ' +
				'For webhook triggers, inputData is the request body (do NOT wrap in { body: ... }). ' +
				'For event-based triggers (e.g. Linear, GitHub, Slack), pass inputData matching ' +
				'the shape the trigger would emit (e.g. { action: "create", data: { ... } }).',
		),
	timeout: z
		.number()
		.int()
		.min(1000)
		.max(MAX_TIMEOUT_MS)
		.optional()
		.describe('Max wait time in milliseconds (default 300000, max 600000)'),
});

const debugAction = z.object({
	action: z
		.literal('debug')
		.describe(
			'Analyze a failed execution with structured diagnostics. When a node failed, ' +
				"`failedNode.resolvedParameters` includes that node's raw parameters, the same " +
				'tree with expressions substituted, and lists of expressions that threw or resolved to empty values',
		),
	executionId: z.string().describe('Execution ID'),
});

const getNodeOutputAction = z.object({
	action: z
		.literal('get-node-output')
		.describe('Retrieve raw output of a specific node from an execution'),
	executionId: z.string().describe('Execution ID'),
	nodeName: z.string().describe("Name of the node (must exist in the execution's workflow)"),
	startIndex: z.number().int().min(0).optional().describe('Item index to start from (default 0)'),
	maxItems: z
		.number()
		.int()
		.min(1)
		.max(50)
		.optional()
		.describe('Maximum number of items to return (default 10, max 50)'),
});

const getResolvedNodeParametersAction = z.object({
	action: z
		.literal('get-resolved-node-parameters')
		.describe(
			"Replay expression resolution for a node's parameters against a past execution. " +
				'Returns the raw `parameters` (with expressions intact), the `resolved` tree (same ' +
				'shape, expressions substituted), `failedExpressions` (those that threw), and ' +
				'`emptyResolutions` (those that resolved to `null`/`undefined`/`""` — the common ' +
				'silent cause of empty downstream fields). Use this when debugging why a node ' +
				'received an unexpected value or failed because of a parameter — far more precise ' +
				'than guessing from raw expression strings or input data.',
		),
	executionId: z.string().describe('Execution ID'),
	nodeName: z.string().describe("Name of the node (must exist in the execution's workflow)"),
	itemIndex: z
		.number()
		.int()
		.min(0)
		.optional()
		.describe('Input item index to resolve against (default 0)'),
	runIndex: z
		.number()
		.int()
		.min(0)
		.optional()
		.describe('Which run of the node to use, if it ran multiple times (default: last run)'),
});

const stopAction = z.object({
	action: z.literal('stop').describe('Cancel a running workflow execution'),
	executionId: z.string().describe('Execution ID'),
});

const inputSchema = sanitizeInputSchema(
	z.discriminatedUnion('action', [
		listAction,
		getAction,
		runAction,
		debugAction,
		getNodeOutputAction,
		getResolvedNodeParametersAction,
		stopAction,
	]),
);

type Input = z.infer<typeof inputSchema>;

// ── Suspend / resume schemas (used by `run`) ───────────────────────────────

const suspendSchema = z.object({
	requestId: z.string(),
	message: z.string(),
	severity: instanceAiConfirmationSeveritySchema,
});

const resumeSchema = z.object({
	approved: z.boolean(),
	/** `'session'` — the user chose "always allow"; persist a thread-level grant so
	 *  subsequent runs skip HITL for this action. */
	scope: z.enum(['once', 'session']).optional(),
});

// ── Handlers ───────────────────────────────────────────────────────────────

async function handleList(context: InstanceAiContext, input: Extract<Input, { action: 'list' }>) {
	const executions = await context.executionService.list({
		workflowId: input.workflowId,
		status: input.status,
		limit: input.limit,
	});
	return { executions };
}

async function handleGet(context: InstanceAiContext, input: Extract<Input, { action: 'get' }>) {
	return await context.executionService.getStatus(input.executionId);
}

function normalizeWorkflowName(name: string): string {
	return name.trim().toLowerCase();
}

function hasWorkflowName(
	allowList: ReadonlySet<string>,
	workflowName: string | undefined,
): boolean {
	if (!workflowName) return false;

	const normalizedWorkflowName = normalizeWorkflowName(workflowName);
	for (const allowedName of allowList) {
		if (normalizeWorkflowName(allowedName) === normalizedWorkflowName) return true;
	}

	return false;
}

async function findAllowedWorkflowByName(
	context: InstanceAiContext,
	allowList: ReadonlySet<string> | undefined,
): Promise<{ id: string; name: string } | undefined> {
	if (process.env.E2E_TESTS !== 'true' || allowList === undefined) return undefined;

	for (const allowedName of allowList) {
		const workflows = await context.workflowService.list({ query: allowedName, limit: 10 });
		const match = workflows.find((workflow) => hasWorkflowName(allowList, workflow.name));
		if (match) return { id: match.id, name: match.name };
	}

	return undefined;
}

async function handleRun(
	context: InstanceAiContext,
	input: Extract<Input, { action: 'run' }>,
	resumeData: z.infer<typeof resumeSchema> | undefined,
	suspend: (payload: z.infer<typeof suspendSchema>) => Promise<never>,
) {
	if (context.permissions?.runWorkflow === 'blocked') {
		return {
			executionId: '',
			status: 'error' as const,
			denied: true,
			reason: 'Action blocked by admin',
		};
	}

	// `always_allow` is only honored for the workflow IDs the caller pre-authorized
	// (e.g. checkpoint follow-ups scope the override to the workflows the checkpoint
	// is verifying). When the allow-list is unset, `always_allow` applies broadly,
	// matching the legacy behavior.
	const allowList = context.allowedRunWorkflowIds;
	const workflowNameAllowList = context.allowedRunWorkflowNames;
	let workflowName: string | undefined;
	let workflowId = input.workflowId;
	const getWorkflowName = async () => {
		workflowName ??= await context.workflowService
			.get(workflowId)
			.then((wf) => wf.name)
			.catch(() => undefined);
		return workflowName;
	};
	let allowedByName =
		context.permissions?.runWorkflow === 'always_allow' &&
		workflowNameAllowList !== undefined &&
		hasWorkflowName(workflowNameAllowList, await getWorkflowName());
	if (
		context.permissions?.runWorkflow === 'always_allow' &&
		workflowNameAllowList !== undefined &&
		!allowedByName &&
		workflowName === undefined
	) {
		const fallbackWorkflow = await findAllowedWorkflowByName(context, workflowNameAllowList);
		if (fallbackWorkflow) {
			workflowId = fallbackWorkflow.id;
			workflowName = fallbackWorkflow.name;
			allowedByName = true;
		}
	}
	const allowedByScope =
		context.requireRunWorkflowApproval !== true &&
		context.permissions?.runWorkflow === 'always_allow' &&
		(allowList === undefined || allowList.has(workflowId) || allowedByName);

	// A per-workflow "always allow" grant skips HITL for the rest of the session, but an
	// admin's `requireRunWorkflowApproval` always wins - same gate as `allowedByScope`.
	const grantKey = buildRunWorkflowSessionGrantKey(workflowId);
	const allowedBySessionGrant =
		context.requireRunWorkflowApproval !== true &&
		context.sessionApprovedToolKeys?.has(grantKey) === true;
	const needsApproval = !allowedByScope && !allowedBySessionGrant;

	// If approval is required and this is the first call, suspend for confirmation
	if (needsApproval && (resumeData === undefined || resumeData === null)) {
		const workflowName = (await getWorkflowName()) ?? input.workflowId;
		return await suspend({
			requestId: nanoid(),
			message: `Execute ${workflowName} (ID: ${input.workflowId})`,
			severity: 'warning' as const,
		});
	}

	// If resumed with denial
	if (resumeData !== undefined && resumeData !== null && !resumeData.approved) {
		return {
			executionId: '',
			status: 'error' as const,
			denied: true,
			reason: 'User denied the action',
		};
	}

	// "Always allow" — persist the grant so subsequent runs of this workflow skip HITL.
	if (resumeData?.approved && resumeData.scope === 'session') {
		await context.grantSessionToolApproval?.(grantKey);
	}

	// Approved or always_allow — execute
	return await context.executionService.run(workflowId, input.inputData, {
		timeout: input.timeout,
	});
}

async function handleDebug(context: InstanceAiContext, input: Extract<Input, { action: 'debug' }>) {
	return await context.executionService.getDebugInfo(input.executionId);
}

async function handleGetNodeOutput(
	context: InstanceAiContext,
	input: Extract<Input, { action: 'get-node-output' }>,
) {
	return await context.executionService.getNodeOutput(input.executionId, input.nodeName, {
		startIndex: input.startIndex,
		maxItems: input.maxItems,
	});
}

async function handleGetResolvedNodeParameters(
	context: InstanceAiContext,
	input: Extract<Input, { action: 'get-resolved-node-parameters' }>,
) {
	return await context.executionService.getResolvedNodeParameters(
		input.executionId,
		input.nodeName,
		{
			itemIndex: input.itemIndex,
			runIndex: input.runIndex,
		},
	);
}

async function handleStop(context: InstanceAiContext, input: Extract<Input, { action: 'stop' }>) {
	return await context.executionService.stop(input.executionId);
}

// ── Tool factory ───────────────────────────────────────────────────────────

export function createExecutionsTool(context: InstanceAiContext) {
	return new Tool('executions')
		.description(
			'Manage workflow executions — list, inspect, run, debug, get node output, ' +
				'get resolved node parameters for a past run, and stop. ' +
				'Use action="run" for verification when the current turn is responsible for verification; prefer verify-built-workflow for standard post-build verification.',
		)
		.input(inputSchema)
		.suspend(suspendSchema)
		.resume(resumeSchema)
		.handler(async (input: Input, ctx) => {
			switch (input.action) {
				case 'list':
					return await handleList(context, input);
				case 'get':
					return await handleGet(context, input);
				case 'run': {
					return await handleRun(context, input, ctx.resumeData, ctx.suspend);
				}
				case 'debug':
					return await handleDebug(context, input);
				case 'get-node-output':
					return await handleGetNodeOutput(context, input);
				case 'get-resolved-node-parameters':
					return await handleGetResolvedNodeParameters(context, input);
				case 'stop':
					return await handleStop(context, input);
			}
		})
		.build();
}
