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

'use strict';

const isNonEmptyArray = require( '../../helpers/is-non-empty-array' );
const splitLongname = require( '../../helpers/split-longname' );

/**
 * Factory creating docs data to be used in templates.
 * API data coming as doclets needs to be transformed and grouped.
 * For example data of a class has to include all properties, methods, etc.
 * @type {DocDataFactory}
 */
module.exports = class DocDataFactory {
	/**
	 * @param dataCollection
	 * @param descriptionParser
	 * @param options
	 */
	constructor( dataCollection, descriptionParser, options = {} ) {
		/**
		 * Collection of doclets additionally groped by 'memberof' property, kind, longname.
		 * @type {DataCollection}
		 */
		this._dataCollection = dataCollection;

		/**
		 * Instance of DescriptionParser class, which transforms doclet.description property.
		 * @type {DescriptionParser}
		 */
		this._descriptionParser = descriptionParser;

		/**
		 * Additional options.
		 * @type {Object}
		 */
		this._options = options;
	}

	/**
	 * Prepares most basic data of an API docs item.
	 * @param doclet
	 * @param options
	 * @returns {Object}
	 */
	_getBase( doclet, options = {} ) {
		const description = options.skipDescription ? '' : this._descriptionParser.parseDescription( doclet.description, {
			headingDecreaseLevel: options.headingDecreaseLevel,
			doclet,
			parentDoclet: options.doclet,
			buildInfo: this._options.buildInfo
		} );
		const badges = prepareBadges( doclet, this );
		const id = doclet.extraId;

		return {
			id: id ? id.replace( /\$/g, 's-' ) : '',
			name: doclet.name,
			longname: doclet.longname,
			kind: doclet.kind,
			access: doclet.access,
			inherited: doclet.mixed ? false : doclet.inherited,
			mixed: doclet.mixed,
			deprecated: doclet.deprecated,
			since: doclet.since,
			file: doclet.file,
			see: doclet.see,
			firedBy: doclet.firedBy,
			badges,
			description,
			// By default we want to show a link to source. However, those URLs can be hide using the `@skipsource` annotation.
			skipSource: doclet.skipSource || false
		};
	}

	/**
	 * Prepares API data for a module.
	 * @param doclet
	 * @returns {Object}
	 */
	getModule( doclet ) {
		const base = this._getBase( doclet, { headingDecreaseLevel: 2 } );
		// Get all doclets, which are a 'memberof' the module doclet.
		const moduleMembersCollection = this._dataCollection.get( `memberof:${ doclet.longname }` );
		const classes = moduleMembersCollection.get( 'class' );
		const interfaces = moduleMembersCollection.get( 'interface' );
		const typedefs = moduleMembersCollection.get( 'typedef' );
		const namespaces = moduleMembersCollection.get( 'namespace' );
		const functions = moduleMembersCollection.get( 'function' );
		const members = moduleMembersCollection.get( 'member' );
		const constants = moduleMembersCollection.get( 'constant' );
		const functionsProcessed = this._getMembers( functions, { parentDoclet: doclet } ) || [];
		const membersProcessed = this._getMembers( members, { parentDoclet: doclet } ) || [];
		const constantsProcessed = this._getMembers( constants, { parentDoclet: doclet } ) || [];

		return Object.assign( base, {
			classes: classes.map( el => this.getClassShort( el, true ) ),
			interfaces: interfaces.map( el => this.getInterfaceShort( el, true ) ),
			typedefs: typedefs.map( el => this.getTypedefShort( el, true ) ),
			namespaces: namespaces.map( el => this.getNamespaceShort( el, true ) ),
			functions: functionsProcessed,
			properties: membersProcessed,
			constants: constantsProcessed,
			// isOfLowImportance means that module page is basicly just a link to Class, Interface etc and is not important for search.
			isOfLowImportance: !functionsProcessed.length && !membersProcessed.length && !constantsProcessed.length
		} );
	}

	/**
	 * Get basic API data of a class to be used in a module page.
	 * @param doclet
	 * @param {Boolean} [asMember=false] - Flag which informs if classed is obtained as member. Used on modules pages, which lists them.
	 * @returns {Object}
	 */
	getClassShort( doclet, asMember = false ) {
		const base = this._getBase( doclet, {
			skipDescription: true
		} );

		const description = this._descriptionParser.parseDescription( doclet.description, {
			doclet,
			asMember,
			buildInfo: this._options.buildInfo
		} );

		const itemData = {
			description
		};

		if ( doclet.typeParameters ) {
			itemData.typeParameters = doclet.typeParameters.map( typeParameter => {
				if ( typeParameter.description ) {
					typeParameter.description = this._descriptionParser.parseDescription( typeParameter.description, {
						doclet,
						buildInfo: this._options.buildInfo
					} );
				}

				return typeParameter;
			} );
		}

		return Object.assign( base, itemData );
	}

	/**
	 * Get basic API data of an interface to be used in a module page.
	 * @param doclet
	 * @param {Boolean} [asMember=false] - Flag which informs if interface is obtained as member. Used on modules pages, which lists them.
	 * @returns {Object}
	 */
	getInterfaceShort( doclet, asMember = false ) {
		return this.getClassShort( doclet, asMember );
	}

	/**
	 * Get basic API data of a typedef to be used in a module page.
	 * @param doclet
	 * @param {Boolean} [asMember=false] - Flag which informs if typedef is obtained as member. Used on modules pages, which lists them.
	 * @returns {Object}
	 */
	getTypedefShort( doclet, asMember = false ) {
		return this.getClassShort( doclet, asMember );
	}

	/**
	 * Get basic API data of a namespace to be used in a module page.
	 * @param doclet
	 * @param {Boolean} [asMember=false] - Flag which informs if namespace is obtained as member. Used on modules pages, which lists them.
	 * @returns {Object}
	 */
	getNamespaceShort( doclet, asMember = false ) {
		return this.getClassShort( doclet, asMember );
	}

	/**
	 * Get API data of a typedef to be used in typedef's own page.
	 * @param doclet
	 * @returns {Object}
	 */
	getTypedefFull( doclet ) {
		const base = this._getBase( doclet, { headingDecreaseLevel: 2 } );
		const augmentsNested = doclet.augmentsNested ? doclet.augmentsNested : [];
		augmentsNested.unshift( doclet.longname );
		augmentsNested.reverse();
		const properties = this._getMembers( doclet.properties, { parentDoclet: doclet } ) || [];

		/* eslint-disable @stylistic/max-len */
		// For all "typedef" doclets - copy property name as its id. Thanks to that we will be able to make a directly link to specified property. See #796.
		// "member-" as a static string must be the same as defined in `addTypedefProperties()` helper in the `@ckeditor/jsdoc-plugins` package.
		// See: https://github.com/ckeditor/ckeditor5-dev/blob/aa905a702d337db001445efc1036ab2b33069d44/packages/jsdoc-plugins/lib/relation-fixer/addtypedefproperties.js#L95
		/* eslint-enable @stylistic/max-len */
		if ( doclet.kind == 'typedef' ) {
			for ( const property of properties ) {
				if ( !property.id ) {
					property.id = 'member-' + property.name;
				}

				if ( !property.longname ) {
					const [ name ] = property.id.split( 'member-' ).filter( Boolean );

					property.longname = `${ doclet.longname }#${ name }`;
				}
			}
		}

		const itemData = {
			types: getTypes( doclet ),
			properties: properties.sort( ( a, b ) => sorter( a.name, b.name ) ),
			augments: augmentsNested
		};

		if ( doclet.params ) {
			itemData.params = this._getMembers( doclet.params );
			itemData.params = this._processParams( itemData.params );
		}

		if ( doclet.returns ) {
			itemData.returns = this._getMembers( doclet.returns );
		}

		itemData.typeParameters = doclet.typeParameters || null;

		return Object.assign( base, itemData );
	}

	/**
	 * Prepares API data for namespace's own page.
 	 * @param doclet
	 * @returns {Object}
	 */
	getNamespaceFull( doclet ) {
		return this.getModule( doclet );
	}

	/**
	 * Prepares API data for interface's own page.
	 * @param doclet
	 * @returns {Object}
	 */
	getInterfaceFull( doclet ) {
		return Object.assign( this.getClassFull( doclet ), {
			subclasses: [],
			implementedBy: doclet.descendants ? sortLongnamesAlphabetically( doclet.descendants ) : []
		} );
	}

	/**
	 * Prepares API data for interface's own page.
	 * @param doclet
	 * @returns {Object}
	 */
	getMixinFull( doclet ) {
		return Object.assign( this.getClassFull( doclet ), {
			subclasses: [],
			mixedBy: doclet.descendants ? sortLongnamesAlphabetically( doclet.descendants ) : []
		} );
	}

	/**
	 * Prepares API data for class'es own page.
	 * @param doclet
	 * @returns {Object}
	 */
	getClassFull( doclet ) {
		const classMembersCollection = this._dataCollection.get( `memberof:${ doclet.longname }` );
		const configOptions = classMembersCollection.get( 'cfg' );
		const properties = classMembersCollection.get( 'member' );
		const methods = classMembersCollection.get( 'function' );
		const events = classMembersCollection.get( 'event' );
		const configOptionsProcessed = this._getMembers( configOptions, { parentDoclet: doclet } );
		// Sorting properties alphabetically and also public > internal > protected > private.
		const propertiesProcessed = sortPartsAlphabetically(
			this._getMembers( properties, { parentDoclet: doclet } ),
			[
				{
					key: 'access',
					value: 'public'
				},
				{
					key: 'access',
					value: 'internal'
				},
				{
					key: 'access',
					value: 'protected'
				},
				{
					key: 'access',
					value: 'private'
				}
			]
		);

		const staticProperties = Array.isArray( propertiesProcessed ) ? propertiesProcessed.filter( p => p.scope === 'static' ) : [];
		const instanceProperties = Array.isArray( propertiesProcessed ) ? propertiesProcessed.filter( p => p.scope !== 'static' ) : [];
		const methodsProcessed = sortPartsAlphabetically(
			this._getMembers( methods, { parentDoclet: doclet } ),
			[
				{
					key: 'name',
					value: 'constructor'
				},
				{
					key: 'access',
					value: 'public'
				},
				{
					key: 'access',
					value: 'internal'
				},
				{
					key: 'access',
					value: 'protected'
				},
				{
					key: 'access',
					value: 'private'
				}
			]
		);

		const staticMethods = Array.isArray( methodsProcessed ) ? methodsProcessed.filter( m => m.scope === 'static' ) : [];
		const instanceMethods = Array.isArray( methodsProcessed ) ? methodsProcessed.filter( m => m.scope !== 'static' ) : [];
		const augmentsNested = doclet.augmentsNested ? doclet.augmentsNested : [];
		const typeParameters = doclet.typeParameters || null;

		augmentsNested.unshift( doclet.longname );
		augmentsNested.reverse();

		return Object.assign( this._getBase( doclet, { headingDecreaseLevel: 2 } ), {
			configOptions: configOptionsProcessed,
			properties: propertiesProcessed,
			staticProperties,
			instanceProperties,
			methods: methodsProcessed,
			staticMethods,
			instanceMethods,
			typeParameters,
			events: this._getMembers( events, {
				callback: item => {
					item.name = item.name.replace( 'event:', '' );

					return item;
				}
			} ),
			subclasses: doclet.descendants ? sortLongnamesAlphabetically( doclet.descendants ) : [],
			implements: doclet.implementsNested ? sortLongnamesAlphabetically( doclet.implementsNested ) : [],
			mixes: doclet.mixesNested ? sortLongnamesAlphabetically( doclet.mixesNested ) : [],
			augments: augmentsNested,
			// By default we want to show a link to source. However, those URLs can be hidden using the `@skipsource` annotation.
			skipSource: doclet.skipSource || false
		} );
	}

	/**
	 * Get API data for error codes.
	 * @returns {Object}
	 */
	getErrors() {
		const errors = this._dataCollection.get( 'error' ).getAll();

		return this._getMembers( errors );
	}

	/**
	 * Process API data of all items (usually doclets of properties, methods, but also data of params etc).
	 * @param {Array.<Object>} items Array of API data objects, usually doclets.
	 * @param {Function} callback Transform function to be executed on every item after processing.
	 * @returns {Array.<Object>}
	 */
	_getMembers( items, { callback, parentDoclet } = {} ) {
		if ( !isNonEmptyArray( items ) ) {
			return;
		}

		let itemsData = [];

		for ( const item of items ) {
			itemsData.push( this._getMemberDetails( item, { parentDoclet } ) );
		}

		if ( typeof callback === 'function' ) {
			itemsData = itemsData.map( callback );
		}

		return itemsData;
	}

	/**
	 * Same as _getMembers but prepares also an excerpt of items descriptions.
	 * @param {Array.<Object>} items Array of API data objects, usually doclets.
	 * @param {Function} callback Transform function to be executed on every item after processing.
	 * @returns {Array.<Object>}
	 */
	_getMembersWithExcerpt( items, { callback, parentDoclet } = {} ) {
		if ( !isNonEmptyArray( items ) ) {
			return;
		}

		let itemsData = [];

		for ( const item of items ) {
			itemsData.push( this._getMemberDetailsWithExcerpt( item, { parentDoclet } ) );
		}

		if ( typeof callback === 'function' ) {
			itemsData = itemsData.map( callback );
		}

		return itemsData;
	}

	/**
	 * Prepares API data of an API item, e.g. property, method.
	 * That data is used in pug templates to render HTML.
	 * @param doclet
	 * @param options
	 * @returns {Object}
	 */
	_getMemberDetails( doclet, options = {} ) {
		const base = this._getBase( doclet, {
			skipDescription: options.skipDescription,
			doclet: options.parentDoclet
		} );
		const itemData = {};

		itemData.readonly = doclet.readonly;
		itemData.defaultvalue = typeof doclet.defaultvalue !== 'undefined' && doclet.defaultvalue !== null ?
			doclet.defaultvalue.toString() : null;
		itemData.label = doclet.label;
		itemData.optional = doclet.optional;
		itemData.scope = doclet.scope;
		itemData.fires = doclet.fires;
		itemData.types = getTypes( doclet );
		itemData.typeParameters = doclet.typeParameters || null;

		if ( doclet.params ) {
			itemData.params = this._getMembers( doclet.params, { parentDoclet: options.parentDoclet } );
			itemData.params = this._processParams( itemData.params );
		}

		if ( doclet.returns ) {
			itemData.returns = this._getMembers( doclet.returns, { parentDoclet: options.parentDoclet } );
		}

		if ( !doclet.access ) {
			itemData.access = 'public';
		}

		if ( doclet.overrides ) {
			if ( !Array.isArray( doclet.overrides ) ) {
				doclet.overrides = [ doclet.overrides ];
			}

			itemData.overrides = doclet.overrides.map( o => {
				if ( typeof o === 'string' ) {
					return o;
				}

				if ( o.link ) {
					return this._descriptionParser.parseDescription( o.link, {
						doclet: options.parentDoclet,
						buildInfo: this._options.buildInfo
					} );
				}

				return null;
			} );
		}

		if ( doclet.typeParameters ) {
			itemData.typeParameters = doclet.typeParameters.map( typeParameter => {
				if ( typeParameter.description ) {
					typeParameter.description = this._descriptionParser.parseDescription( typeParameter.description, {
						doclet: options.parentDoclet,
						buildInfo: this._options.buildInfo
					} );
				}

				return typeParameter;
			} );
		}

		return Object.assign( base, itemData );
	}

	/**
	 * Same as _getMemberDetails but with an excerpt of description.
	 * @param doclet
	 * @param options
	 * @returns {Object}
	 */
	_getMemberDetailsWithExcerpt( doclet, options = {} ) {
		const description = this._descriptionParser.parseDescription( doclet.description, {
			withExcerpt: true,
			doclet: options.parentDoclet,
			buildInfo: this._options.buildInfo
		} );

		return Object.assign(
			this._getMemberDetails(
				doclet,
				Object.assign(
					{ skipDescription: true },
					options
				)
			),
			{ description }
		);
	}

	/**
	 * Add nested params to every param if available.
	 * Mostly used for 'options' params, which have own sub-params.
	 * @param {Array.<Object>} data API params data.
	 * @returns {Array.<Object>}
	 */
	_processParams( data = [] ) {
		const resultParams = data.slice(); // copy data array

		// Find properties that may contain nested properties.
		const parentParams = resultParams.filter( item => {
			if ( item.params ) {
				return true;
			}

			return item.types.some( type => {
				// An inline callback.
				if ( type.type && type.type === 'function' ) {
					return true;
				}

				if ( typeof type !== 'string' ) {
					return false;
				}

				return type.toLowerCase() === 'object';
			} );
		} );

		// For every parentParam gather its subparams.
		for ( const parent of parentParams ) {
			const subParams = [];

			for ( let i = 0; i < resultParams.length; i++ ) {
				const item = resultParams[ i ];

				if ( item.name && item.name.includes( `${ parent.name }.` ) ) {
					subParams.push( item );
					// remove sub-params from main array, they will be kept under parentParam
					resultParams.splice( i--, 1 );
				}
			}

			parent.params = subParams;
			parent.subParamsText = 'Properties';

			// build a string to display after parent param, next to method name
			if ( parent.params.length > 0 ) {
				parent.subParamsString = ' = { ';

				if ( parent.types.length === 1 && parent.types[ 0 ].type === 'function' ) {
					parent.subParamsString += renderInlineCallbackAsObjectProperty( parent.types[ 0 ] );

					if ( subParams.length ) {
						parent.subParamsString += ', ';
					}
				}

				subParams.forEach( ( sub, index ) => {
					const isComputedProperty = sub.id === 'member-__index';

					if ( isComputedProperty ) {
						const [ parentParam, nestedParam ] = sub.name.split( '.' );

						parent.subParamsString += `${ parentParam }<i>${ nestedParam }</i>`;
					} else {
						parent.subParamsString += sub.optional ? `[${ sub.name }]` : sub.name;
					}

					if ( index < subParams.length - 1 ) {
						parent.subParamsString += ', ';
					}
				} );

				parent.subParamsString += ' }';
			}
		}

		return resultParams;
	}
};

