import {
	AddConnectionCommand,
	AddNodeCommand,
	BulkCommand,
	EnableNodeToggleCommand,
	RemoveConnectionCommand,
	RemoveNodeCommand,
	type Undoable,
} from '@/app/models/history';
import { useHistoryStore } from '@/app/stores/history.store';
import {
	useWorkflowDocumentStore,
	type WorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import {
	CanvasNodeDirtiness,
	type CanvasNodeDirtinessType,
} from '@/features/workflows/canvas/canvas.types';
import type { INodeConnections, NodeConnectionType } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { computed, toValue, type MaybeRefOrGetter } from 'vue';

/**
 * Does the command make the given node dirty?
 */
function shouldCommandMarkDirty(
	command: Undoable,
	nodeName: string,
	siblingCommands: Undoable[],
	getIncomingConnections: (nodeName: string) => INodeConnections,
	getOutgoingConnectors: (nodeName: string) => INodeConnections,
): boolean {
	if (command instanceof BulkCommand) {
		return command.commands.some((cmd) =>
			shouldCommandMarkDirty(
				cmd,
				nodeName,
				command.commands,
				getIncomingConnections,
				getOutgoingConnectors,
			),
		);
	}

	if (command instanceof AddConnectionCommand) {
		return command.connectionData[1]?.node === nodeName;
	}

	if (command instanceof RemoveConnectionCommand) {
		const [from, to] = command.connectionData;

		if (to.node !== nodeName) {
			return false;
		}

		// the connection was removed along with its source node
		return siblingCommands.some(
			(sibling) => sibling instanceof RemoveNodeCommand && sibling.node.name === from.node,
		);
	}

	const incomingNodes = Object.values(getIncomingConnections(nodeName))
		.flat()
		.flat()
		.filter((connection) => connection !== null)
		.map((connection) => connection.node);

	if (command instanceof AddNodeCommand) {
		return incomingNodes.includes(command.node.name);
	}

	if (command instanceof EnableNodeToggleCommand) {
		return (
			incomingNodes.includes(command.nodeName) &&
			(command.newState ||
				Object.keys(getOutgoingConnectors(command.nodeName)).some(
					(type) => (type as NodeConnectionType) !== NodeConnectionTypes.Main,
				))
		);
	}

	return false;
}

/**
 * If given node is part of a loop, returns the set of nodes that forms the loop, otherwise returns undefined.
 */
function findLoop(
	nodeName: string,
	visited: string[],
	getIncomingConnections: (nodeName: string) => INodeConnections,
): string[] | undefined {
	const index = visited.indexOf(nodeName);

	if (index >= 0) {
		return visited.slice(index);
	}

	const newVisited = [...visited, nodeName];

	for (const [type, typeConnections] of Object.entries(getIncomingConnections(nodeName))) {
		if ((type as NodeConnectionType) !== NodeConnectionTypes.Main) {
			continue;
		}

		for (const connections of typeConnections) {
			for (const { node } of connections ?? []) {
				const loop = findLoop(node, newVisited, getIncomingConnections);

				if (loop) {
					return loop;
				}
			}
		}
	}

	return undefined;
}

/**
 * Determines the subgraph that is affected by changes made after the last (partial) execution.
 *
 * Takes the workflow document id (a value, ref, or getter) explicitly so it
 * can resolve the document store directly via `useWorkflowDocumentStore(id)`
 * instead of relying on `inject()`, which only resolves inside the
 * `WorkflowLayout` tree. This makes the composable safe to call from
 * off-layout contexts (e.g. the workflow-diff modal's `watchEffect`) without
 * the try/catch fallback `useWorkflowDocumentRenderData` previously needed.
 *
 * Passing a reactive id lets callers bound to a swappable injected document
 * store (e.g. push handlers that replace the current document while the host
 * component stays mounted) re-resolve the store reactively. The internal
 * computeds are built once and recompute against the active document, so the
 * dirtiness tracks the live document without callers having to recreate the
 * composable inside their own `computed`.
 */
export function useNodeDirtiness(workflowDocumentId: MaybeRefOrGetter<WorkflowDocumentId>) {
	const historyStore = useHistoryStore();

	const workflowDocumentStore = computed(() =>
		useWorkflowDocumentStore(toValue(workflowDocumentId)),
	);
	const executionStateStore = computed(() =>
		useWorkflowExecutionStateStore(toValue(workflowDocumentId)),
	);

	function getIncomingConnections(nodeName: string): INodeConnections {
		return workflowDocumentStore.value.incomingConnectionsByNodeName(nodeName);
	}

	function getOutgoingConnections(nodeName: string): INodeConnections {
		return workflowDocumentStore.value.outgoingConnectionsByNodeName(nodeName);
	}

	function getParentSubNodes(nodeName: string) {
		return Object.entries(getIncomingConnections(nodeName))
			.filter(([type]) => (type as NodeConnectionType) !== NodeConnectionTypes.Main)
			.flatMap(([, typeConnections]) => typeConnections.flat().filter((conn) => conn !== null));
	}

	function getDirtinessByParametersUpdate(
		nodeName: string,
		after: number,
	): CanvasNodeDirtinessType | undefined {
		if ((workflowDocumentStore.value.getParametersLastUpdate(nodeName) ?? 0) > after) {
			return CanvasNodeDirtiness.PARAMETERS_UPDATED;
		}

		for (const connection of getParentSubNodes(nodeName)) {
			if (getDirtinessByParametersUpdate(connection.node, after) !== undefined) {
				return CanvasNodeDirtiness.UPSTREAM_DIRTY;
			}
		}

		return undefined;
	}

	function getDirtinessByConnectionsUpdate(
		nodeName: string,
		after: number,
	): CanvasNodeDirtinessType | undefined {
		for (let i = historyStore.undoStack.length - 1; i >= 0; i--) {
			const command = historyStore.undoStack[i];

			if (command.getTimestamp() < after) {
				break;
			}

			if (
				shouldCommandMarkDirty(
					command,
					nodeName,
					[],
					getIncomingConnections,
					getOutgoingConnections,
				)
			) {
				return CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED;
			}
		}

		for (const connection of getParentSubNodes(nodeName)) {
			if (getDirtinessByConnectionsUpdate(connection.node, after) !== undefined) {
				return CanvasNodeDirtiness.UPSTREAM_DIRTY;
			}
		}

		return undefined;
	}

	/**
	 * Depth of node is defined as the minimum distance (number of connections) from the trigger node
	 */
	const depthByName = computed(() => {
		const depth: Record<string, number> = {};

		function setDepthRecursively(nodeName: string, current: number, visited: Set<string>) {
			if (visited.has(nodeName)) {
				return;
			}

			const myVisited = new Set<string>(visited);

			myVisited.add(nodeName);

			for (const [type, typeConnections] of Object.entries(getOutgoingConnections(nodeName))) {
				if ((type as NodeConnectionType) !== NodeConnectionTypes.Main) {
					continue;
				}

				for (const connections of typeConnections) {
					for (const { node } of connections ?? []) {
						if (!depth[node] || depth[node] > current) {
							depth[node] = current;
						}

						setDepthRecursively(node, current + 1, myVisited);
					}
				}
			}
		}

		for (const startNode of workflowDocumentStore.value.allNodes) {
			const hasIncomingNode = Object.keys(getIncomingConnections(startNode.name)).length > 0;

			if (hasIncomingNode) {
				continue;
			}

			depth[startNode.name] = 0;
			setDepthRecursively(startNode.name, 1, new Set());
		}

		return depth;
	});

	const dirtinessByName = computed(() => {
		const dirtiness: Record<string, CanvasNodeDirtinessType | undefined> = {};
		const runDataByNode = executionStateStore.value.activeExecutionRunData ?? {};

		function setDirtiness(nodeName: string, value: CanvasNodeDirtinessType) {
			dirtiness[nodeName] = dirtiness[nodeName] ?? value;

			const loop = findLoop(nodeName, [], getIncomingConnections);

			if (!loop) {
				return;
			}

			const loopEntryNodeName = [...loop].sort(
				(a, b) =>
					(depthByName.value[a] ?? Number.MAX_SAFE_INTEGER) -
					(depthByName.value[b] ?? Number.MAX_SAFE_INTEGER),
			)?.[0];

			if (loopEntryNodeName && depthByName.value[loopEntryNodeName]) {
				// If a node in a loop becomes dirty, the first node in the loop should also be dirty
				dirtiness[loopEntryNodeName] =
					dirtiness[loopEntryNodeName] ?? CanvasNodeDirtiness.UPSTREAM_DIRTY;
			}
		}

		for (const [nodeName, runData] of Object.entries(runDataByNode)) {
			const runAt = runData[0]?.startTime ?? 0;

			if (!runAt) {
				continue;
			}

			const parameterUpdate = getDirtinessByParametersUpdate(nodeName, runAt);

			if (parameterUpdate) {
				setDirtiness(nodeName, parameterUpdate);
				continue;
			}

			const connectionUpdate = getDirtinessByConnectionsUpdate(nodeName, runAt);

			if (connectionUpdate) {
				setDirtiness(nodeName, connectionUpdate);
				continue;
			}

			const hasInputPinnedDataChanged = Object.values(getIncomingConnections(nodeName))
				.flat()
				.flat()
				.filter((connection) => connection !== null)
				.some((connection) => {
					const pinnedDataLastUpdatedAt =
						workflowDocumentStore.value.getPinnedDataLastUpdate(connection.node) ?? 0;

					return pinnedDataLastUpdatedAt > runAt;
				});

			if (hasInputPinnedDataChanged) {
				setDirtiness(nodeName, CanvasNodeDirtiness.PINNED_DATA_UPDATED);
				continue;
			}

			const pinnedDataLastRemovedAt =
				workflowDocumentStore.value.getPinnedDataLastRemovedAt(nodeName) ?? 0;

			if (pinnedDataLastRemovedAt > runAt) {
				setDirtiness(nodeName, CanvasNodeDirtiness.PINNED_DATA_UPDATED);
				continue;
			}
		}

		return dirtiness;
	});

	return { dirtinessByName };
}
