import type {
	DependenciesBatchResponse,
	DependencyCountsBatchResponse,
	DependencyResourceType,
	ResolvedDependency,
} from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
import {
	CredentialsRepository,
	ProjectRelationRepository,
	WorkflowDependencyRepository,
	WorkflowRepository,
} from '@n8n/db';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import { In } from '@n8n/typeorm';

import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { AgentCredentialDependencyRepository } from '@/modules/agents/repositories/agent-credential-dependency.repository';
import { AgentRepository } from '@/modules/agents/repositories/agent.repository';
import { DataTableRepository } from '@/modules/data-table/data-table.repository';
import { RoleService } from '@/services/role.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';

/** Workflows named for a node type when the caller does not say how many it wants. */
const DEFAULT_NODE_USAGE_WORKFLOW_LIMIT = 10;

/** Node types in the histogram when the caller does not say how many it wants. Higher than the
 *  workflow limit because the histogram is the answer rather than a sample of it, and a project
 *  rarely reaches for more types than this — but it stays bounded, because an instance-wide read
 *  otherwise returns every type in use. */
const DEFAULT_NODE_USAGE_TYPE_LIMIT = 100;

/**
 * Node-type usage over the workflows in scope. Exactly one of `nodeTypes` and `workflows` is set:
 * the histogram when no node type was named, the workflows using it when one was.
 */
export interface NodeTypeUsage {
	/** Indexed, non-archived workflows in scope — the denominator for every count. */
	workflowsInScope: number;
	nodeTypes?: Array<{ nodeType: string; workflowCount: number }>;
	workflows?: Array<{ workflowId: string; name: string; updatedAt: Date }>;
	/**
	 * Whether the limit cut the list short. On the histogram this also decides whether an absent
	 * node type means "not used" or "not shown", so callers must not report absence as evidence
	 * when it is true.
	 */
	truncated?: boolean;
}

interface RawDepMaps {
	agentUsageMap: Map<string, Set<string>>;
	credMap: Map<string, Set<string>>;
	dtMap: Map<string, Set<string>>;
	subMap: Map<string, Set<string>>;
	parentMap: Map<string, Set<string>>;
	errorWfMap: Map<string, Set<string>>;
	errorWfParentMap: Map<string, Set<string>>;
	allCredIds: Set<string>;
	allAgentIds: Set<string>;
	allWfIds: Set<string>;
	allDtIds: Set<string>;
}

@Service()
export class WorkflowDependencyQueryService {
	constructor(
		private readonly dependencyRepository: WorkflowDependencyRepository,
		private readonly credentialsRepository: CredentialsRepository,
		private readonly workflowRepository: WorkflowRepository,
		private readonly dataTableRepository: DataTableRepository,
		private readonly workflowFinderService: WorkflowFinderService,
		private readonly credentialsFinderService: CredentialsFinderService,
		private readonly projectRelationRepository: ProjectRelationRepository,
		private readonly roleService: RoleService,
		private readonly agentDependencyRepository: AgentCredentialDependencyRepository,
		private readonly agentRepository: AgentRepository,
		private readonly moduleRegistry: ModuleRegistry,
	) {}

	/**
	 * Node-type usage across the workflows a user can read, read from the dependency index rather
	 * than by fetching workflows.
	 *
	 * Without `nodeType` it returns the histogram; with one, the workflows using it. Either way the
	 * denominator ships with the answer, because a count means nothing without it.
	 */
	async getNodeTypeUsage(
		user: User,
		options: { projectId?: string; nodeType?: string; limit?: number } = {},
	): Promise<NodeTypeUsage> {
		// The same scopes-to-roles step the workflow listing performs, so this reads exactly the
		// workflows a listing would return rather than applying a rule of its own.
		const [projectRoles, workflowRoles] = await Promise.all([
			this.roleService.rolesWithScope('project', ['workflow:read']),
			this.roleService.rolesWithScope('workflow', ['workflow:read']),
		]);
		const scope = {
			projectRoles,
			workflowRoles,
			...(options.projectId ? { projectId: options.projectId } : {}),
		};

		if (!options.nodeType) {
			return await this.dependencyRepository.countNodeTypeUsage(
				user,
				scope,
				options.limit ?? DEFAULT_NODE_USAGE_TYPE_LIMIT,
			);
		}

		const limit = options.limit ?? DEFAULT_NODE_USAGE_WORKFLOW_LIMIT;
		const [rows, workflowsInScope] = await Promise.all([
			// One over the limit, so a truncated list is reported as truncated rather than guessed at.
			this.dependencyRepository.findWorkflowsUsingNodeType(
				user,
				scope,
				options.nodeType,
				limit + 1,
			),
			this.dependencyRepository.countWorkflowsInScope(user, scope),
		]);

		return {
			workflowsInScope,
			workflows: rows.slice(0, limit),
			...(rows.length > limit ? { truncated: true } : {}),
		};
	}

