import { Logger } from '@n8n/backend-common';
import { detectAuthenticationParameterValue } from '@n8n/ai-utilities/node-catalog';
import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types';
import { ProjectRepository, type User } from '@n8n/db';
import { Service } from '@n8n/di';
import type { ExploreResourcesParams, ExploreResourcesResult } from '@n8n/instance-ai';
import type {
	INodeCredentials,
	INodeParameters,
	INodeProperties,
	INodePropertyCollection,
	INodePropertyMode,
	INodePropertyOptions,
	INodeTypeDescription,
} from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';

import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { NodeTypes } from '@/node-types';
import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
import { getBase } from '@/workflow-execute-additional-data';

/**
 * How far `findUnavailableResourceLocatorValues` will page before giving up on
 * validating a parameter. Bounds the worst case (an account with thousands of
 * resources) while still covering the small, complete lists — model catalogues,
 * channel lists — where a stale value is actually worth reporting.
 */
const MAX_AVAILABILITY_PAGES = 5;

/** Display name for the managed n8n Connect credential slot in explore-resources. */
const N8N_CONNECT_DISPLAY_NAME = 'Gateway credits';

/**
 * Looks up dynamic resource locator and load-options values for a node on
 * behalf of a user, after verifying credential ownership.
 *
 * Shared between Instance AI (`InstanceAiAdapterService.exploreResources`)
 * and the public MCP `explore_node_resources` tool.
 */
@Service()
export class NodeResourceExplorerService {
	constructor(
		private readonly logger: Logger,
		private readonly dynamicNodeParametersService: DynamicNodeParametersService,
		private readonly credentialsFinderService: CredentialsFinderService,
		private readonly projectRepository: ProjectRepository,
		private readonly nodeTypes: NodeTypes,
	) {}

	async exploreResources(
		user: User,
		params: ExploreResourcesParams,
	): Promise<ExploreResourcesResult> {
		const credentials = await this.resolveExploreCredentials(user, params);
		const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(user.id);

		const nodeTypeAndVersion = { name: params.nodeType, version: params.version };
		const currentNodeParameters = structuredClone(
			params.currentNodeParameters ?? {},
		) as INodeParameters;

		const nodeDescription = this.getNodeDescription(params.nodeType, params.version);

		// `getNodeParameter('authentication', 0)` falls back to the wrong default when
		// the parameter isn't set — pre-fill it so cred-resolution picks the right slot.
		if (!currentNodeParameters.authentication && nodeDescription) {
			const authValue = detectAuthenticationParameterValue(nodeDescription, params.credentialType);
			if (authValue !== undefined) currentNodeParameters.authentication = authValue;
		}

		const additionalData = await getBase({
			userId: user.id,
			projectId: personalProject.id,
			currentNodeParameters,
		});

		// Look up the property's builderHint so the agent sees selection guidance
		// alongside the raw list. This makes the hint reachable even when the
		// agent jumps straight to explore-resources without reading type-definition.
		const builderHint = nodeDescription
			? findBuilderHintForMethod(nodeDescription, params.methodName, params.methodType)
			: undefined;

		try {
			if (params.methodType === 'listSearch') {
				const result = await this.dynamicNodeParametersService.getResourceLocatorResults(
					params.methodName,
					'',
					additionalData,
					nodeTypeAndVersion,
					currentNodeParameters,
					credentials,
					params.filter,
					params.paginationToken,
				);
				return {
					results: (result.results ?? []).map((r) => ({
						name: String(r.name),
						value: r.value,
						url: r.url,
					})),
					paginationToken:
						typeof result.paginationToken === 'string' ? result.paginationToken : undefined,
					...(builderHint ? { builderHint } : {}),
				};
			}

			const isRouted = nodeDescription
				? isRoutedLoadOptionsProperty(nodeDescription, params.methodName)
				: false;

			const options = isRouted
				? await this.dynamicNodeParametersService.getOptionsViaLoadOptionsByPath(
						params.methodName,
						additionalData,
						nodeTypeAndVersion,
						currentNodeParameters,
						credentials,
					)
				: await this.dynamicNodeParametersService.getOptionsViaMethodName(
						params.methodName,
						'',
						additionalData,
						nodeTypeAndVersion,
						currentNodeParameters,
						credentials,
					);
			return {
				results: options.map((o) => ({
					name: String(o.name),
					value: o.value,
					description: o.description,
				})),
				...(builderHint ? { builderHint } : {}),
			};
		} catch (error) {
			this.logger.error('Failed to load options for explore-resources', {
				error: error instanceof Error ? error.message : String(error),
			});
			throw error;
		}
	}

