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

'use strict';

const MarkdownIt = require( 'markdown-it' );
const ReflectionKind = require( './reflectionkind' );
const markdown = new MarkdownIt( {
	html: true
} );

const ISSUE_URL = 'https://github.com/cksource/umberto/issues/new?assignees=&labels=type:feature,squad:platform';
const MODULE_INDEX_PATTERN = /^module:(?!icons\b)[\w-]+\/index/;

class TypedocConverter {
	/**
	 * @param {Array.<AbstractParser>} parsers
	 */
	constructor( parsers ) {
		/**
		 * @protected
		 * @member {Array.<Object>}
		 */
		this._doclets = [];

		/**
		 * A map translating an identifier from Typedoc to a JsDoc doclet.
		 * It may contain an identifier from Typedoc that points to another identifier (a reference linked to another reference).
		 *
		 * @protected
		 * @member {Map.<Number, String>}
		 */
		this._moduleMap = new Map();

		/**
		 * @protected
		 * @member {Array.<AbstractParser>}
		 */
		this._parsers = parsers.map( Class => new Class() );

		/**
		 * @protected
		 * @member {TypeConverter}
		 */
		this._typeConverter = new TypeConverter( this._moduleMap );
	}

	/**
	 * @param {Array.<TypedocCommentItem>} content
	 * @returns {String}
	 */
	static convertComment( content ) {
		const value = content.reduce( ( value, { kind, text, tag } ) => {
			if ( kind === 'inline-tag' ) {
				text = `{${ tag } ${ text }}`;
			}

			return value + text;
		}, '' );

		return markdown.render( value ).trim();
	}

	/**
	 * Prepares the name for the computed property.
	 *
	 * @param {BaseReflection} item
	 * @returns {String}
	 */
	static getComputedName( item ) {
		if ( item.kind !== ReflectionKind.IndexSignature ) {
			return item.name;
		}

		const [ firstParam ] = item.parameters;
		const paramName = firstParam.name;
		const paramType = firstParam.type.name;

		return `[${ paramName }: ${ paramType }]`;
	}

	/**
	 * @param {ProjectReflection} projectReflection
	 * @returns {Array.<Object>}
	 */
	convertToJsDoc( projectReflection ) {
		// Convert the entire project to JsDoc doclets.
		for ( const child of projectReflection.children ) {
			this._convertChild( child );
		}

		// Then, when all doclets are specified, let's align types.
		for ( const doclet of this._doclets ) {
			// Skip source if the `file` property is not specified.
			if ( !doclet.file ) {
				doclet.skipSource = true;
			}

			// Post-processing is possible only if a doclet contains its original signature.
			if ( !doclet._signature ) {
				continue;
			}

			// TODO: Could we get a deep list of all inheritance/derived?
			// Map parent references of classes and interfaces...
			if ( 'augmentsNested' in doclet ) {
				const extendedTypes = doclet._signature.extendedTypes || [];

				doclet.augmentsNested = extendedTypes.flatMap( getHierarchyCallback( this._typeConverter ) );
			}

			// ...but also, the derived instances...
			if ( 'descendants' in doclet ) {
				const extendedBy = doclet._signature.extendedBy || [];

				doclet.descendants = extendedBy.flatMap( getHierarchyCallback( this._typeConverter ) );
			}

			// ...and implemented interfaces.
			if ( 'implementsNested' in doclet ) {
				const implementedTypes = doclet._signature.implementedTypes || [];

				doclet.implementsNested = implementedTypes.flatMap( getHierarchyCallback( this._typeConverter ) );
			}

			// Unify the `_signature` tuple, so all doclets use the same algorithm.
			if ( !Array.isArray( doclet._signature ) ) {
				doclet._signature = [ doclet._signature, doclet._signature ];
			}

			const [ getSignature, setSignature ] = doclet._signature;
			const returnTypes = this._convertReturnTypes( doclet, getSignature );

			if ( returnTypes ) {
				if ( 'returns' in doclet ) {
					doclet.returns = returnTypes;
				} else if ( 'type' in doclet ) {
					doclet.type = returnTypes[ 0 ].type;
				}
			}

			if ( 'params' in doclet ) {
				doclet.params = this._convertParameters( doclet, setSignature );
			}

			if ( 'properties' in doclet ) {
				doclet.properties = this._convertProperties( setSignature );
			}

			if ( 'typeParameters' in doclet ) {
				// For all doclets, it does not matter whether we process a get or a set signature.
				// Hence, let's use the first one.
				doclet.typeParameters = this._typeConverter.convertTypeParameters( getSignature.typeParameters );
			}

			// To resolve references from a package entry point (`@module package/index`).
			// We want to link a reference to its target module.
			//
			// Currently, we do not render them in the API docs. However, there are plans to improve
			// rendering of CKEditor 5 packages (`ckeditor5` and `ckeditor5-premium-features`).
			// So, let's keep it for the future.
			if ( '_isReference' in doclet ) {
				let targetDoclet;
				let referenceId = getSignature.target;

				// The reference identifier may point to another reference identifier.
				// The loop is needed to get the target (final) module name.
				do {
					targetDoclet = this._doclets.find( item => item.id === referenceId );
					referenceId = targetDoclet.target;
				} while ( referenceId );

				doclet.targetDoclet = targetDoclet;
			}

			// In the end, remove the helper properties.
			delete doclet._isReference;
			delete doclet._signature;
		}

		// Index pages are not rendered yet. Hence, we drop all these doclets. Except for icons.
		return this._doclets
			.filter( doclet => {
				return !doclet.longname.match( MODULE_INDEX_PATTERN ) && !doclet.memberof?.match( MODULE_INDEX_PATTERN );
			} );
	}