	async getDependencyCounts(
		resourceIds: string[],
		resourceType: DependencyResourceType,
		user: User,
	): Promise<DependencyCountsBatchResponse> {
		const loaded = await this.loadDepsForResources(resourceIds, resourceType, user);
		if (!loaded) return {};

		const { accessibleInputIds, maps } = loaded;

		const result: DependencyCountsBatchResponse = {};
		for (const id of accessibleInputIds) {
			result[id] = {
				agentUsage: maps.agentUsageMap.get(id)?.size ?? 0,
				credentialId: maps.credMap.get(id)?.size ?? 0,
				dataTableId: maps.dtMap.get(id)?.size ?? 0,
				errorWorkflow: maps.errorWfMap.get(id)?.size ?? 0,
				errorWorkflowParent: maps.errorWfParentMap.get(id)?.size ?? 0,
				workflowCall: maps.subMap.get(id)?.size ?? 0,
				workflowParent: maps.parentMap.get(id)?.size ?? 0,
			};
		}
		return result;
	}

	/** Return resolved dependencies for each input resource, excluding inaccessible ones. */
	async getResourceDependencies(
		resourceIds: string[],
		resourceType: DependencyResourceType,
		user: User,
	): Promise<DependenciesBatchResponse> {
		const loaded = await this.loadDepsForResources(resourceIds, resourceType, user);
		if (!loaded) return {};

		const { accessibleInputIds, maps } = loaded;

		// Check user access for each dependency type
		const [accessibleWfIds, accessibleCredIds, accessibleDtIds, accessibleAgentProjectIds] =
			await Promise.all([
				this.filterByAccess([...maps.allWfIds], 'workflow', user),
				this.filterByAccess([...maps.allCredIds], 'credential', user),
				this.filterByAccess([...maps.allDtIds], 'dataTable', user),
				maps.allAgentIds.size > 0 ? this.getAccessibleAgentProjectIds(user) : new Set<string>(),
			]);

		// Load all referenced resources (not just accessible ones) so that ids whose
		// resource has been deleted — the index may still reference them — can be
		// dropped instead of being reported as inaccessible.
		const [credentials, workflows, dataTables, agents] = await Promise.all([
			maps.allCredIds.size > 0
				? this.credentialsRepository.find({
						where: { id: In([...maps.allCredIds]), usageScope: 'project' },
						select: ['id', 'name'],
					})
				: [],
			maps.allWfIds.size > 0
				? this.workflowRepository.find({
						where: { id: In([...maps.allWfIds]) },
						select: ['id', 'name'],
					})
				: [],
			maps.allDtIds.size > 0
				? this.dataTableRepository.find({
						where: { id: In([...maps.allDtIds]) },
						select: ['id', 'name', 'projectId'],
					})
				: [],
			maps.allAgentIds.size > 0
				? this.agentRepository.findSummariesByIds([...maps.allAgentIds])
				: [],
		]);

		const accessibleWfIdSet = new Set(accessibleWfIds);
		const accessibleCredIdSet = new Set(accessibleCredIds);
		const accessibleDtIdSet = new Set(accessibleDtIds);

		const agentNames = new Map<string, { name: string; projectId: string }>();
		const wfNames = new Map<string, string>();
		const credNames = new Map<string, string>();
		const dtNames = new Map<string, { name: string; projectId: string }>();
		const existingAgentIds = new Set<string>();
		const existingWfIds = new Set<string>();
		const existingCredIds = new Set<string>();
		const existingDtIds = new Set<string>();

		for (const agent of agents) {
			existingAgentIds.add(agent.id);
			// `null` means global `agent:read`; otherwise access is limited to the listed projects.
			if (accessibleAgentProjectIds === null || accessibleAgentProjectIds.has(agent.projectId)) {
				agentNames.set(agent.id, {
					name: agent.name ?? agent.id,
					projectId: agent.projectId,
				});
			}
		}
		for (const c of credentials) {
			existingCredIds.add(c.id);
			if (accessibleCredIdSet.has(c.id)) credNames.set(c.id, c.name ?? c.id);
		}
		for (const w of workflows) {
			existingWfIds.add(w.id);
			if (accessibleWfIdSet.has(w.id)) wfNames.set(w.id, w.name ?? w.id);
		}
		for (const dt of dataTables) {
			existingDtIds.add(dt.id);
			if (accessibleDtIdSet.has(dt.id))
				dtNames.set(dt.id, { name: dt.name ?? dt.id, projectId: dt.projectId });
		}

		return this.buildEnrichedResult(
			accessibleInputIds,
			maps,
			{
				agentNames,
				wfNames,
				credNames,
				dtNames,
			},
			{ existingAgentIds, existingWfIds, existingCredIds, existingDtIds },
		);
	}