	/**
	 * Resolve credentials for explore-resources / availability checks.
	 * The n8n Connect managed tag skips the credential store and passes the
	 * gateway-managed marker so CredentialsHelper mints a synthetic credential
	 * against the gateway allowlist.
	 */
	private async resolveExploreCredentials(
		user: User,
		params: Pick<ExploreResourcesParams, 'credentialId' | 'credentialType'>,
	): Promise<INodeCredentials> {
		if (params.credentialId === AI_GATEWAY_MANAGED_TAG) {
			return {
				[params.credentialType]: {
					id: null,
					name: N8N_CONNECT_DISPLAY_NAME,
					__aiGatewayManaged: true,
				},
			};
		}

		const credential = await this.credentialsFinderService.findCredentialForUser(
			params.credentialId,
			user,
			['credential:read'],
		);
		if (!credential || credential.type !== params.credentialType) {
			throw new Error(`Credential ${params.credentialId} not found or not accessible`);
		}

		return {
			[credential.type]: { id: credential.id, name: credential.name },
		};
	}

	/**
	 * Check the node's resource-locator parameters against what the given credential
	 * can actually reach, and report the ones whose current value isn't available.
	 *
	 * Exists because a credential can narrow the value space of a parameter that was
	 * chosen before it was connected: the free-OpenAI-credits credential proxies
	 * `GET /v1/models` through a filtered allowlist, so a model the builder picked at
	 * build time (when no credential existed) may simply not be callable. A user's own
	 * key can be restricted the same way by their org's model access, so this isn't
	 * limited to managed credentials.
	 *
	 * Reports the unusable value only — not a replacement, and not the usable values.
	 * Callers that want those can list them through `exploreResources`, which runs the
	 * same lookup.
	 *
	 * Two things bound what this will claim, because a false "your value is invalid" is
	 * worse than staying quiet:
	 *
	 * - Only values that are **comparable to the list** are checked: a literal identifier in a
	 *   locator's `list` or `id` mode. `url` mode stores a URL and expressions resolve at
	 *   runtime, so neither can be matched against a catalogue of ids; a free-text parameter
	 *   has no enumerable value space at all.
	 * - Only **exhaustive** lists are trusted. A partial result tells us what's on the
	 *   pages we fetched, not what exists — a user with thousands of spreadsheets would
	 *   otherwise have a perfectly good `documentId` flagged because it sorted too low.
	 *   Paginated lists are followed up to `MAX_AVAILABILITY_PAGES`; beyond that the
	 *   parameter is left unvalidated rather than judged on a partial view.
	 *
	 * Each page costs a live provider round trip, so this is not free; it is the same
	 * call a resource-locator dropdown makes when it is opened.
	 */
	async findUnavailableResourceLocatorValues(
		user: User,
		params: {
			nodeType: string;
			version: number;
			credentialType: string;
			credentialId: string;
			parameters: Record<string, unknown>;
		},
	): Promise<UnavailableResourceLocatorValue[]> {
		const nodeDescription = this.getNodeDescription(params.nodeType, params.version);
		if (!nodeDescription) return [];

		const candidates = collectListBackedLocators(
			nodeDescription,
			params.parameters,
			params.version,
		);
		if (candidates.length === 0) return [];

		const unavailable: UnavailableResourceLocatorValue[] = [];
		for (const candidate of candidates) {
			const listing = await this.listAllReachableValues(user, params, candidate);

			// `complete: false` means we stopped early — a lookup error, or a list longer
			// than MAX_AVAILABILITY_PAGES. Either way we haven't seen everything, so we
			// can't call the value missing.
			if (!listing.complete) continue;
			// An empty list means the lookup told us nothing, not that the value is wrong.
			// (It also covers a credential that can reach no value of this kind at all,
			// where reporting the parameter gives the caller nothing to act on.)
			if (listing.options.length === 0) continue;
			if (listing.options.some((o) => o.value === candidate.currentValue)) continue;

			unavailable.push({
				name: candidate.name,
				displayName: candidate.displayName,
				currentValue: candidate.currentValue,
			});
		}

		return unavailable;
	}

