import type {
	McpToolCallRequest,
	McpToolCallResult,
	McpTool,
	InstanceAiGatewayCapabilities,
	ToolCategory,
} from '@n8n/api-types';
import { nanoid } from 'nanoid';
import { EventEmitter } from 'node:events';

const REQUEST_TIMEOUT_MS = 60_000; // 1 minute — tool calls like browser automation and shell execution can be long-running

// ── Internal types ───────────────────────────────────────────────────────────

interface PendingRequest {
	resolve: (result: McpToolCallResult) => void;
	reject: (error: Error) => void;
	timer: NodeJS.Timeout;
	toolCall: McpToolCallRequest;
}

export interface LocalGatewayRequestEvent {
	type: 'filesystem-request';
	payload: {
		requestId: string;
		toolCall: McpToolCallRequest;
	};
}

export interface LocalGatewayDisconnectEvent {
	type: 'gateway-disconnect';
}

export type LocalGatewayEvent = LocalGatewayRequestEvent | LocalGatewayDisconnectEvent;

/**
 * Singleton MCP gateway for a connected local client (e.g. the computer-use daemon).
 *
 * The client advertises its capabilities as `McpTool[]` on connect; all tool
 * calls are dispatched generically via the SSE channel. Tools are not limited
 * to filesystem operations — any capability the daemon exposes is supported.
 *
 * Protocol:
 * 1. Client connects via SSE to GET /instance-ai/gateway/events
 * 2. Client POSTs MCP tool definitions to /instance-ai/gateway/init
 * 3. callTool() → emits filesystem-request via SSE
 * 4. Client executes locally, POSTs MCP result to /instance-ai/gateway/response/:requestId
 * 5. resolveRequest() resolves the pending promise → caller gets McpToolCallResult
 *
 * Resource-access confirmations (GATEWAY_CONFIRMATION_REQUIRED) are handled at the
 * tool layer via native agents suspend/resume data — not here.
 */
export class LocalGateway {
	private readonly pendingRequests = new Map<string, PendingRequest>();

	private readonly emitter = new EventEmitter();

	private _connected = false;

	private _connectedAt: string | null = null;

	private _rootPath: string | null = null;

	private _hostIdentifier: string | null = null;

	private _toolCategories: ToolCategory[] = [];

	private _availableTools: McpTool[] = [];

	private _excludedToolCategories: ReadonlySet<string> = new Set();

	get isConnected(): boolean {
		return this._connected;
	}

	get connectedAt(): string | null {
		return this._connectedAt;
	}

	get rootPath(): string | null {
		return this._rootPath;
	}

	/** Restrict which tool categories are exposed to the agent. Pass an empty set to expose all. */
	setExcludedToolCategories(categories: string[]): void {
		this._excludedToolCategories = new Set(categories);
	}

	private isExcluded(tool: McpTool): boolean {
		const category = tool.annotations?.category;
		return category !== undefined && this._excludedToolCategories.has(category);
	}

	/** The MCP tools advertised by the client on connect, minus excluded categories. */
	getAvailableTools(): McpTool[] {
		return this._availableTools.filter((t) => !this.isExcluded(t));
	}

	/** Return tools that belong to the given category (based on annotations.category). */
	getToolsByCategory(category: string): McpTool[] {
		if (this._excludedToolCategories.has(category)) return [];
		return this._availableTools.filter((t) => t.annotations?.category === category);
	}

	/** Subscribe to outbound tool call events (consumed by the SSE endpoint). */
	onRequest(listener: (event: LocalGatewayRequestEvent) => void): () => void {
		this.emitter.on('filesystem-request', listener);
		return () => this.emitter.off('filesystem-request', listener);
	}

	/** Subscribe to disconnect events so the SSE endpoint can tell the daemon to tear down. */
	onDisconnect(listener: (event: LocalGatewayDisconnectEvent) => void): () => void {
		this.emitter.on('gateway-disconnect', listener);
		return () => this.emitter.off('gateway-disconnect', listener);
	}

	/** Called when the client uploads its MCP tool capabilities. */
	init(data: InstanceAiGatewayCapabilities): void {
		this._rootPath = data.rootPath;
		this._hostIdentifier = data.hostIdentifier ?? null;
		this._toolCategories = data.toolCategories ?? [];
		this._availableTools = data.tools;
		this._connected = true;
		this._connectedAt = new Date().toISOString();
	}

