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

'use strict';

const { styleText } = require( 'util' );
const cheerio = require( 'cheerio' );
const splitLongname = require( '../../helpers/split-longname' );
const macroReplacer = require( '../../tasks/macro-replacer' );
const findTargetDoclet = require( '../utils/findtargetdoclet' );
const { hasDedicatedApiPages, LONG_NAME_LABEL_REGEXP, LONG_NAME_MEMBER_SEPARATOR_REGEXP } = require( '../utils/utils' );
const logCrossProjectReference = require( '../../../scripts/utils/logcrossprojectreference' );
const createPrerenderPugTemplate = require( '../../../scripts/utils/pug-renderer/create-prerender-pug-template' );

const renderCodeBlockPug = createPrerenderPugTemplate( {
	requires: [
		'_components/svg/index',
		'_components/icon/index',
		'_components/tooltip-popover/index',
		'_components/skeleton/index',
		'_components/code-block/index'
	],
	component: 'code-block',
	componentAttrs: {
		static: {
			copyable: true
		},
		dynamic: [ 'copyable', 'language' ],
		mask: [ 'code' ]
	}
} );

/**
 * Class responsible for parsing doc descriptions.
 * @type {DescriptionParser}
 */
module.exports = class DescriptionParser {
	constructor( fileNameManager, dataCollection, { macrosVariables } = {} ) {
		this._fileNameManager = fileNameManager;
		this._dataCollection = dataCollection;
		this._macrosVariables = macrosVariables;
	}

	parseDescription( str, options = {} ) {
		if ( typeof str !== 'string' ) {
			return;
		}

		const theme = {
			theme: this._macrosVariables.slug === 'ckeditor4' ? 'umberto' : 'gloria'
		};

		Object.assign( options, theme );

		return composeFunctions(
			this._cleanTabs,
			this._changeHeaders,
			this._parseJSDocLinks.bind( this ),
			this._parseLinksToGuides,
			this._macrosReplacer.bind( this ),
			this._splitToExcerptAndContent
		)( str, options );
	}

	/**
	 * Splits entity's description into an excerpt and full description, if needed.
	 * @param {String} str Original description text.
	 * @param {Boolean} withExcerpt Whether to create excerpt or not.
	 * @returns {Object}.excerpt Short description.
	 * @returns {Object}.content Full description.
	 */
	_splitToExcerptAndContent( str, { withExcerpt = false } = {} ) {
		if ( withExcerpt ) {
			if ( !str.startsWith( '<p>' ) ) {
				return {
					excerpt: '',
					content: str
				};
			}

			const $ = cheerio.load( str, null, false );
			const excerpt = $( 'p' ).first().remove().html();
			const content = $.html();

			return {
				excerpt,
				content
			};
		} else {
			return {
				content: str
			};
		}
	}

	/**
	 * Changes {@glink} tags into actual HTML links.
	 * These tags are meant to be used in source code as links to guides.
	 * @param str
	 * @returns {String}
	 */
	_parseLinksToGuides( str ) {
		const guideLinkRegExp = /{@glink\s+[^}]+}/g;
		let result = str;

		if ( str.indexOf( '@glink' ) === -1 ) {
			return result;
		}

		let match;

		while ( ( match = guideLinkRegExp.exec( str ) ) !== null ) {
			const detailedRegExp = /@glink\s+(?:@(\w+)\s+)?([^}\s]+)\s*([^}]*)}/;
			const detailedMatch = detailedRegExp.exec( match[ 0 ] );
			const projectSlug = detailedMatch[ 1 ];

			// Cross project references are not supported.
			if ( projectSlug ) {
				logCrossProjectReference( { expression: match[ 0 ] } );

				continue;
			}

			const pathToGuideSplit = detailedMatch[ 2 ].split( '#' );
			const pathToGuide = pathToGuideSplit[ 0 ];
			const hash = pathToGuideSplit.length > 1 ? `#${ pathToGuideSplit[ 1 ] }` : '';
			const linkText = detailedMatch[ 3 ] ? detailedMatch[ 3 ] : pathToGuide;
			const href = `../${ pathToGuide }.html`;

			result = result.replace( match[ 0 ], `<a href=${ href }${ hash } data-glink>${ linkText }</a>` );
		}

		return result;
	}

	/**
	 * Finds all {@link} and {@see} tags and converts them to links if possible. If not, converts to text.
	 *
	 * @param {String} str String to be parsed.
	 * @param {Object} options
	 * @param {Doclet} options.doclet Parent doclet of currently processed item. Used to handle shorthand links.
	 * @param {Doclet} options.parentDoclet Parent of the parent. Used to handle shorthand links.
	 * @param {Boolean} options.asMember Whether the processed description is a child of the doclet.
	 * @returns {String} result Parsed string.
	 */
	_parseJSDocLinks( str, options = {} ) {
		const regex = /{(?:@link|@see)\s+([^{]+?(?:{[^}]+})?([^{}]*\\?))}/g;

		if ( str.indexOf( '@link' ) === -1 && str.indexOf( '@see' ) === -1 ) {
			return str;
		}

		let result = str;
		let match = regex.exec( str );

		while ( match !== null ) {
			const [ fullMatch ] = match;
			let [ , longname, linkText ] = match[ 1 ].match( /^(\S+)\s*([\s\S]*)$/ );
			let { longnameBase, name, label } = splitLongname( longname );
			let structure, module, member, query;

			if ( !options.asMember && ( longnameBase.startsWith( '~' ) || longname.startsWith( '~' ) || longname.startsWith( '#' ) ) ) {
				member = name;
				query = { skipModule: true };
				structure = options.parentDoclet ? options.parentDoclet.longname : options.doclet.memberof;

				// Shorthand: `#someMethod`.
				// TODO: Why `#` only? Can it start with `.` or `~`?
				if ( longname.startsWith( '#' ) ) {
					member = longname.slice( 1 ).replace( LONG_NAME_LABEL_REGEXP, '' );

					// It is needed due to the possible parsing of invalid links within the `@error` tag.
					if ( options.doclet.longname ) {
						structure = options.doclet.longname.split( LONG_NAME_MEMBER_SEPARATOR_REGEXP )[ 0 ];
					}
				}
				// Shorthand: `~SomeClass#someMethod`.
				// Use the doclet full name as a reference to the structure.
				else if ( longname.startsWith( '~' ) ) {
					if ( options.doclet.longname ) {
						structure = options.doclet.longname.split( '~' )[ 0 ];

						// When looking for a different structure within the same module, include its name to search for proper doclets.
						if ( longnameBase ) {
							structure += `~${ longnameBase }`;
						}
					} else if ( options.parentDoclet && options.parentDoclet.longname ) {
						structure = options.parentDoclet.longname.split( '~' )[ 0 ];
						member = longname.slice( 1 );
					}
				}
			} else {
				// Normalize the specified link expression when linking within the module or definition (class, interface, etc.) scope.
				if ( options.asMember && ( longnameBase.startsWith( '~' ) || longname.startsWith( '~' ) || longname.startsWith( '#' ) ) ) {
					if ( longname.startsWith( '#' ) ) {
						longname = options.doclet.longname + longname;
					} else {
						longname = options.doclet.memberof + longname;
					}

					const split = splitLongname( longname );
					longnameBase = split.longnameBase;
					name = split.name;
				}

				module = longname;
				structure = longnameBase;
				member = name;
			}

			let targetDoclet = findTargetDoclet( this._dataCollection, { module, structure, member, label, query } );
			let replaceToPhrase;

			if ( !targetDoclet && options.parentDoclet ) {
				const augmentsNested = [
					// If looking for a property, check if it is defined in a derived class, e.g.,
					//
					// ```ts
					// class A {
					// 	public foo: any;
					//
					// 	/**
					// 	 * See {@link ~A#foo}.
					// 	 */
					// 	public getFoo(): void;
					// }
					// class B extends A {
					// }
					// ```
					//
					// `B` contains the property, so from its perspective, the link is valid.
					// Let's try to parse it as it would be specified as `~B#foo`.
					options.parentDoclet.longname,

					// When looking for a structure withing the same module, check parent classes, e.g.,
					//
					// ```ts
					// class A {
					// 	/**
					// 	 * See {@link ~A}.
					// 	 */
					// 	public getFoo(): void;
					// }
					// class B extends A {
					// }
					// ```
					//
					// `~A` seeing from a module containing the `B` class does not make sense.
					// Let's try to find the proper doclet in parent classes.
					...( options.parentDoclet.augmentsNested || [] )
				];

				targetDoclet = augmentsNested
					.map( augmentName => {
						const splitResult = splitLongname( augmentName );

						let structure = [
							`module:${ splitResult.packageName }`,
							...splitResult.directoryNames,
							splitResult.moduleName
						].join( '/' );

						// When `name` and `className` are equal, we look for a `member` in the `${ structure }~${ className }` doclet.
						if ( splitResult.name === splitResult.className ) {
							structure += `~${ splitResult.className }`;
						}

						return findTargetDoclet( this._dataCollection, {
							structure,
							member,
							label,
							query
						} );
					} )
					.find( Boolean );
			}

			if ( targetDoclet ) {
				let href;

				if ( hasDedicatedApiPages( targetDoclet ) ) {
					href = this._fileNameManager.getUrl( targetDoclet.longname );

					// Properties of `type` do not have own doclets. Instead, they are specified as `properties` in the `type`
					// (parent) doclet. Hence, we must link to it manually. See: #1182.
					if ( this._hasMember( targetDoclet, member ) ) {
						href += `#member-${ member }`;
					}
				} else {
					href = [
						this._fileNameManager.getUrl( targetDoclet.memberof ),
						encodeURI( targetDoclet.extraId )
					].join( '#' );
				}

				if ( !linkText[ 3 ] && targetDoclet.kind === 'event' ) {
					linkText = `event-${ targetDoclet.name }`;
				}

				let linkContent = linkText;

				if ( !linkText ) {
					linkContent = `<code>${ splitLongname( longname.replace( 'event:', 'event-' ) ).name }</code>`;
				}

				replaceToPhrase = `<a href="${ href }">${ linkContent }</a>`;
			} else {
				console.log(
					`${ styleText( 'yellow', 'Warning:' ) } Invalid link in API docs: ${ styleText( 'gray', fullMatch ) }.`,
					'There is no doclet of the link target.'
				);

				options.buildInfo.warningCount += 1;

				if ( linkText ) {
					replaceToPhrase = `<code>${ linkText } (${ longname })</code>`;
				} else {
					replaceToPhrase = `<code>${ longname }</code>`;
				}
			}

			result = result.replace( fullMatch, replaceToPhrase );
			match = regex.exec( str );
		}

		return result;
	}

	/**
	 * @protected
	 * @param {Object} typedef
	 * @param {String} typedef.kind
	 * @param {Array.<Object>} [typedef.properties]
	 * @param {String} propertyName
	 * @returns {Boolean}
	 */
	_hasMember( typedef, propertyName ) {
		if ( typedef.kind !== 'typedef' ) {
			return false;
		}

		if ( !Array.isArray( typedef.properties ) ) {
			return false;
		}

		return !!typedef.properties.find( item => item.name === propertyName );
	}

	/**
	 * Changes headings in description to smaller. Allowed headings are h3-h6.
	 * If in source code commments h1, h2 heading are used, they need to be lowered.
	 * @param {String} data Html to be changed.
	 * @param {Number} decreaseLevel By how much headings should be lowered.
	 * @returns {String}
	 */
	_changeHeaders( data, { decreaseLevel = 3 } = {} ) {
		// Check for a case where description doesn't contain any heading.
		if ( data.indexOf( '<h' ) === -1 ) {
			return data;
		}

		const $ = cheerio.load( data, null, false );

		$( ':header' ).replaceWith( ( i, item ) => {
			const headerLevel = Number( item.name.slice( -1 ) );
			const newHeaderLevel = headerLevel + decreaseLevel <= 6 ? headerLevel + decreaseLevel : 6;

			return cheerio.load( `<h${ newHeaderLevel }></h${ newHeaderLevel }>` )( `h${ newHeaderLevel }` ).append( $( item ).html() );
		} );

		return $.html();
	}

	/**
	 * Fixes an issue where additional tabs are created by JSDoc.
	 * @param str
	 * @returns {String}
	 */
	_cleanTabs( str, options = {} ) {
		if ( str.indexOf( '<pre' ) === -1 ) {
			return str;
		}

		const $ = cheerio.load( str, null, false );
		const theme = options.theme;

		if ( theme === 'gloria' ) {
			const $code = $( 'pre code' );

			if ( !$code.length ) {
				return null;
			}

			// Get the language from the class (e.g., "language-js" -> "js")
			const language = $code.attr( 'class' ) ?
				$code.attr( 'class' ).replace( 'doc', '' ).replace( 'language-', '' ).replace( 'ts', 'typescript' ).trim() :
				null;

			// Get the code content
			const code = $code.html();

			// Use the render-pug-component utility
			const html = renderCodeBlockPug( {
				language,
				code
			} );

			$( this ).html( html );
			$( 'pre' ).replaceWith( html );
		} else {
			$( 'pre[class~="source"]' ).each( function() {
				let code = $( this ).html();
				const begin = /<code> {4}/.exec( code );

				if ( begin ) {
					code = code.replace( /(?:<code>|\n) {4}/g, match => {
						return match.replace( '    ', '' );
					} );
				}

				$( this ).html( code );
			} );
		}

		return $.html();
	}

	_macrosReplacer( fullText ) {
		return macroReplacer( { fullText, variables: this._macrosVariables, pagePath: 'unknown' } );
	}
};

function composeFunctions( ...fns ) {
	return ( result, options ) => {
		for ( const fn of fns ) {
			result = fn( result, options );
		}

		return result;
	};
}