	/**
	 * Page through everything the credential can reach for one locator parameter.
	 *
	 * `complete` is the load-bearing part of the return: it's true only when we reached
	 * the end of the list, which is the sole condition under which a missing value means
	 * anything. Bailing out early is always safe — the caller stays quiet — whereas
	 * treating a truncated list as complete would flag values that are perfectly fine.
	 */
	private async listAllReachableValues(
		user: User,
		params: {
			nodeType: string;
			version: number;
			credentialType: string;
			credentialId: string;
			parameters: Record<string, unknown>;
		},
		candidate: Pick<CandidateLocator, 'name' | 'methodName' | 'methodType'>,
	): Promise<{ options: Array<{ name: string; value: string }>; complete: boolean }> {
		const byValue = new Map<string, { name: string; value: string }>();
		let paginationToken: string | undefined;

		for (let page = 0; page < MAX_AVAILABILITY_PAGES; page++) {
			let result;
			try {
				result = await this.exploreResources(user, {
					nodeType: params.nodeType,
					version: params.version,
					methodName: candidate.methodName,
					methodType: candidate.methodType,
					credentialType: params.credentialType,
					credentialId: params.credentialId,
					currentNodeParameters: params.parameters,
					...(paginationToken ? { paginationToken } : {}),
				});
			} catch (error) {
				// One failure shouldn't hide the other parameters, and an unreachable
				// provider must not turn into a spurious "your value is invalid".
				this.logger.debug('Resource-locator availability check failed', {
					parameter: candidate.name,
					error: error instanceof Error ? error.message : String(error),
				});
				return { options: [], complete: false };
			}

			for (const entry of result.results) {
				const value = String(entry.value);
				if (!byValue.has(value)) byValue.set(value, { name: entry.name, value });
			}

			paginationToken = result.paginationToken;
			if (paginationToken === undefined) return { options: [...byValue.values()], complete: true };
		}

		// Deliberately not treated as complete: a long list stays unvalidated rather than
		// producing false "unavailable" reports from a partial view.
		this.logger.debug('Resource-locator list too long to validate against', {
			parameter: candidate.name,
			pagesFetched: MAX_AVAILABILITY_PAGES,
			valuesSeen: byValue.size,
		});
		return { options: [...byValue.values()], complete: false };
	}

	private getNodeDescription(nodeType: string, version: number): INodeTypeDescription | undefined {
		try {
			return this.nodeTypes.getByNameAndVersion(nodeType, version).description;
		} catch {
			return undefined;
		}
	}
}

/**
 * A resource-locator value the credential can't reach. Identifies the parameter and the
 * offending value only — the reachable values are gathered internally to decide that, but
 * deliberately not returned: callers that want them can list them with `exploreResources`.
 */
export interface UnavailableResourceLocatorValue {
	/** Parameter name, as declared by the node. */
	name: string;
	displayName: string;
	/** The configured value the credential can't reach. Left in place; repair is the caller's. */
	currentValue: string;
}