	private async loadDepsForResources(
		resourceIds: string[],
		resourceType: DependencyResourceType,
		user: User,
	): Promise<{ accessibleInputIds: string[]; maps: RawDepMaps } | null> {
		const accessibleInputIds = await this.filterByAccess(resourceIds, resourceType, user);
		if (accessibleInputIds.length === 0) return null;

		const [rawDeps, agentDeps] = await Promise.all([
			this.dependencyRepository.find({
				where: [
					{
						workflowId: In(accessibleInputIds),
						dependencyType: In(['credentialId', 'dataTableId', 'errorWorkflow', 'workflowCall']),
					},
					{ dependencyKey: In(accessibleInputIds) },
				],
				select: ['workflowId', 'dependencyType', 'dependencyKey'],
			}),
			resourceType === 'credential' && this.moduleRegistry.isActive('agents')
				? this.agentDependencyRepository.findByCredentialIds(accessibleInputIds)
				: [],
		]);

		if (rawDeps.length === 0 && agentDeps.length === 0) return null;

		return { accessibleInputIds, maps: this.buildDepMaps(rawDeps, agentDeps) };
	}

	private buildDepMaps(
		rawDeps: Array<{ workflowId: string; dependencyType: string; dependencyKey: string }>,
		agentDeps: Array<{ agentId: string; credentialId: string }>,
	): RawDepMaps {
		const agentUsageMap = new Map<string, Set<string>>();
		const credMap = new Map<string, Set<string>>();
		const dtMap = new Map<string, Set<string>>();
		const subMap = new Map<string, Set<string>>();
		const parentMap = new Map<string, Set<string>>();
		const errorWfMap = new Map<string, Set<string>>();
		const errorWfParentMap = new Map<string, Set<string>>();
		const allCredIds = new Set<string>();
		const allAgentIds = new Set<string>();
		const allWfIds = new Set<string>();
		const allDtIds = new Set<string>();

		for (const dep of rawDeps) {
			allWfIds.add(dep.workflowId);
			switch (dep.dependencyType) {
				case 'credentialId':
					addToSet(credMap, dep.workflowId, dep.dependencyKey);
					addToSet(parentMap, dep.dependencyKey, dep.workflowId);
					allCredIds.add(dep.dependencyKey);
					break;
				case 'dataTableId':
					addToSet(dtMap, dep.workflowId, dep.dependencyKey);
					addToSet(parentMap, dep.dependencyKey, dep.workflowId);
					allDtIds.add(dep.dependencyKey);
					break;
				case 'workflowCall':
					addToSet(subMap, dep.workflowId, dep.dependencyKey);
					addToSet(parentMap, dep.dependencyKey, dep.workflowId);
					allWfIds.add(dep.dependencyKey);
					break;
				case 'errorWorkflow':
					addToSet(errorWfMap, dep.workflowId, dep.dependencyKey);
					addToSet(errorWfParentMap, dep.dependencyKey, dep.workflowId);
					allWfIds.add(dep.dependencyKey);
					break;
			}
		}

		for (const dep of agentDeps) {
			addToSet(agentUsageMap, dep.credentialId, dep.agentId);
			allAgentIds.add(dep.agentId);
		}

		return {
			agentUsageMap,
			credMap,
			dtMap,
			subMap,
			parentMap,
			errorWfMap,
			errorWfParentMap,
			allCredIds,
			allAgentIds,
			allWfIds,
			allDtIds,
		};
	}

