/**
 * @license Copyright (c) 2017-2025, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md.
 */

'use strict';

const upath = require( 'upath' );
const isNonEmptyArray = require( '../../helpers/is-non-empty-array' );
const TreeModel = require( 'tree-model' );
const Node = require( './tree-node' );
const fs = require( 'fs-extra' );

const NODE_TYPES = {
	ROOT: 'root',
	PACKAGE: 'package',
	DIR: 'folder',
	MODULE: 'module',
	CLASS: 'class',
	INTERFACE: 'interface',
	TYPEDEF: 'typedef',
	MIXIN: 'class',
	NAMESPACE: 'namespace'
};
const NODE_ORDER = {
	'root': 0,
	'folder': 1,
	'package': 2,
	'module': 3,
	'namespace': 4,
	'class': 5,
	'interface': 6,
	'typedef': 7,
	'mixin': 8
};

/**
 * Creates a data structure representing API navigation tree.
 * @type {module.NavigationTree}
 */
module.exports = class NavigationTree {
	/**
	 * @param {String} rootPath Path of the root node.
	 * @param {Function} longnameToUrl Function returning a filename for a given longname.
	 * @param {Array.<String>} packagePaths Paths to package/sub-repos directories.
	 * @param {String} projectRootPath Path to documented project directory. Used to construct urls.
	 * @param {String} apiSrcDir Project's api directory. Used to construct ulrs along with projectRootPath.
	 * @param {DataCollection} dataCollection Collection of doclets.
	 */
	constructor( {
		rootPath = '/',
		longnameToUrl,
		packagePaths,
		projectRootPath,
		apiSrcDir = 'api',
		dataCollection
	} = {} ) {
		this._tree = new TreeModel( {
			// Child nodes are inserted in alphabetical order.
			modelComparatorFn( modelA, modelB ) {
				if ( modelA.type !== modelB.type ) {
					return NODE_ORDER[ modelA.type ] - NODE_ORDER[ modelB.type ];
				}

				return modelA.name.localeCompare( modelB.name );
			}
		} );

		this._root = this.createNode( NODE_TYPES.ROOT, 'root', rootPath );

		this.NODE_TYPES = NODE_TYPES;

		this.longnameToUrl = longnameToUrl;

		this.packagePaths = packagePaths;

		this.projectRootPath = projectRootPath;

		this.apiSrcDir = apiSrcDir;

		this.dataCollection = dataCollection;

		this._addPackagePages();
	}

	/**
	 * In case of a multi-repo project, index pages of various packages may be written in markdown and generated by hexo as guides.
	 * But they need to be added to the API navigation tree.
	 */
	_addPackagePages() {
		for ( const p of this.packagePaths ) {
			const packageGuidePath = upath.join( this.projectRootPath, p.path, 'docs', this.apiSrcDir, `${ p.prefix }.md` );

			if ( fs.existsSync( packageGuidePath ) ) {
				this.add(
					{
						name: p.prefix,
						longname: p.prefix,
						kind: 'package'
					}
				);
			}
		}
	}

	/**
	 * Whole tree is accessible by returning the root note.
	 */
	getTree() {
		return this._root;
	}

	/**
	 * Adds a doclet to navigation tree.
	 * @param {Doclet} item
	 */
	add( item ) {
		const nodesToAdd = this.createNodesToAdd( item );
		const itemPath = this.buildPath( nodesToAdd.map( node => {
			return node.model.name;
		} ) );

		if ( this.getNodeByPath( itemPath ) ) {
			// this node already exists in the tree, return
			return;
		}

		// Which nodes need to be inserted and where.
		const insertionData = this.getInsertionData( nodesToAdd );

		if ( insertionData ) {
			this.addNodesAsSubtree( insertionData.startNode, insertionData.nodesToInsert );
		}
	}

	/**
	 * Creates an array of tree nodes from a single doclet.
	 * Given a doclet of a class, we may need a subtree of package->folder->module->class etc.
	 * @param {Doclet} item
	 * @returns {Array.<Node>}
	 */
	createNodesToAdd( item ) {
		const ln = item.longname.replace( 'module:', '' );
		const parts = ln.split( /[/~.]/ ).reverse();
		const name = parts[ 0 ];
		const parents = [];
		let currentDoclet = item;

		for ( let i = 0; i < parts.length; i++ ) {
			// Check if parent really exists in doclet collection.
			const potentialParent = this.dataCollection.get( `ln:${ currentDoclet.memberof }` ).getFirst();

			if ( currentDoclet.memberof && currentDoclet.memberof !== currentDoclet.longname && potentialParent ) {
				potentialParent.navTreeName = parts[ i + 1 ] || '';
				parents.unshift( potentialParent );
				currentDoclet = potentialParent;
			} else {
				break;
			}
		}

		// Package and directories.
		// When parent doclets in a subtree are found and there are still other parts left,
		// Then the left parts are for package/directory nodes.
		const nonDocletParts = parts.reverse().slice( 0, parts.length - parents.length - 1 );

		// Create nodes to add.
		const nodesToAdd = [];

		// Create nodes for package and directories.
		nonDocletParts.forEach( ( ndp, index ) => {
			if ( index === 0 ) {
				const packagePath = this.buildPath( [ ndp ] );
				nodesToAdd.push(
					this.createNode(
						NODE_TYPES.PACKAGE,
						ndp,
						packagePath,
						{
							longname: ndp
						}
					)
				);
			} else {
				const initialPath = isNonEmptyArray( nodesToAdd ) ? nodesToAdd[ nodesToAdd.length - 1 ].model.path : undefined;
				nodesToAdd.push(
					this.createNode(
						NODE_TYPES.DIR,
						ndp,
						this.buildPath( [ ndp ], initialPath )
					)
				);
			}
		} );

		for ( const parent of parents ) {
			const initialPath = isNonEmptyArray( nodesToAdd ) ? nodesToAdd[ nodesToAdd.length - 1 ].model.path : undefined;
			const node = this.createNode(
				NODE_TYPES[ parent.kind ? parent.kind.toUpperCase() : 'MODULE' ],
				parent.navTreeName,
				this.buildPath( [ parent.navTreeName ], initialPath ),
				{
					longname: parent.longname,
					access: parent.access
				}
			);

			nodesToAdd.push( node );
		}

		const initialPath = isNonEmptyArray( nodesToAdd ) ? nodesToAdd[ nodesToAdd.length - 1 ].model.path : undefined;
		const currentNode = this.createNode(
			NODE_TYPES[ item.kind ? item.kind.toUpperCase() : 'CLASS' ],
			name,
			this.buildPath( [ name ], initialPath ),
			{
				longname: item.longname,
				access: item.access
			}
		);

		nodesToAdd.push( currentNode );

		return nodesToAdd;
	}

	/**
	 *
	 * @param {String} type Node type from NODE_TYPES.
	 * @param {String} name Node name displayed in API tree.
	 * @param {String} path Node's path, e.g. ckeditor5/engine/model/document/Document
	 * @param {Object} data Additional node's data.
	 * @return {TreeModel.Node}
	 */
	createNode( type, name, nodePath, data ) {
		const parsedNode = this._tree.parse( new Node( type, name, nodePath, data ) );
		const that = this;

		parsedNode.getProp = function( item ) {
			if ( Object.prototype.hasOwnProperty.call( this.model, item ) ) {
				return this.model[ item ];
			}

			return null;
		};

		parsedNode.setProp = function( item, value ) {
			if ( Object.prototype.hasOwnProperty.call( this.model, item ) ) {
				this.model[ item ] = value;

				return this.model[ item ];
			}

			return null;
		};

		parsedNode.getChildren = function() {
			return this.children;
		};

		parsedNode.getUrl = function() {
			if ( this.model.type === NODE_TYPES.PACKAGE ) {
				const packageName = this.model.name;
				const packagePath = that.packagePaths.find( p => p.prefix === packageName );

				if ( !packagePath ) {
					return '';
				}

				const packageGuidePath = upath.join(
					that.projectRootPath,
					packagePath.path,
					'docs',
					that.apiSrcDir,
					`${ packageName }.md`
				);

				if ( fs.existsSync( packageGuidePath ) ) {
					return `${ packageName }.html`;
				} else {
					return '';
				}
			}

			return that.longnameToUrl( this.model.longname );
		};

		return parsedNode;
	}

	/**
	 * Builds a node path, e.g. ckeditor5/engine/model/document/Document.
	 * Node path is used to locate a node in a tree quickly.
	 * @param segments
	 * @param initial
	 * @returns {String}
	 */
	buildPath( segments, initial = this.getRootPath() ) {
		return segments.reduce( ( result, val ) => {
			return upath.join( result, val );
		}, initial );
	}

	/**
	 * Adds node to another node.
	 * @param target
	 * @param node
	 */
	addNode( target, node ) {
		if ( !node ) {
			return;
		}

		target.addChild( node );
	}

	/**
	 *
	 * @param path
	 */
	getNodeByPath( path ) {
		return this._root.first( node => {
			return node.model.path === path;
		} );
	}

	/**
	 * Finds the node to which new nodes should be appended. Also returns the new nodes which should be appended.
	 * Return null if entry point wasn't found.
	 * @param {Array.<TreemModel.Node>} nodes Nodes to be added.
	 * @param {TreeModel.Node} startNode Node to which new nodes are appended.
	 * @returns {Object|null}
	 */
	getInsertionData( nodes, startNode = this._root ) {
		if ( !isNonEmptyArray( nodes ) || ( isNonEmptyArray( nodes ) && !nodes[ 0 ] ) ) {
			return null;
		}

		const node = nodes[ 0 ];
		const currentExistingNode = this.getNodeByPath( node.model.path );

		// If current node from nodes to add already exists in the tree, run getInsertionData again for the next node.
		// Set current existing node as the startNode for the next iteration.
		if ( currentExistingNode ) {
			return this.getInsertionData( nodes.slice( 1 ), currentExistingNode );
		} else {
			// Append to tree's root node if first node of nodes to add doesn't yet exist.
			return {
				startNode,
				nodesToInsert: nodes
			};
		}
	}

	/**
	 * Recursively appends nodes to target node. Nodes are appended as a subtree.
	 * Each consecutive node is appended to previous node.
	 * @param target
	 * @param nodes
	 */
	addNodesAsSubtree( target, nodes ) {
		const node = nodes[ 0 ];
		this.addNode( target, node );

		if ( nodes.length > 0 ) {
			this.addNodesAsSubtree( node, nodes.slice( 1 ) );
		}
	}

	getRootPath() {
		return this._root.model.path;
	}

	collapseAll() {
		const nodes = this._root.all();

		for ( const n of nodes ) {
			n.setProp( 'isExpanded', false );
			n.setProp( 'isActive', false );
		}
	}

	expandToItem( criteria ) {
		this.collapseAll();
		const nodeToExpand = this._root.first( node => {
			for ( const key of Object.keys( criteria ) ) {
				if ( node.getProp( key ) !== criteria[ key ] ) {
					return false;
				}
			}

			return true;
		} );

		if ( nodeToExpand ) {
			nodeToExpand.setProp( 'isActive', true );
			const subTreeNodes = nodeToExpand.getPath();

			for ( const n of subTreeNodes ) {
				n.setProp( 'isExpanded', true );
			}
		}
	}
};