interface CandidateLocator {
	name: string;
	displayName: string;
	methodName: string;
	methodType: 'listSearch' | 'loadOptions';
	currentValue: string;
}

/** Locator modes whose stored value is the resource identifier itself. */
const IDENTIFIER_LOCATOR_MODES = new Set(['list', 'id']);

/**
 * The value a resource-locator holds, when that value is comparable to the list.
 *
 * `list` and `id` both store the identifier, so either can be checked against the
 * catalogue: whether someone picked `gpt-6-mini` from a dropdown or typed it in, the
 * credential still can't call it. Other modes are skipped because their value isn't an
 * identifier at all — `url` mode stores a URL and the node extracts the id from it at
 * runtime, so comparing it to a list of ids would mismatch every single time.
 *
 * A bare string is the identifier too, and is treated the same.
 *
 * Expressions are skipped whatever the mode: `={{ $json.model }}` resolves per item at
 * runtime, so there is no single value to look up, and comparing the expression text to a
 * catalogue would mismatch every time. Reporting it would invite the caller to replace a
 * deliberate dynamic value with a static one. This mirrors `validateResourceLocatorParameter`
 * in n8n-workflow, which returns no errors as soon as the value starts with `=`.
 *
 * What keeps a typed-in id from being falsely reported is not the mode but the guards on
 * the list itself: a partial, empty or failed lookup never yields a verdict. The residual
 * risk is a provider whose catalogue endpoint looks complete but isn't — an
 * OpenAI-compatible proxy behind a custom `baseURL`, say — where a legitimately callable
 * model could be reported. Accepted, because the alternative is not catching the values
 * the builder invents, which is the point of the check.
 */
function readLocatorValue(raw: unknown): string | undefined {
	if (typeof raw === 'string') return staticIdentifier(raw);
	if (typeof raw !== 'object' || raw === null) return undefined;

	const mode = Reflect.get(raw, 'mode');
	if (typeof mode === 'string' && !IDENTIFIER_LOCATOR_MODES.has(mode)) return undefined;

	const value = Reflect.get(raw, 'value');
	if (typeof value === 'string') return staticIdentifier(value);
	if (typeof value === 'number') return String(value);
	return undefined;
}

/** A value only counts when it's a literal identifier — not empty, and not an expression. */
function staticIdentifier(value: string): string | undefined {
	if (value === '') return undefined;
	if (value.startsWith('=')) return undefined;
	return value;
}

/**
 * Resource-locator properties that resolve their `list` mode through a search method,
 * are visible for the node's current parameters, and hold a concrete value — the only
 * ones whose value can be checked against a credential.
 *
 * The visibility filter is essential, not tidiness. A node can declare the same locator
 * many times over, once per resource/operation branch, each pointing at a different
 * search method: the OpenAI node's `modelRLC` yields five `modelId` locators backed by
 * `modelSearch`, `imageModelSearch`, `imageGenerateModelSearch` and `videoModelSearch`.
 * Probing all of them would spend a provider call apiece and, worse, judge the active
 * value against another branch's list — a chat model is legitimately absent from the
 * video-model list, so it would be reported as unusable.
 *
 * Values are resolved through `getNodeParameters` first so branch discriminators left at
 * their defaults (n8n strips those on save) still resolve, and so hidden parameters are
 * dropped the same way execution drops them.
 */
