import Graph from 'graphology';
import { SyntaxNode } from 'web-tree-sitter';

import { LanguageProfile } from '../../code-context/_base/LanguageProfile';
import { ScopeDebug } from '../../test/ScopeDebug';
import { DefToScope, EdgeKind, ImportToScope, RefToDef, RefToImport, ScopeToScope } from './edge/EdgeKind';
import { ImportWithRefs } from './model/ImportWithRefs';
import { Symbol } from './model/Symbol';
import { nameOfSymbol } from './model/SymbolId';
import { TextRange } from './model/TextRange';
import { LocalDef } from './node/LocalDef';
import { LocalImport } from './node/LocalImport';
import { LocalScope, ScopeStack } from './node/LocalScope';
import { NodeKind } from './node/NodeKind';
import { Reference } from './node/Reference';

export type NodeIndex = string;

export class ScopeGraph {
	graph: Graph<NodeKind>;
	private rootIndex: NodeIndex;
	private languageProfile: LanguageProfile;

	constructor(rootNode: SyntaxNode, languageProfile: LanguageProfile) {
		this.graph = new Graph();
		const range = TextRange.from(rootNode);
		let localScope = new LocalScope(range);
		const index = 0;
		this.languageProfile = languageProfile;
		this.graph.addNode(index, localScope);
		this.rootIndex = index.toString();
	}

	getNode(node: NodeIndex): NodeKind {
		return this.graph.getNodeAttributes(node);
	}

	getEdge(edge: string): EdgeKind {
		return this.graph.getEdgeAttributes(edge);
	}

	insertLocalScope(scope: LocalScope) {
		let parentScope = this.scopeByRange(scope.range, this.rootIndex);
		if (parentScope) {
			const indexId = this.graph.nodes().length + 1;
			this.graph.addNode(indexId, scope);
			this.graph.addEdge(indexId, parentScope, new ScopeToScope());
		}
	}

	insertLocalDef(localDef: LocalDef) {
		const definingScope = this.scopeByRange(localDef.range, this.rootIndex);
		if (definingScope) {
			const indexId = this.graph.nodes().length + 1;
			this.graph.addNode(indexId, localDef);
			this.graph.addEdge(indexId, definingScope, new DefToScope());
		}
	}

	insertHoistedDef(localDef: LocalDef) {
		const definingScope = this.scopeByRange(localDef.range, this.rootIndex);
		if (definingScope) {
			const newIndexId = this.graph.nodes().length + 1;
			this.graph.addNode(newIndexId, localDef);

			const targetScope = this.parentScope(definingScope) || definingScope;
			this.graph.addEdge(newIndexId, targetScope, new DefToScope());
		}
	}

	insertGlobalDef(localDef: LocalDef) {
		const indexId = this.graph.nodes().length + 1;
		this.graph.addNode(indexId, localDef);
		this.graph.addEdge(indexId, this.rootIndex, new DefToScope());
	}

	insertLocalImport(import_: LocalImport) {
		const definingScope = this.scopeByRange(import_.range, this.rootIndex);
		if (definingScope) {
			const indexId = this.graph.nodes().length + 1;
			this.graph.addNode(indexId, import_);
			this.graph.addEdge(indexId, definingScope, new ImportToScope());
		}
	}

	insertRef(reference: Reference, sourceCode: string) {
		let possibleDefs: string[] = [];
		let possibleImports: string[] = [];

		const localScopeIndex = this.scopeByRange(reference.range, this.rootIndex);
		if (localScopeIndex) {
			let stack = this.scopeStack(localScopeIndex);
			for (const scope of stack) {
				this.graph
					.inEdges(scope)
					.filter(edge => {
						return this.graph.getEdgeAttributes(edge) instanceof DefToScope;
					})
					.forEach(edge => {
						const localDef = this.graph.source(edge);
						const def = this.graph.getNodeAttributes(localDef) as LocalDef;
						if (reference.name(sourceCode) === def.name(sourceCode)) {
							if (
								def.symbolId &&
								reference.symbolId &&
								def.symbolId.namespaceIndex !== reference.symbolId.namespaceIndex
							) {
								// both contain symbols, but they don't belong to the same namepspace
							} else {
								possibleDefs.push(localDef);
							}
						}
					});

				this.graph.inEdges(scope).forEach(edge => {
					if (this.graph.getEdgeAttributes(edge) instanceof ImportToScope) {
						const localImport = this.graph.source(edge);
						const import_ = this.graph.getNodeAttributes(localImport) as LocalImport;
						if (reference.name(sourceCode) === import_.name(sourceCode)) {
							possibleImports.push(localImport);
						}
					}
				});
			}
		}

		if (possibleDefs.length > 0 || possibleImports.length > 0) {
			const newRef = new Reference(reference.range, reference.symbolId);
			const refIndex = this.graph.nodes().length + 1;
			this.graph.addNode(refIndex, newRef);
			for (const defIndex of possibleDefs) {
				this.graph.addEdge(refIndex, defIndex, new RefToDef());
			}
			for (const impIndex of possibleImports) {
				this.graph.addEdge(refIndex, impIndex, new RefToImport());
			}
		}
	}

	scopeStack(start: NodeIndex): ScopeStack {
		return new ScopeStack(this.graph, start);
	}

	private scopeByRange(range: TextRange, start: NodeIndex): NodeIndex | null {
		const targetRange = this.graph.getNodeAttributes(start).range;
		if (targetRange.contains(range)) {
			const childScopes = this.graph
				.inEdges(start)
				.filter(edge => this.graph.getEdgeAttributes(edge) instanceof ScopeToScope)
				.map(edge => this.graph.source(edge));

			for (const childScope of childScopes) {
				const t = this.scopeByRange(range, childScope);
				if (t) {
					return t;
				}
			}
			return start;
		}
		return null;
	}