	/**
	 * @protected
	 * @param {BaseReflection} reflection
	 * @param {String|null} [parentName=null]
	 */
	_convertChild( reflection, parentName = null ) {
		const parser = this._parsers.find( parser => parser.canParse( reflection ) );

		let doclets;

		if ( parser ) {
			doclets = parser.parse( reflection, parentName );
		}

		if ( doclets ) {
			doclets = Array.isArray( doclets ) ? doclets : [ doclets ];

			for ( const item of doclets ) {
				this._doclets.push( item );

				this._moduleMap.set( reflection.id, item.longname );
			}
		}

		if ( !doclets ) {
			return;
		}

		const [ doclet ] = doclets;
		parentName = doclet.longname;

		// A computed property is stored in the `indexSignatures` key, but we want to convert it in the same way as we do for a "normal"
		// property. Hence, let's just treat the `indexSignatures` as it would be a doclet's child, so the dedicated parser for the
		// `indexSignatures` can prepare a doclet for the computed property.
		( reflection.children || [] ).forEach( subItem => {
			this._convertChild( subItem, parentName );
		} );

		( reflection.indexSignatures || [] ).forEach( ( subItem, index ) => {
			this._convertChild( mapIndexSignatureObject( subItem, index ), parentName );
		} );

		// CKEditor 5 events are stored in the `ckeditor5Events` key.
		( reflection.ckeditor5Events || [] ).forEach( subItem => {
			this._convertChild( {
				...subItem,
				isCKEditor5Event: true
			}, parentName );
		} );
	}

	/**
	 * @protected
	 * @param {Object} doclet
	 * @param {CallSignatureReflection} signature
	 * @returns {Array|null}
	 */
	_convertParameters( doclet, signature ) {
		if ( !signature.parameters ) {
			return null;
		}

		return signature.parameters
			.flatMap( item => {
				const response = this._convertTypeToJsDoc( item );

				response.name = item.name;

				if ( item.comment && item.comment.summary ) {
					response.description = TypedocConverter.convertComment( item.comment.summary );
				}

				if ( item.flags.isOptional ) {
					response.optional = true;
				}

				if ( item.defaultValue ) {
					response.defaultvalue = item.defaultValue;
				}

				const nestedProperties = this._convertProperties( item );

				if ( !nestedProperties ) {
					return response;
				}

				return [
					response,
					...nestedProperties.map( nestedProperty => {
						nestedProperty.name = `${ item.name }.${ nestedProperty.name }`;

						return nestedProperty;
					} )
				];
			} );
	}