	/** Called when the client POSTs back an MCP result for a pending request. */
	resolveRequest(requestId: string, result?: McpToolCallResult, error?: string): boolean {
		const pending = this.pendingRequests.get(requestId);
		if (!pending) return false;

		clearTimeout(pending.timer);
		this.pendingRequests.delete(requestId);

		if (error) {
			pending.reject(new Error(error));
			return true;
		}

		// Resolve with the result as-is (including isError responses) so the tool
		// layer (create-tools-from-mcp-server.ts) can inspect GATEWAY_CONFIRMATION_REQUIRED
		// errors and handle them via native tool suspension.
		pending.resolve(result ?? { content: [] });
		return true;
	}

	/** Mark the gateway as disconnected and reject all pending requests. */
	disconnect(): void {
		this.emitter.emit('gateway-disconnect', {
			type: 'gateway-disconnect',
		} satisfies LocalGatewayDisconnectEvent);

		this._connected = false;
		this._connectedAt = null;
		this._rootPath = null;
		this._hostIdentifier = null;
		this._toolCategories = [];
		this._availableTools = [];

		for (const [id, pending] of this.pendingRequests) {
			clearTimeout(pending.timer);
			pending.reject(new Error('Local gateway disconnected'));
			this.pendingRequests.delete(id);
		}
	}

	/** Return connection status for the frontend. */
	getStatus(): {
		connected: boolean;
		connectedAt: string | null;
		directory: string | null;
		hostIdentifier: string | null;
		toolCategories: ToolCategory[];
	} {
		return {
			connected: this._connected,
			connectedAt: this._connectedAt,
			directory: this._rootPath,
			hostIdentifier: this._hostIdentifier,
			toolCategories: this._toolCategories.filter(
				(category) => !this._excludedToolCategories.has(category.name),
			),
		};
	}

	/**
	 * Dispatch an MCP tool call to the remote client and await its result.
	 * Throws if not connected, if the request times out, or if aborted.
	 */
	async callTool(
		toolCall: McpToolCallRequest,
		options?: { abortSignal?: AbortSignal },
	): Promise<McpToolCallResult> {
		if (!this._connected) {
			throw new Error('Local gateway is not connected');
		}

		const tool = this._availableTools.find((t) => t.name === toolCall.name);
		if (tool && this.isExcluded(tool)) {
			return {
				content: [{ type: 'text', text: `Unknown tool: ${toolCall.name}` }],
				isError: true,
			};
		}

		const abortSignal = options?.abortSignal;
		if (abortSignal?.aborted) {
			const error = new Error(
				typeof abortSignal.reason === 'string' ? abortSignal.reason : 'This operation was aborted',
			);
			error.name = 'AbortError';
			throw error;
		}

		const requestId = `gw_${nanoid()}`;

		return await new Promise<McpToolCallResult>((resolve, reject) => {
			let settled = false;

			const settle = (action: () => void) => {
				if (settled) return;
				settled = true;
				clearTimeout(timer);
				abortSignal?.removeEventListener('abort', onAbort);
				this.pendingRequests.delete(requestId);
				action();
			};

			const onAbort = () => {
				settle(() => {
					const error = new Error(
						typeof abortSignal?.reason === 'string'
							? abortSignal.reason
							: 'This operation was aborted',
					);
					error.name = 'AbortError';
					reject(error);
				});
			};

			const timer = setTimeout(() => {
				settle(() => {
					reject(new Error(`Local gateway request timed out after ${REQUEST_TIMEOUT_MS}ms`));
				});
			}, REQUEST_TIMEOUT_MS);

			abortSignal?.addEventListener('abort', onAbort, { once: true });

			this.pendingRequests.set(requestId, {
				resolve: (result) => {
					settle(() => {
						resolve(result);
					});
				},
				reject: (error) => {
					settle(() => {
						reject(error);
					});
				},
				timer,
				toolCall,
			});

			this.emitter.emit('filesystem-request', {
				type: 'filesystem-request',
				payload: { requestId, toolCall },
			} satisfies LocalGatewayEvent);
		});
	}
}