	private parentScope(start: NodeIndex): NodeIndex | null {
		if (this.graph.getNodeAttributes(start) instanceof LocalScope) {
			const edges = this.graph.outEdges(start);
			for (const edge of edges) {
				const target = this.graph.target(edge);
				if (this.graph.getEdgeAttributes(edge) instanceof ScopeToScope) {
					return target;
				}
			}
		}

		return null;
	}

	public hoverableRanges(): TextRange[] {
		const iterator = this.graph.nodes();
		return iterator
			.filter(node => {
				const nodeKind = this.graph.getNodeAttributes(node);
				return nodeKind instanceof LocalDef || nodeKind instanceof Reference || nodeKind instanceof LocalImport;
			})
			.map(node => {
				const nodeKind = this.graph.getNodeAttributes(node);
				return nodeKind.range;
			});
	}

	public definitions(referenceNode: NodeIndex): NodeIndex[] {
		const iterator = this.graph.outEdges(referenceNode);
		return iterator.filter(edge => this.graph.getEdgeAttributes(edge) instanceof RefToDef);
	}

	public imports(referenceNode: NodeIndex): NodeIndex[] {
		const iterator = this.graph.outEdges(referenceNode);
		return iterator.filter(edge => this.graph.getEdgeAttributes(edge) instanceof RefToImport);
	}

	public references(definitionNode: NodeIndex): NodeIndex[] {
		const iterator = this.graph.inEdges(definitionNode);
		return iterator.filter(
			edge =>
				this.graph.getEdgeAttributes(edge) instanceof RefToDef ||
				this.graph.getEdgeAttributes(edge) instanceof RefToImport,
		);
	}

	public nodeByRange(startByte: number, endByte: number): NodeIndex | undefined {
		return this.graph
			.nodes()
			.filter(node => {
				const nodeKind = this.graph.getNodeAttributes(node);
				return nodeKind instanceof LocalDef || nodeKind instanceof Reference || nodeKind instanceof LocalImport;
			})
			.find(node => {
				const nodeKind = this.graph.getNodeAttributes(node);
				return nodeKind.range.start.byte >= startByte && nodeKind.range.end.byte <= endByte;
			});
	}

	debug(src: NodeIndex, language: LanguageProfile) {
		const graph = this.graph;
		const start = this.rootIndex;
		return ScopeDebug.new(graph, start, src, language);
	}

	nodeByPosition(line: number, column: number): NodeIndex | undefined {
		return this.graph
			.nodes()
			.filter(node => {
				const nodeKind = this.graph.getNodeAttributes(node);
				return nodeKind instanceof LocalDef || nodeKind instanceof Reference || nodeKind instanceof LocalImport;
			})
			.find(node => {
				const nodeKind = this.graph.getNodeAttributes(node);
				const range = nodeKind.range;
				return (
					range.start.line === line &&
					range.end.line === line &&
					range.start.column <= column &&
					range.end.column >= column
				);
			});
	}

	allLocalDefs(): NodeKind[] {
		return this.graph
			.nodes()
			.filter(node => this.graph.getNodeAttributes(node) instanceof LocalDef)
			.map(node => this.graph.getNodeAttributes(node));
	}

	allImportsBySource(src: string): string[] {
		let graph = this.graph;
		const imports = graph
			.inEdges(this.rootIndex)
			.filter(edge => graph.getEdgeAttributes(edge) instanceof ImportToScope)
			.map(edge => {
				const impNode = graph.source(edge);
				const range = graph.getNodeAttributes(impNode).range;

				return range.contentFromRange(src);
			});

		return imports;
	}

	allImports(src: string): ImportWithRefs[] {
		const graph = this.graph;
		return this.graph
			.inEdges(this.rootIndex)
			.filter(edge => graph.getEdgeAttributes(edge) instanceof ImportToScope)
			.map(edge => {
				const impNode = graph.source(edge);
				const range = graph.getNodeAttributes(impNode).range;
				const name = src.slice(range.start.byte, range.end.byte);

				const refs = graph
					.inEdges(impNode)
					.filter(edge => graph.getEdgeAttributes(edge) instanceof RefToImport)
					.map(edge => graph.getNodeAttributes(graph.source(edge)).range)
					.sort();

				const text = range.contentFromRange(src);

				return { name: name, range, refs, text: text };
			});
	}

	symbols(): Symbol[] {
		const namespaces = this.languageProfile.namespaces;
		return this.graph
			.nodes()
			.filter(node => {
				const nodeKind = this.graph.getNodeAttributes(node);
				return nodeKind instanceof LocalDef;
			})
			.map(node => {
				const nodeKind = this.graph.getNodeAttributes(node);
				const localDef = nodeKind as LocalDef;
				if (localDef.symbolId) {
					return new Symbol(nameOfSymbol(namespaces, localDef.symbolId), localDef.range);
				}
				return undefined;
			})
			.filter(sym => sym !== undefined) as Symbol[];
	}

	symbolNameOf(idx: NodeIndex): string | undefined {
		const namespaces = this.languageProfile.namespaces;
		const nodeKind = this.graph.getNodeAttributes(idx);
		if (nodeKind instanceof LocalDef) {
			const localDef = nodeKind as LocalDef;
			if (localDef.symbolId) {
				return nameOfSymbol(namespaces, localDef.symbolId!!);
			}
		}
		return undefined;
	}
}