	/**
	 * @protected
	 * @param {CallSignatureReflection} signature
	 * @returns {Array|null}
	 */
	_convertProperties( signature ) {
		let types;

		// Support for a literal object (`const foo: { /* object literal */ } = ...`).
		if ( 'children' in signature || 'indexSignatures' in signature ) {
			types = [
				{
					declaration: {
						children: [
							...( signature.children || [] ),
							...( signature.indexSignatures || [] ).map( mapIndexSignatureObject )
						]
					}
				}
			];
		} else {
			types = signature.type.type === 'union' ? signature.type.types : [ signature.type ];
		}

		const properties = types
			.filter( type => type.declaration )
			.flatMap( type => {
				return [
					...( type.declaration.children || [] ),
					...( type.declaration.indexSignatures || [] ).map( mapIndexSignatureObject )
				];
			} );

		return properties.map( child => {
			// Nested properties cannot be duplicated so we can take the first signature when processing functions.
			const childSignature = child.kind === ReflectionKind.Method ? child.signatures[ 0 ] : child;
			const response = this._convertTypeToJsDoc( childSignature );

			// For all properties, explicitly create the `id` key (as `extraId` key).
			// The `id` is then used to create the `longname` key. Unfortunately, nested properties are processed manually,
			// so part of the parser's job must be done here.
			response.extraId = `member-${ childSignature.name }`;
			response.name = TypedocConverter.getComputedName( childSignature );

			if ( signature.comment && signature.comment.blockTags ) {
				const atProperty = signature.comment.blockTags.find( blockTag => {
					return blockTag.tag === '@property' && blockTag.name === childSignature.name;
				} );

				if ( atProperty ) {
					response.description = TypedocConverter.convertComment( atProperty.content );
				}
			} else if ( childSignature.comment && childSignature.comment.summary ) {
				response.description = TypedocConverter.convertComment( childSignature.comment.summary );
			}

			if ( childSignature.flags.isOptional ) {
				response.optional = true;
			}

			if ( childSignature.defaultValue ) {
				response.defaultvalue = childSignature.defaultValue;
			}

			return response;
		} );
	}

	/**
	 * @protected
	 * @param {Object} doclet
	 * @param {CallSignatureReflection} signature
	 * @returns {Array|null}
	 */
	_convertReturnTypes( doclet, signature ) {
		if ( !signature.type ) {
			// Temporary solution for the `object` type. Let's create a fake structure that converter understands.
			if ( signature.kind === ReflectionKind.TypeAlias ) {
				return [
					this._convertTypeToJsDoc( {
						type: {
							type: 'reflection',
							declaration: {
								// Passing non-empty `signatures` would convert it as a callable type.
								signatures: null
							}
						}
					} )
				];
			}

			return null;
		}

		const response = this._convertTypeToJsDoc( signature );
		const comment = signature.comment || {};

		if ( !comment.blockTags ) {
			comment.blockTags = [];
		}

		const atReturns = comment.blockTags.find( blockTag => blockTag.tag === '@returns' );

		if ( atReturns ) {
			response.description = TypedocConverter.convertComment( atReturns.content );
		}

		return [ response ];
	}

	/**
	 * @protected
	 * @param {ParameterReflection} parameterReflection
	 * @returns {Object}
	 */
	_convertTypeToJsDoc( parameterReflection ) {
		let namesOrNames = this._typeConverter.convert( parameterReflection.type );

		// Union.
		if ( namesOrNames instanceof Set ) {
			return {
				type: {
					names: [ ...namesOrNames ]
				}
			};
		}
		// Missing type. Use a name without linking it anywhere.
		else if ( !namesOrNames ) {
			namesOrNames = parameterReflection.type.name;
		}

		return {
			type: {
				// Convert items into an array to avoid having mixed structures in a view.
				names: [ namesOrNames ]
			}
		};
	}
}

/**
 * A helper class for converting complex Typedoc reflections to less complicated JSDoc objects.
 *
 * @private
 */
class TypeConverter {
	/**
	 * @param {Map} moduleMap
	 */
	constructor( moduleMap ) {
		/**
		 * A map translating an identifier from Typedoc to a JsDoc doclet.
		 *
		 * @protected
		 * @member {Map.<Number, String>}
		 */
		this._moduleMap = moduleMap;
	}