	/**
	 * Build enriched result — only includes accessible deps, counts existing but
	 * inaccessible ones, and drops ids whose resource no longer exists.
	 */
	private buildEnrichedResult(
		resourceIds: string[],
		maps: RawDepMaps,
		accessMaps: {
			agentNames: Map<string, { name: string; projectId: string }>;
			wfNames: Map<string, string>;
			credNames: Map<string, string>;
			dtNames: Map<string, { name: string; projectId: string }>;
		},
		existing: {
			existingAgentIds: Set<string>;
			existingWfIds: Set<string>;
			existingCredIds: Set<string>;
			existingDtIds: Set<string>;
		},
	): DependenciesBatchResponse {
		const result: DependenciesBatchResponse = {};

		for (const resourceId of resourceIds) {
			const dependencies: ResolvedDependency[] = [];
			let inaccessibleCount = 0;

			for (const id of maps.agentUsageMap.get(resourceId) ?? []) {
				if (!existing.existingAgentIds.has(id)) continue;
				const agent = accessMaps.agentNames.get(id);
				if (agent) {
					dependencies.push({
						id,
						name: agent.name,
						type: 'agentUsage',
						projectId: agent.projectId,
					});
				} else {
					inaccessibleCount++;
				}
			}

			const resolve = (
				ids: Set<string> | undefined,
				nameMap: Map<string, string>,
				existingIds: Set<string>,
				type: ResolvedDependency['type'],
			) => {
				for (const id of ids ?? []) {
					if (!existingIds.has(id)) continue;
					const name = nameMap.get(id);
					if (name !== undefined) {
						dependencies.push({ id, name, type });
					} else {
						inaccessibleCount++;
					}
				}
			};

			resolve(
				maps.subMap.get(resourceId),
				accessMaps.wfNames,
				existing.existingWfIds,
				'workflowCall',
			);
			resolve(
				maps.parentMap.get(resourceId),
				accessMaps.wfNames,
				existing.existingWfIds,
				'workflowParent',
			);
			resolve(
				maps.errorWfMap.get(resourceId),
				accessMaps.wfNames,
				existing.existingWfIds,
				'errorWorkflow',
			);
			resolve(
				maps.errorWfParentMap.get(resourceId),
				accessMaps.wfNames,
				existing.existingWfIds,
				'errorWorkflowParent',
			);
			resolve(
				maps.credMap.get(resourceId),
				accessMaps.credNames,
				existing.existingCredIds,
				'credentialId',
			);

			for (const id of maps.dtMap.get(resourceId) ?? []) {
				if (!existing.existingDtIds.has(id)) continue;
				const dt = accessMaps.dtNames.get(id);
				if (dt) {
					dependencies.push({ id, name: dt.name, type: 'dataTableId', projectId: dt.projectId });
				} else {
					inaccessibleCount++;
				}
			}

			result[resourceId] = { dependencies, inaccessibleCount };
		}

		return result;
	}

	/**
	 * Filter a list of resource IDs to only those the user has access to.
	 */
	private async filterByAccess(
		ids: string[],
		resourceType: DependencyResourceType,
		user: User,
	): Promise<string[]> {
		if (ids.length === 0) return [];

		switch (resourceType) {
			case 'workflow': {
				const accessible = await this.workflowFinderService.findWorkflowIdsWithScopeForUser(
					ids,
					user,
					['workflow:read'],
				);
				return ids.filter((id) => accessible.has(id));
			}
			case 'credential': {
				const accessible = await this.credentialsFinderService.findCredentialIdsWithScopeForUser(
					ids,
					user,
					['credential:read'],
				);
				return ids.filter((id) => accessible.has(id));
			}
			case 'dataTable': {
				return await this.filterDataTableIdsByAccess(ids, user);
			}
		}
	}

	private async filterDataTableIdsByAccess(ids: string[], user: User): Promise<string[]> {
		if (hasGlobalScope(user, 'dataTable:listProject')) return ids;

		const dataTables = await this.dataTableRepository.find({
			where: { id: In(ids) },
			select: ['id', 'projectId'],
		});

		const roles = await this.roleService.rolesWithScope('project', ['dataTable:listProject']);
		const accessibleProjectIds = new Set(
			await this.projectRelationRepository.getAccessibleProjectsByRoles(user.id, roles),
		);

		return dataTables.filter((dt) => accessibleProjectIds.has(dt.projectId)).map((dt) => dt.id);
	}

	private async getAccessibleAgentProjectIds(user: User): Promise<Set<string> | null> {
		if (hasGlobalScope(user, 'agent:read')) return null;

		const roles = await this.roleService.rolesWithScope('project', ['agent:read']);
		return new Set(
			await this.projectRelationRepository.getAccessibleProjectsByRoles(user.id, roles),
		);
	}
}

function addToSet(map: Map<string, Set<string>>, key: string, val: string) {
	let set = map.get(key);
	if (!set) {
		set = new Set();
		map.set(key, set);
	}
	set.add(val);
}
