import type { BaseMessage } from '@langchain/core/messages';
import { type DynamicStructuredTool, type StructuredTool, Tool } from '@langchain/core/tools';
import type { JSONSchema7 } from 'json-schema';
import { StructuredToolkit, type SupplyDataToolResponse } from 'n8n-core';
import type {
	ICredentialDataDecryptedObject,
	IExecuteFunctions,
	ISupplyDataFunctions,
	IWebhookFunctions,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { ZodType } from 'zod';

import { N8nTool } from './N8nTool';
import { convertJsonSchemaToZod } from './schemaParsing';

export function getPromptInputByType(options: {
	ctx: IExecuteFunctions | ISupplyDataFunctions;
	i: number;
	promptTypeKey: string;
	inputKey: string;
}) {
	const { ctx, i, promptTypeKey, inputKey } = options;
	const promptType = ctx.getNodeParameter(promptTypeKey, i, 'define') as string;

	let input;
	if (promptType === 'auto') {
		input = ctx.evaluateExpression('{{ $json["chatInput"] }}', i) as string;
	} else if (promptType === 'guardrails') {
		input = ctx.evaluateExpression('{{ $json["guardrailsInput"] }}', i) as string;
	} else {
		input = ctx.getNodeParameter(inputKey, i) as string;
	}

	if (input === undefined) {
		if (promptType === 'auto' || promptType === 'guardrails') {
			const key = promptType === 'auto' ? 'chatInput' : 'guardrailsInput';
			throw new NodeOperationError(ctx.getNode(), 'No prompt specified', {
				description: `Expected to find the prompt in an input field called '${key}' (this is what the ${promptType === 'auto' ? 'chat trigger node' : 'guardrails node'} node outputs). To use something else, change the 'Prompt' parameter`,
			});
		} else {
			throw new NodeOperationError(ctx.getNode(), 'No prompt specified', {
				description:
					'The prompt field is empty or the expression used could not be resolved. Please check the configured prompt value.',
			});
		}
	}

	return input;
}

// Minimum version at which a memory node scopes its session id
// to the node's own name
const SESSION_KEY_SCOPING_MIN_VERSION: Record<string, number> = {
	'@n8n/n8n-nodes-langchain.memoryBufferWindow': 1.4,
	'@n8n/n8n-nodes-langchain.memoryPostgresChat': 1.4,
	'@n8n/n8n-nodes-langchain.memoryRedisChat': 1.6,
	'@n8n/n8n-nodes-langchain.memoryMongoDbChat': 1.1,
	'@n8n/n8n-nodes-langchain.memoryMotorhead': 1.4,
	'@n8n/n8n-nodes-langchain.memoryXata': 1.5,
	'@n8n/n8n-nodes-langchain.memoryZep': 1.4,
};

function shouldScopeSessionKey(ctx: ISupplyDataFunctions | IWebhookFunctions): boolean {
	const node = ctx.getNode();
	if (!node) return false;
	const minVersion = SESSION_KEY_SCOPING_MIN_VERSION[node.type];
	// if the node is not in SESSION_KEY_SCOPING_MIN_VERSION, it should
	// scope by default the session key with no retrocompatibility issues
	if (minVersion === undefined) return true;
	return (node.typeVersion ?? 0) >= minVersion;
}

// Some memory backends (Motorhead URL paths, Xata/Zep record IDs) reject
// characters outside [A-Za-z0-9_-]. Node names in n8n allow spaces, emoji,
// and punctuation, so sanitize before using the name as part of a session key.
function sanitizeForSessionKey(name: string): string {
	return name.replace(/[^A-Za-z0-9_-]/g, '_');
}

export function getSessionId(
	ctx: ISupplyDataFunctions | IWebhookFunctions,
	itemIndex: number,
	selectorKey = 'sessionIdType',
	autoSelect = 'fromInput',
	customKey = 'sessionKey',
) {
	let sessionId = '';
	const selectorType = ctx.getNodeParameter(selectorKey, itemIndex) as string;

	if (selectorType === autoSelect) {
		// If memory node is used in webhook like node(like chat trigger node), it doesn't have access to evaluateExpression
		// so we try to extract sessionId from the bodyData
		if ('getBodyData' in ctx) {
			const bodyData = ctx.getBodyData() ?? {};
			sessionId = bodyData.sessionId as string;
		} else {
			sessionId = ctx.evaluateExpression('{{ $json.sessionId }}', itemIndex) as string;

			// try to get sessionId from chat trigger
			if (!sessionId || sessionId === undefined) {
				try {
					const chatTrigger = ctx.getChatTrigger();

					if (chatTrigger) {
						sessionId = ctx.evaluateExpression(
							`{{ $('${chatTrigger.name}').first().json.sessionId }}`,
							itemIndex,
						) as string;
					}
				} catch (error) {}
			}
		}

		if (sessionId === '' || sessionId === undefined) {
			throw new NodeOperationError(ctx.getNode(), 'No session ID found', {
				description:
					"Expected to find the session ID in an input field called 'sessionId' (this is what the chat trigger node outputs). To use something else, change the 'Session ID' parameter",
				itemIndex,
			});
		}
	} else {
		sessionId = ctx.getNodeParameter(customKey, itemIndex, '') as string;
		if (sessionId === '' || sessionId === undefined) {
			throw new NodeOperationError(ctx.getNode(), 'Key parameter is empty', {
				description:
					"Provide a key to use as session ID in the 'Key' parameter or use the 'Connected Chat Trigger Node' option to use the session ID from your Chat Trigger",
				itemIndex,
			});
		}
	}

	// Scoping uses the memory node's own name, so connecting a single memory
	// node to multiple callers (e.g. an agent and a sub-agent) will still
	// produce the same session key for all of them and they will share the
	// same memory bucket. To isolate memory per caller, duplicate the memory
	// node instead of reusing one.
	if (selectorType === autoSelect && shouldScopeSessionKey(ctx)) {
		sessionId = `${sessionId}__${sanitizeForSessionKey(ctx.getNode().name)}`;
	}

	return sessionId;
}

export function serializeChatHistory(chatHistory: BaseMessage[]): string {
	return chatHistory
		.map((chatMessage) => {
			if (chatMessage._getType() === 'human') {
				return `Human: ${chatMessage.content}`;
			} else if (chatMessage._getType() === 'ai') {
				return `Assistant: ${chatMessage.content}`;
			} else {
				return `${chatMessage.content}`;
			}
		})
		.join('\n');
}

export function escapeSingleCurlyBrackets(text?: string): string | undefined {
	if (text === undefined) return undefined;

	let result = text;

	result = result
		// First handle triple brackets to avoid interference with double brackets
		.replace(/(?<!{){{{(?!{)/g, '{{{{')
		.replace(/(?<!})}}}(?!})/g, '}}}}')
		// Then handle single brackets, but only if they're not part of double brackets
		// Convert single { to {{ if it's not already part of {{ or {{{
		.replace(/(?<!{){(?!{)/g, '{{')
		// Convert single } to }} if it's not already part of }} or }}}
		.replace(/(?<!})}(?!})/g, '}}');

	return result;
}

/* Convert tools with json schema to tools with zod schema and type Tool
 * Most nodes expect tools to have a Zod schema and have Tool type, do this conversion to make sure all tools are compatible
 */
const normalizeToolSchema = (tool: Tool | DynamicStructuredTool | StructuredTool) => {
	if (tool instanceof Tool) {
		return tool;
	}
	const isZodObject = tool.schema instanceof ZodType;
	if (tool.schema && !isZodObject) {
		tool.schema = convertJsonSchemaToZod(tool.schema as JSONSchema7);
	}

	return tool as Tool;
};

export const getConnectedTools = async (
	ctx: IExecuteFunctions | IWebhookFunctions | ISupplyDataFunctions,
	enforceUniqueNames: boolean,
	convertStructuredTool: boolean = true,
	escapeCurlyBrackets: boolean = false,
): Promise<Tool[]> => {
	const toolkitConnections = (await ctx.getInputConnectionData(
		NodeConnectionTypes.AiTool,
		0,
	)) as SupplyDataToolResponse[];

	// Get parent nodes to map toolkits to their source nodes.
	// getInputConnectionData filters out disabled nodes, so parents must be filtered
	// the same way to keep the index alignment between toolkitConnections and parentNodes.
	const parentNodes =
		'getParentNodes' in ctx
			? ctx
					.getParentNodes(ctx.getNode().name, {
						connectionType: NodeConnectionTypes.AiTool,
						depth: 1,
					})
					.filter((node) => !node.disabled)
			: [];

	const connectedTools = (toolkitConnections ?? [])
		.flatMap((toolOrToolkit, index) => {
			if (toolOrToolkit instanceof StructuredToolkit) {
				const tools = toolOrToolkit.tools;
				// Add metadata to each tool from the toolkit
				return tools.map((tool) => {
					const sourceNode = parentNodes[index] ?? tool.name;

					tool.metadata ??= {};
					tool.metadata.isFromToolkit = true;
					tool.metadata.sourceNodeName = sourceNode?.name;
					return tool;
				});
			} else {
				const sourceNode = parentNodes[index] ?? toolOrToolkit.name;
				toolOrToolkit.metadata ??= {};
				toolOrToolkit.metadata.isFromToolkit = false;
				toolOrToolkit.metadata.sourceNodeName = sourceNode?.name;
			}

			return toolOrToolkit;
		})
		.map(normalizeToolSchema);

	if (!enforceUniqueNames) return connectedTools;

	const seenNames = new Set<string>();

	const finalTools: Tool[] = [];

	for (const tool of connectedTools) {
		const { name } = tool;
		if (seenNames.has(name)) {
			throw new NodeOperationError(
				ctx.getNode(),
				`You have multiple tools with the same name: '${name}', please rename them to avoid conflicts`,
			);
		}
		seenNames.add(name);

		if (escapeCurlyBrackets) {
			tool.description = escapeSingleCurlyBrackets(tool.description) ?? tool.description;
		}

		if (convertStructuredTool && tool instanceof N8nTool) {
			finalTools.push(tool.asDynamicTool());
		} else {
			finalTools.push(tool);
		}
	}

	return finalTools;
};

/**
 * Merges custom credential headers into an existing defaultHeaders object.
 * Used by OpenAI and other LangChain nodes that pass `configuration.defaultHeaders`.
 */
export function mergeCustomHeaders(
	credentials: ICredentialDataDecryptedObject,
	defaultHeaders: Record<string, string>,
): Record<string, string> {
	if (
		credentials.header &&
		typeof credentials.headerName === 'string' &&
		credentials.headerName &&
		typeof credentials.headerValue === 'string'
	) {
		return {
			...defaultHeaders,
			[credentials.headerName]: credentials.headerValue,
		};
	}
	return defaultHeaders;
}

/**
 * Sometimes model output is wrapped in an additional object property.
 * This function unwraps the output if it is in the format { output: { output: { ... } } }
 */
export function unwrapNestedOutput(output: Record<string, unknown>): Record<string, unknown> {
	if (
		'output' in output &&
		Object.keys(output).length === 1 &&
		typeof output.output === 'object' &&
		output.output !== null &&
		'output' in output.output &&
		Object.keys(output.output).length === 1
	) {
		return output.output as Record<string, unknown>;
	}

	return output;
}