	/**
	 * @param {AvailableTypes} type
	 * @returns {Object|Array.<String>|String|null}
	 */
	convert( type ) {
		// Sometimes the `type` property of the reflection does not contain the actual type of the reflection, but it could hold another
		// kind of information, like the `array` or `optional` values. In that case, the actual type is stored in the `elementType`
		// property. To handle this condition comprehensively, it is enough to always try to take the actual reflection from the
		// `elementType` property (if it exists).
		const typeReflection = type.elementType || type;
		const convertedType = this._convertSingleType( typeReflection );

		if ( type.type === 'array' ) {
			return this.convertArray( convertedType );
		}

		return convertedType;
	}

	/**
	 * @param {AvailableTypes|null} type
	 * @returns {string|Array}
	 */
	convertArray( type ) {
		if ( !type ) {
			return 'Array';
		}

		if ( type instanceof Set ) {
			return [ ...type ];
		}

		return [ type ];
	}

	/**
	 * Converts type parameters or class, interface, method or function to a format that can be rendered on API pages.
	 *
	 * The returned object contains a `value` property which must be an array.
	 * Thanks to that, it can be pass through the `type()` mixin.
	 *
	 * See: `themes/umberto/<theme name>/layout/_api-docs/_mixin/_type.pug`.
	 *
	 * @param {Array.<TypeParameterReflection>|null} parameters
	 * @returns {Array.<Object>|null}
	 */
	convertTypeParameters( parameters ) {
		if ( !Array.isArray( parameters ) ) {
			return null;
		}

		return parameters.map( parameter => {
			let description;

			if ( parameter.comment && parameter.comment.summary ) {
				description = TypedocConverter.convertComment( parameter.comment.summary );
			}

			// A type without a definition. Just a name.
			if ( !parameter.type ) {
				if ( parameter.default ) {
					const value = this.convert( parameter.default );

					return {
						name: parameter.name,
						value: [ value ],
						description
					};
				}

				return {
					name: parameter.name,
					description
				};
			}

			// A type parameter describing an argument of a function or a returned type or a reference.
			if ( typeof parameter.type === 'string' ) {
				return {
					name: this.convert( parameter ),
					description
				};
			}

			// A simple type....
			if ( parameter.type.type === 'intrinsic' ) {
				let value = parameter.type.name;

				// ...with a default value.
				if ( parameter.default ) {
					value += ` = ${ this.convertIntrinsicType( parameter.default ) }`;
				}

				return {
					name: parameter.name,
					isExtending: true,
					value: [ value ],
					description
				};
			}

			let type = this.convert( parameter.type );

			// To avoid complex type parameters (they are available in typings provided by TypeScript),
			// let's ignore unions.
			if ( type instanceof Set ) {
				return {
					name: parameter.name,
					description
				};
			}

			if ( parameter.default ) {
				type += ` = ${ this.convertIntrinsicType( parameter.default ) }`;
			}

			return {
				name: parameter.name,
				isExtending: true,
				value: [ type ],
				description
			};
		} );
	}

	/**
	 * @param {ReferenceType} reference
	 * @returns {String}
	 */
	convertReference( reference ) {
		if ( !reference.target ) {
			return reference.name;
		}

		const moduleName = this._moduleMap.get( reference.target );

		if ( moduleName ) {
			return moduleName;
		}

		return reference.name;
	}

	/**
	 * @param {AvailableTypes} parameter
	 * @returns {Object}
	 */
	convertIndexedAccess( parameter ) {
		const convertIndex = reflection => {
			if ( reflection.type === 'reference' ) {
				return this.convertReference( reflection );
			}

			return this.convertIntrinsicType( reflection );
		};

		return {
			type: 'generic',
			name: this._convertSingleType( parameter.objectType ),
			typeParameter: convertIndex( parameter.indexType )
		};
	}

	/**
	 * @param {LiteralOrIntrinsic} typeReflection
	 * @returns {String|null|*}
	 */
	convertIntrinsicType( typeReflection ) {
		if ( typeReflection.type === 'literal' ) {
			if ( typeof typeReflection.value === 'string' ) {
				return `'${ typeReflection.value }'`;
			}

			return String( typeReflection.value );
		}

		return typeReflection.name;
	}