/**
 * Prepare types data.
 * @param doclet
 * @returns {Array<String>}
 */
function getTypes( doclet ) {
	if ( doclet.type && doclet.type.names ) {
		return doclet.type.names;
	} else if ( doclet.type ) {
		return [ doclet.type ];
	} else {
		return [];
	}
}

/**
 * Prepares data for badges displayed with properties, methods etc ('private', 'inherited' etc).
 * @param item
 * @param context
 * @returns {Array}
 */
function prepareBadges( item, context ) {
	const badges = [];

	if ( item.singleton ) {
		badges.push( {
			class: 'purple',
			text: 'singleton'
		} );
	}

	if ( item.since ) {
		badges.push( {
			class: 'since',
			text: 'since',
			info: item.since
		} );
	}

	if ( item.deprecated ) {
		const deprecatedBadge = {
			class: 'red',
			text: 'deprecated'
		};

		if ( item.deprecated.version ) {
			deprecatedBadge.info = item.deprecated.version;
		}

		badges.push( deprecatedBadge );
	}

	if ( item.chainable ) {
		badges.push( {
			class: 'green',
			text: 'chainable'
		} );
	}

	switch ( item.access ) {
		case 'private':
			badges.push( {
				class: 'red',
				text: 'private'
			} );
			break;

		case 'internal':
			badges.push( {
				class: 'orange',
				text: 'internal'
			} );
			break;

		case 'protected':
			badges.push( {
				class: 'orange',
				text: 'protected'
			} );
			break;
	}

	if ( item.readonly ) {
		badges.push( {
			class: 'gray',
			text: 'readonly'
		} );
	}

	if ( !item.mixed && item.inherited ) {
		badges.push( {
			class: 'gray',
			text: 'inherited'
		} );
	}

	if ( item.mixed ) {
		badges.push( {
			class: 'gray',
			text: 'mixed'
		} );
	}

	if ( item.scope === 'static' ) {
		badges.push( {
			class: 'gray',
			text: 'static'
		} );
	}

	if ( item.observable ) {
		const obsDocs = context._options.projectConfig.observablesdocs || '';
		const longnameToUrl = context._options.longnameToUrl;
		// const url = longnameToUrl( obsDocs ) || '';
		const url = obsDocs.includes( '.html' ) ? obsDocs : longnameToUrl( obsDocs );

		badges.push( {
			class: 'gray badge--observable',
			text: 'observable',
			linkTo: url || ''
		} );
	}

	if ( item.abstract ) {
		badges.push( {
			class: 'gray',
			text: 'abstract'
		} );
	}

	return badges;
}