function collectListBackedLocators(
	nodeDescription: INodeTypeDescription,
	parameters: Record<string, unknown>,
	typeVersion: number,
): Array<CandidateLocator> {
	const node = { typeVersion };
	const resolved =
		NodeHelpers.getNodeParameters(
			nodeDescription.properties,
			parameters as INodeParameters,
			true,
			false,
			node,
			nodeDescription,
		) ?? (parameters as INodeParameters);

	const candidates: CandidateLocator[] = [];
	const seen = new Set<string>();

	for (const property of nodeDescription.properties) {
		if (seen.has(property.name)) continue;

		let methodName: string | undefined;
		let methodType: 'listSearch' | 'loadOptions' = 'listSearch';

		if (property.type === 'resourceLocator') {
			methodName = property.modes?.reduce<string | undefined>(
				(found, mode: INodePropertyMode) =>
					found ?? (mode.type === 'list' ? mode.typeOptions?.searchListMethod : undefined),
				undefined,
			);
			methodType = 'listSearch';
		} else if (property.type === 'options') {
			if (property.typeOptions?.loadOptionsMethod) {
				methodName = property.typeOptions.loadOptionsMethod;
				methodType =
					property.typeOptions.loadOptionsMethod === 'listSearch' ? 'listSearch' : 'loadOptions';
			} else if (property.typeOptions?.searchListMethod) {
				methodName = property.typeOptions.searchListMethod;
				methodType = 'listSearch';
			} else if (property.typeOptions?.loadOptions?.routing) {
				methodName = property.name;
				methodType = 'loadOptions';
			}
		}

		if (!methodName) continue;

		if (!NodeHelpers.displayParameter(resolved, property, node, nodeDescription)) continue;

		const currentValue = readLocatorValue(resolved[property.name]);
		if (currentValue === undefined) continue;

		seen.add(property.name);
		candidates.push({
			name: property.name,
			displayName: property.displayName,
			methodName,
			methodType,
			currentValue,
		});
	}

	return candidates;
}

/**
 * Find the `builderHint.propertyHint` of the property that references a given
 * method name via `@searchListMethod` (RLC list modes) or `@loadOptionsMethod`.
 * Returns undefined if no matching property is found.
 */
function findBuilderHintForMethod(
	nodeDesc: INodeTypeDescription,
	methodName: string,
	methodType: 'listSearch' | 'loadOptions',
): string | undefined {
	const referencesMethod = (prop: INodeProperties): boolean => {
		switch (methodType) {
			case 'loadOptions':
				return (
					prop.typeOptions?.loadOptionsMethod === methodName ||
					(Boolean(prop.typeOptions?.loadOptions?.routing) && prop.name === methodName)
				);
			case 'listSearch': {
				const modes: INodePropertyMode[] = prop.modes ?? [];
				return modes.some((mode) => mode.typeOptions?.searchListMethod === methodName);
			}
		}
	};

	const isCollection = (
		item: INodePropertyOptions | INodeProperties | INodePropertyCollection,
	): item is INodePropertyCollection => 'values' in item;
	const isProperty = (
		item: INodePropertyOptions | INodeProperties | INodePropertyCollection,
	): item is INodeProperties => 'type' in item;

	const searchProps = (
		items?: Array<INodePropertyOptions | INodeProperties | INodePropertyCollection>,
	): string | undefined => {
		for (const item of items ?? []) {
			if (isCollection(item)) {
				const nested = searchProps(item.values);
				if (nested) return nested;
				continue;
			}
			if (!isProperty(item)) continue;
			if (referencesMethod(item) && item.builderHint?.propertyHint) {
				return item.builderHint.propertyHint;
			}
			const nested = searchProps(item.options);
			if (nested) return nested;
		}
		return undefined;
	};

	return searchProps(nodeDesc.properties);
}

function isRoutedLoadOptionsProperty(
	nodeDesc: INodeTypeDescription,
	propertyName: string,
): boolean {
	const search = (
		items?: Array<INodePropertyOptions | INodeProperties | INodePropertyCollection>,
	): boolean => {
		for (const item of items ?? []) {
			if ('values' in item && Array.isArray(item.values)) {
				if (search(item.values)) return true;
				continue;
			}
			if ('type' in item) {
				if (item.name === propertyName && Boolean(item.typeOptions?.loadOptions?.routing)) {
					return true;
				}
				if (item.options && search(item.options)) return true;
			}
		}
		return false;
	};
	return search(nodeDesc.properties);
}