	/**
	 * @param {Array.<UnionType>} types
	 * @returns {Set}
	 */
	convertUnion( types ) {
		const values = types.map( singleType => this.convert( singleType ) )
			.filter( Boolean );

		// Keep unique values only.
		return new Set( values );
	}

	/**
	 * Converts a single type reflection produced by Typedoc to a simpler JSDoc-structure that Umberto handles out-of-the-box.
	 *
	 * It returns:
	 *  * the `null` value when converting the `void` type,
	 *  * a `module:...` string if a type is a reference to other module,
	 *  * a string if given type is a template,
	 *  * an instance of the `Set` class if given type is an union,
	 *  * an object when converting predicates or inline types.
	 *
	 * When detecting an unsupported type, it prints a warning message on a console suggesting
	 * to report an issue and providing support for the missing structure.
	 *
	 * @protected
	 * @param {AvailableTypes} typeReflection
	 * @returns {Object|String|Set|null}
	 */
	_convertSingleType( typeReflection ) {
		// See the `AvailableTypes` type in the `typedoc.ts` file for checking the supported types.
		//
		// Results of this function is processed by Pug.
		// See: `themes/umberto/<theme name>/layout/_api-docs/_mixin/_type.pug`.
		//
		// When introducing support for more TypeScript constructions,
		// consider introducing a new structure that contains an opening symbol,
		// a separator, and a closing symbol to avoid `instanceof` checks in the view.
		//
		// Examples:
		//
		// * Array: `Array<Type1 | Type2>`
		// * Union: `Type1 | Type2 | Type3`
		// * Intersection: `... & ...`
		// * Tuple: `[ name1: Type1, name2: Type2 ]`
		//
		// TODO: When working on #1052, consider rewriting the function to always return an object
		// containing type arguments. Then, `TypedocConverter#_convertTypeToJsDoc()` should not process
		// them manually.
		if ( typeReflection.type === 'reference' ) {
			const reference = this.convertReference( typeReflection );

			if ( !typeReflection.typeArguments ) {
				return reference;
			}

			// An object representing a type containing type arguments.
			return {
				type: 'generic',
				name: reference,
				typeParameters: this.convertTypeParameters( typeReflection.typeArguments )
			};
		}

		if ( typeReflection.type === 'indexedAccess' ) {
			return this.convertIndexedAccess( typeReflection );
		}

		if ( typeReflection.type === 'literal' || typeReflection.type === 'intrinsic' ) {
			return this.convertIntrinsicType( typeReflection );
		}

		// An array can be a part of a union type. Example: `string | Array<string> | Array<Array<string>>`.
		// Hence, it needs to be parsed twice (`#convert()` and `#_convertSingleType()).
		// See: #1189.
		if ( typeReflection.type === 'array' ) {
			return this.convertArray( typeReflection );
		}

		// Inline type: an object or a function.
		if ( typeReflection.type === 'reflection' ) {
			if ( Array.isArray( typeReflection.declaration.signatures ) ) {
				const [ signature ] = typeReflection.declaration.signatures;
				const isClass = signature.kind === ReflectionKind.ConstructorSignature;

				const params = ( signature.parameters || [] )
					.map( singleType => ( {
						name: singleType.name,
						type: this.convert( singleType.type )
					} ) )
					.filter( Boolean );

				const returns = signature.type ? this.convert( signature.type ) : null;

				return {
					type: 'function',
					params,
					returns,
					isClass
				};
			}

			// The fields specified below describe an object.
			//
			// * `typeReflection.declaration.indexSignature` - a generic object,
			// * `Array.isArray( typeReflection.declaration.children )` - an objet with the exact fields.
			return 'object';
		}

		// A structure represented by an array.
		if ( typeReflection.type === 'tuple' ) {
			// It is an array with mixed types.
			// The position of these types matter.
			return 'tuple';
		}

		if ( typeReflection.type === 'namedTupleMember' ) {
			return this.convert( typeReflection.element );
		}

		// A structure that mixes two and more different types.
		if ( typeReflection.type === 'intersection' ) {
			return 'object';
		}

		if ( typeReflection.type === 'query' ) {
			return typeReflection.queryType.name;
		}

		// See: https://www.typescriptlang.org/docs/handbook/2/mapped-types.html.
		if ( typeReflection.type === 'mapped' ) {
			const type = [
				'{',
				'[',
				typeReflection.parameter,
				'in'
			];

			if ( typeReflection.parameterType.type === 'typeOperator' ) {
				type.push(
					typeReflection.parameterType.operator,
					this.convertReference( typeReflection.parameterType.target )
				);
			} else {
				type.push( typeReflection.parameterType.name );
			}

			type.push(
				']?:', // TODO: Perhaps we should check `typeReflection.optionalModifier`.
				typeReflection.templateType.name,
				'}'
			);

			return type.join( ' ' );
		}

		// A conditional type.
		// Perhaps it could be rendered as an union.
		// See: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html.
		if ( typeReflection.type === 'conditional' ) {
			return 'object';
		}

		// See: https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html.
		if ( typeReflection.type === 'templateLiteral' ) {
			const output = typeReflection.tail.reduce( ( output, [ type, suffix ] ) => {
				return output + '${ ' + this._convertSingleType( type ) + ' }' + suffix;
			}, typeReflection.head );

			return '`' + output + '`';
		}

		if ( typeReflection.type === 'typeOperator' ) {
			return {
				type: 'operator',
				operator: typeReflection.operator,
				values: this.convert( typeReflection.target )
			};
		}

		// See: https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html#unions.
		if ( typeReflection.type === 'union' ) {
			return this.convertUnion( typeReflection.types );
		}

		// See: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates.
		/* istanbul ignore else */
		if ( typeReflection.type === 'predicate' ) {
			let typesOrType = this.convert( typeReflection.targetType );

			if ( typesOrType instanceof Set ) {
				typesOrType = [ ...typesOrType ];
			}

			if ( !Array.isArray( typesOrType ) ) {
				// `typesOrType` can be an object representing a generic value.
				// TODO: Missing CC here.
				const value = typeof typesOrType === 'object' ? typesOrType.name : typesOrType;

				if ( value.toLowerCase() === 'object' ) {
					return 'boolean';
				}

				// Convert a single item into an array to avoid having mixed structures in a view.
				typesOrType = [ typesOrType ];
			}

			return {
				type: 'predicate',
				name: typeReflection.name,
				instances: typesOrType
			};
		}

		// Since typedoc@0.28, CKEditor 5 types in unions are represented as `unknown`.
		// We aim to display a pure string instead of the `unknown` value.
		// See: http://127.0.0.1:8080/ckeditor5/45.0.0/api/module_engine_conversion_conversion-Conversion.html#function-attributeToAttribute
		if ( typeReflection.type === 'unknown' ) {
			return {
				type: 'raw',
				value: this.convertIntrinsicType( typeReflection )
			};
		}

		// At this stage, the type cannot be converted. Perhaps, we missed something while developing
		// the conversion. Let's ask users to create a new issue containing the non-supported structure.
		//
		// Note: The function is created only to hack Istanbul when generating the CC.
		// There is no need for asserting it.
		/* istanbul ignore next */
		const displayMissingTypeAlert = () => {
			console.warn( 'Found a type definition not supported by Umberto yet.' );
			console.warn( 'Please, create a new issue: ' + ISSUE_URL );
			console.log( '' );
			console.warn( 'Ensure to include the following type definition in the issue to help us understand the structure.' );
			console.log( '' );
			console.log( '```json\n' + JSON.stringify( typeReflection, null, 2 ) + '\n```' );
		};

		/* istanbul ignore next */
		displayMissingTypeAlert();
	}
}

module.exports = TypedocConverter;

/**
 * @param {TypeConverter} converter
 * @returns {Function}
 */
function getHierarchyCallback( converter ) {
	return reference => {
		if ( reference.type === 'intersection' ) {
			return reference.types.map( ref => converter.convertReference( ref ) );
		}

		return converter.convertReference( reference );
	};
}

function mapIndexSignatureObject( subItem, index ) {
	return {
		...subItem,
		name: `${ subItem.name }[${ index }]`
	};
}