function sortLongnamesAlphabetically( arr ) {
	if ( !arr || !arr.length ) {
		return;
	}

	return arr
		.map( item => {
			return {
				entry: item,
				split: splitLongname( item ).name
			};
		} )
		.sort( ( a, b ) => sorter( a.split, b.split ) )
		.map( item => item.entry );
}

function sortPartsAlphabetically( arr, criteria = [ {} ] ) {
	if ( !arr || !arr.length ) {
		return;
	}

	const result = [];

	for ( const crit of criteria ) {
		const tmp = arr
			.filter( item => item[ crit.key ] === crit.value )
			.sort( ( a, b ) => sorter( a.name, b.name ) );

		tmp.forEach( item => {
			const index = arr.indexOf( item );
			arr.splice( index, 1 );
		} );

		result.push( ...tmp );
	}

	return result;
}

function sorter( a, b ) {
	// Computed property name starts with the "[" character. We want to show them on the top, so let's prepend the name
	// (only for the sorting purposes) with a space, which ASCII code is less than any other alphanumeric character.
	if ( a.startsWith( '[' ) ) {
		a = ' ' + a;
	}

	if ( b.startsWith( '[' ) ) {
		b = ' ' + b;
	}

	return a <= b ? -1 : 1;
}

/**
 * This is a JavaScript version of the `renderInlineFunction()` pug mixin.
 *
 * See: `themes/umberto/<theme name>/layout/_api-docs/_mixin/_type.pug`.
 *
 * The goal is to render an inline object describing a callable type.
 *
 * @param {Object} type
 * @param {Array.<String>} type.params
 * @param {Array.<String>|String} type.returns
 * @param {Boolean} type.isClass
 * @returns {String}
 */
function renderInlineCallbackAsObjectProperty( type ) {
	const { params, returns, isClass } = type;

	return [
		// Indicate if a function returns a class.
		isClass ? 'new' : '',

		// Parameters section.
		'(',
		params.map( mapToDocletCallback ).join( ', ' ),
		')',

		// Returns section.
		' =&gt; ',

		// Wraps an union in brackets.
		Array.isArray( returns ) ? '(' : '',
		Array.isArray( returns ) ? returns.map( mapToDocletCallback ).join( ' | ' ) : mapToDocletCallback( returns ),
		Array.isArray( returns ) ? ')' : ''
	].join( '' );

	function mapToDocletCallback( param ) {
		if ( !param.startsWith( 'module:' ) ) {
			return param;
		}

		const href = param.replace( /:|\//g, '_' ).replace( '~', '-' ) + '.html';
		const value = param.split( '~' )[ 1 ];

		return `<a href="${ href }">${ value }</a>`;
	}
}
