/**
 * @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 fs = require( 'fs-extra' );
const cheerio = require( 'cheerio' );
const FileNameManager = require( './classes/file-name-manager' );
const HtmlFile = require( './classes/html-file' );
const DocDataFactory = require( './classes/doc-data-factory' );
const DescriptionParser = require( './classes/description-parser' );
const NavigationTree = require( './classes/navigation-tree' );
const capitalize = require( '../helpers/capitalize' );
const getShortModulePath = require( '../helpers/get-short-module-path' );
const getDocSearchConfig = require( '../helpers/get-docsearch-config' );
const splitLongname = require( '../helpers/split-longname' );
const githubUrlUtils = require( '../helpers/github-url' );
const getReportIssueWidgetUrl = require( '../../scripts/utils/getreportissuewidgeturl' );
const getPageGroupHelper = require( '../../src/hexo/helper/get-page-group' );
const findTargetDoclet = require( './utils/findtargetdoclet' );
const writeHtmlFiles = require( '../../src/tasks/write-html-files' );

const { randomId } = require( '../../scripts/utils/random-id' );
const spritesheetSvg = require( '../../scripts/utils/spritesheet-svg' );
const inlineSvg = require( '../../scripts/utils/inline-svg' );
const parseHref = require( '../../scripts/utils/parse-href' );
const hexoManager = require( '../hexo-manager' );

/**
 * Renders HTML for every module, class, mixin etc.
 *
 * @type {module.ApiBuilder}
 */
module.exports = class ApiBuilder {
	/**
	 * @param templateCollection
	 * @param dataCollection
	 * @param config
	 */
	constructor( templateCollection, dataCollection, config ) {
		this.buildInfo = {
			warningCount: 0
		};

		/**
		 * Collection of doclets additionally groped by 'memberof' property, kind, longname.
		 * @type {DataCollection}
		 */
		this._dataCollection = dataCollection;

		/**
		 * Stores urls associated with longnames.
		 * @type {FileNameManager}
		 */
		this._fileNameManager = new FileNameManager();

		/**
		 * Collection of pug templates.
		 * @type {TemplateCollection}
		 */
		this._tmplCol = templateCollection;

		/**
		 * Destination directory, e.g. build/docs/ckeditor5/latest.
		 * @type {String}
		 */
		this._destinationDir = config.destinationDir;

		/**
		 * Project name, e.g. CKEditor 5
		 * @type {String}
		 */
		this._projectName = config.projectConfig.name;

		/**
		 * Project slug, e.g. ckeditor5
		 * @type {String}
		 */
		this._projectSlug = config.projectConfig.slug;

		/**
		 * Group configs from umberto.json config. Groups are top level categories of the documentation.
		 * Example: API, Guides, Examples.
		 * @type {Object}
		 */
		this._groups = config.projectConfig.groups;

		/**
		 * Project base path, e.g. ckeditor5/latest.
		 * @type {String}
		 */
		this._BASE_PATH = config.BASE_PATH;

		/**
		 * Function providing import path which can be displayed under page's main heading.
		 * @type {Function}
		 */
		this._getRealImportPath = config.projectConfig.getRealImportPath;

		/**
		 * Algolia's docsearch config.
		 * @type {Object}
		 */
		this._docSearch = config.docSearch;

		/**
		 * Repository url, used to create 'Report issue/Contribute to guide' links.
		 * @type {String}
		 */
		this._defaultRepoUrl = config.projectConfig.repoUrl;

		/**
		 * umberto.json config.
		 * @type {Object}
		 */
		this._projectConfig = config.projectConfig;

		/**
		 * Global variables used to replacing macros in API docs.
		 */
		this.macrosVariables = config.macrosVariables;

		const descriptionParser = new DescriptionParser( this._fileNameManager, this._dataCollection, {
			macrosVariables: Object.assign( {}, config.projectConfig.variables, config.macrosVariables, config.projectConfig )
		} );

		/**
		 * Prepares doc data for various templates.
		 * @type {DocDataFactory}
		 */
		this._dataFactory = new DocDataFactory( this._dataCollection, descriptionParser, {
			projectConfig: config.projectConfig,
			longnameToUrl: this._fileNameManager.getUrl.bind( this._fileNameManager ),
			buildInfo: this.buildInfo
		} );

		/**
		 * Instance of a class building API navigation tree.
		 * @type {NavigationTree}
		 */
		this._navTree = new NavigationTree( {
			rootPath: this._projectSlug,
			longnameToUrl: this._fileNameManager.getUrl.bind( this._fileNameManager ),
			packagePaths: config.projectConfig.items || [],
			projectRootPath: config.projectConfig.projectRootPath,
			apiSrcDir: this._groups.find( g => g.id === 'api-reference' ).sourceDir,
			dataCollection: this._dataCollection
		} );

		this._navTreeHtml = '';
		this._filterHtml = '';

		/**
		 * Rendered HTML files.
		 * @type {Array.<HtmlFile>}
		 */
		this._renderedFiles = [];

		this._tmplCol.addHelper( 'longnameToUrl', this._fileNameManager.getUrl.bind( this._fileNameManager ) );
		this._tmplCol.addHelper( 'getRealImportPath', this._getRealImportPath );
		this._tmplCol.addHelper( 'getUrlAddressFromLongname', this._getUrlAddressFromLongname.bind( this ) );

		this._tmplCol.addHelper( 'uParseHref', ( str, data ) => parseHref( str, {
			path: data.path,
			projectName: data.projectName,
			projectGlobals: hexoManager.hexo.projectGlobals,
			BASE_PATH: data.BASE_PATH
		}, hexoManager.hexo, {
			relativeUrlHelper: hexoManager.hexo.extend.helper.store.relative_url,
			isSilentError: true
		} ) );

		this._tmplCol.addHelper( 'uRandomId', randomId );
		this._tmplCol.addHelper( 'uCapitalize', capitalize );

		this.canonicalUrlBeginning = config.canonicalUrlBeginning;
	}

	/**
	 * Main function of this class. Renders HTML for all API items.
	 * Also creates a JSON file, with entity names, used for additional API docs search.
	 */
	async buildApi() {
		const modulesData = this._dataCollection.get( 'module' ).getAll();
		const classesData = this._dataCollection.get( 'class' ).getAll();
		const interfacesData = this._dataCollection.get( 'interface' ).getAll();
		const typedefsData = this._dataCollection.get( 'typedef' ).getAll();
		const mixinsData = this._dataCollection.get( 'mixin' ).getAll();
		const namespacesData = this._dataCollection.get( 'namespace' ).getAll();
		const concated = [].concat(
			modulesData,
			classesData,
			interfacesData,
			typedefsData,
			mixinsData,
			namespacesData
		);

		// Register filenames in FilenameManager so that all names are available for building navigation tree.
		this._registerFilenames( concated );
		this._buildNavTree( concated );
		this._filterHtml = this._tmplCol.renderTemplate( '_partial/filter' );

		await this._getPages( modulesData, 'module' );
		await this._getPages( classesData, 'class' );
		await this._getPages( interfacesData, 'interface' );
		await this._getPages( typedefsData, 'typedef' );
		await this._getPages( mixinsData, 'mixin' );
		await this._getPages( namespacesData, 'namespace' );

		this._renderedFiles = [];

		// Build API json data for search engine.
		this._createApiSearchJson( [].concat(
			classesData,
			interfacesData,
			typedefsData,
			mixinsData,
			namespacesData,
			this._dataCollection.get( 'member' ).getAll(),
			this._dataCollection.get( 'function' ).getAll(),
			this._dataCollection.get( 'cfg' ).getAll(),
			this._dataCollection.get( 'event' ).getAll()
		) );

		return this.buildInfo;
	}

	/**
	 * Get rendered HtmlFiles.
	 * @returns {Array<HtmlFile>}
	 */
	getFiles() {
		if ( this._renderedFiles.length === 0 ) {
			console.error( 'There are no rendered files, you may want to call buildApi() first.' );
		}

		return this._renderedFiles;
	}

	/**
	 * Get HTML of API navigation tree.
	 * @returns {String}
	 */
	getNavTree() {
		return this._navTreeHtml;
	}

	/**
	 * Get HTML of API error codes.
	 * @returns {String}
	 */
	getErrors() {
		const errorData = this._dataFactory.getErrors();

		return this._tmplCol.renderTemplate( 'errors', { errorData } );
	}

	getDataCollection() {
		return this._dataCollection;
	}

	_createApiSearchJson( data ) {
		const apiConfig = this._projectConfig.groups.find( g => g.id === 'api-reference' );
		const apiSlug = apiConfig.slug;
		const apiSearchData = data.map( doclet => {
			let hash = '';
			let getFilenameBy = 'longname';
			const split = splitLongname( doclet.longname );
			const splitParts = [ split.packageName, ...split.directoryNames ];
			const extraParentInfo = splitParts.length > 0 ? splitParts.join( '/' ) : '';
			const id = doclet.extraId;
			let parent = doclet.name;

			if ( !id ) {
				return {};
			}

			if ( doclet.kind === 'member' || doclet.kind === 'function' || doclet.kind === 'cfg' || doclet.kind === 'event' ) {
				hash = `#${ id }`;
				getFilenameBy = 'memberof';
				parent = apiConfig.trimLongname === false ? doclet.memberof : splitLongname( doclet.memberof ).name;
			}

			const filename = this._fileNameManager.getFilename( doclet[ getFilenameBy ] );

			if ( !filename ) {
				return {};
			}

			const urlWithoutHash = upath.join( apiSlug, filename );
			const url = urlWithoutHash + hash;
			let customPriority;

			if ( this._projectConfig.docsearch && this._projectConfig.docsearch.customRanking ) {
				const customRanking = this._projectConfig.docsearch.customRanking.find( cr => url.includes( cr.url ) );
				customPriority = customRanking ? customRanking.rank : 0;
			}

			let preTitle = '';

			switch ( doclet.kind ) {
				case 'member':
					preTitle = 'Properties › ';
					break;

				case 'cfg':
					preTitle = 'Config options › ';
					break;

				case 'function':
					preTitle = 'Methods › ';
					break;

				case 'event':
					preTitle = 'Events › ';
					break;
			}

			return {
				name: doclet.kind === 'function' ? doclet.name + ' ()' : doclet.name,
				preTitle,
				shortName: splitLongname( doclet.longname ).name,
				kind: doclet.kind,
				access: doclet.access || 'public',
				url,
				isDerived: !!( doclet.inherited || doclet.mixed || doclet.overrides || false ),
				parent: `${ parent }${ extraParentInfo ? ' (' + extraParentInfo + ')' : '' }`,
				priority: customPriority
			};
		} );

		fs.outputFileSync( upath.resolve( 'build/docs', this._BASE_PATH, 'api.json' ), JSON.stringify( apiSearchData ) );
	}

	/**
	 * Registers longnames and their filenames. This is needed to create proper filenames and links.
	 *
	 * @private
	 * @param {Array} doclets
	 */
	_registerFilenames( doclets ) {
		for ( const d of doclets ) {
			this._fileNameManager.registerFilename( d.longname );
		}
	}

	_buildNavTree( doclets ) {
		for ( const d of doclets ) {
			const type = d.kind;

			switch ( type ) {
				case 'class':
					this._navTree.add( d, this._navTree.NODE_TYPES.CLASS );
					break;

				case 'interface':
					this._navTree.add( d, this._navTree.NODE_TYPES.INTERFACE );
					break;

				case 'typedef':
					this._navTree.add( d, this._navTree.NODE_TYPES.TYPEDEF );
					break;

				case 'mixin':
					this._navTree.add( d, this._navTree.NODE_TYPES.MIXIN );
					break;

				case 'namespace':
					this._navTree.add( d, this._navTree.NODE_TYPES.NAMESPACE );
					break;

				case 'module':
					this._navTree.add( d, this._navTree.NODE_TYPES.MODULE );
					break;
			}
		}

		this._navTreeHtml = spritesheetSvg( this._tmplCol.renderTemplate(
			'_partial/navtree',
			{
				projectLocals: {
					apiTree: this._navTree
				}
			}
		), false );
	}

	/**
	 * Renders HTML of each API docs page and creates an HtmlFile instance for that page.
	 */
	async _getPages( items, type ) {
		const views = [];

		for ( const i of items ) {
			const data = this._getPageData( i, type );
			const filename = this._fileNameManager.getFilename( i.longname );

			if ( filename ) {
				const filePath = upath.join( this._destinationDir, filename );
				const groups = this._groups.map( g => {
					g._url = upath.join( this._BASE_PATH, g.slug, 'index.html' );

					return g;
				} );

				const shortModulePath = getShortModulePath( data.longname );
				const split = splitLongname( data.longname );
				const splitParts = [ split.packageName, ...split.directoryNames ];
				const realImportPath = typeof this._getRealImportPath === 'function' ? this._getRealImportPath( shortModulePath ) : '';

				let title;

				if ( data.kind === 'module' ) {
					title = `${ capitalize( data.kind ) } ${ data.longname.replace( 'module:', '' ) }`;
				} else {
					title = `${ capitalize( data.kind ) } ${ capitalize( split.name ) }`;
				}

				const $ = data.description ? cheerio.load( data.description.content, null, false ) : cheerio.load( '', null, false );
				const html = $.root();
				html.find( 'pre' ).remove();
				const desc = html.text().trim().replace( /\n/g, ' ' ).replace( / +/g, ' ' );

				// eslint-disable-next-line @stylistic/max-len
				const metaDescription = `${ this._projectName } API Documentation. The ${ capitalize( data.kind ) } ${ data.kind === 'module' ? data.longname.replace( 'module:', '' ) : capitalize( split.name ) }.${ desc.length ? ' ' + desc : '' }`;

				// Page properties needed by pug templates. They match page properties of guides generated by hexo.
				const page = {
					path: filePath.slice( filePath.indexOf( this._BASE_PATH ) ),
					BASE_PATH: this._BASE_PATH,
					projectfullname: this._projectName,
					projectName: this._projectSlug,
					groupId: 'api-reference',
					groupSlug: this._groups.find( g => g.id === 'api-reference' ).slug,
					title,
					realImportPath,
					shortHiddenPath: splitParts.length > 0 ? splitParts.join( '/' ) : '',
					isApi: true,
					issuesUrl: githubUrlUtils.getIssueUrl( this._projectConfig ),
					startPage: this._projectConfig.startPage ? '/' + this._projectConfig.startPage : '/index.html',
					canonicalUrlBeginning: this.canonicalUrlBeginning,
					'meta-description': trimDescription( { min: 155, max: 300, text: metaDescription } ),
					'og-description': trimDescription( { min: 300, max: 600, text: metaDescription } ),
					'twitter-card': trimDescription( { min: 300, max: 600, text: metaDescription } ),
					'dc-description': trimDescription( { min: 300, max: 600, text: metaDescription } )
				};

				// This line needs to be sync with the `scripts/filter/before-post-render/add-project-info-to-page.js` file.
				page.reportIssueWidget = getReportIssueWidgetUrl( page, this._projectConfig );

				// More locals to be used in templates. Matching locals used by hexo.
				const projectLocals = {
					latestBasePath: upath.join( this._projectSlug, 'latest' ),
					groups,
					seeSourceRepositoryUrl: this._projectConfig.github !== undefined ? this._projectConfig.github.url : realImportPath,
					getLinkToSource: githubUrlUtils.getFullGithubLink.bind( this ),
					docSearchConfig: getDocSearchConfig( this._docSearch, {
						groups: this._groups,
						slug: this._projectSlug,
						customRanking: this._projectConfig.docsearch ? this._projectConfig.docsearch.customRanking : []
					} ),
					projectVersion: this._projectConfig.version,
					getPageGroup: getPageGroupHelper
				};

				const view = new HtmlFile(
					this._tmplCol.renderTemplate(
						type,
						{
							data,
							navTree: '%%_RENDERED_NAV_TREE_%%',
							filter: this._filterHtml,
							page,
							projectLocals,
							sitemap: this._projectConfig.sitemap,
							docSearchConfig: getDocSearchConfig( this._docSearch )
						}
					),
					filePath
				);

				let content = inlineSvg( view.content );

				content = spritesheetSvg( content );

				view.content = content.replaceAll( '%%_RENDERED_NAV_TREE_%%', this._navTreeHtml );

				await writeHtmlFiles( [ view ] );
			}
		}

		return views;

		/**
		 * Function trim text to the last sentence.
		 * If text is shorter than `min` then unmodified text is returned.
		 * If text is longer then tex is trimmed to `max` value, and there is searching of sentence ending within range of min and max.
		 * If such ending is found, then text is trimmed to finish on last sentence. Otherwise trimmed text to max value is returned
		 * (it might happen with some very long sentences.
		 *
		 * @param {Object} config - Configuration object
		 * @param {Number} config.min - Minimal amount of characters in trimmed text.
		 * @param {Number} config.max - Maximal amount of characters in trimmed text.
		 * @param {String} config.text - Text for trimming operation
		 *
		 * @return {String} Trimmed text
		 */
		function trimDescription( { min, max, text } ) {
			if ( text.length < min ) {
				return text;
			}

			const trimmedText = text.slice( 0, max );

			if ( trimmedText.endsWith( '.' ) ) {
				return trimmedText;
			}

			const trimmedArray = trimmedText.split( '. ' );
			trimmedArray.splice( -1, 1 );

			const trimmedToLastSentence = `${ trimmedArray.join( '. ' ) }.`;

			return trimmedToLastSentence.length < min ? trimmedText : trimmedToLastSentence;
		}
	}

	/**
	 * Process API data for current item (class, module, mixin etc).
	 * @param {Doclet} data Doclet of current item.
	 * @param {String} type Item's kind.
	 */
	_getPageData( data, type ) {
		switch ( type ) {
			case 'class':
				return this._dataFactory.getClassFull( data );

			case 'interface':
				return this._dataFactory.getInterfaceFull( data );

			case 'typedef':
				return this._dataFactory.getTypedefFull( data );

			case 'mixin':
				return this._dataFactory.getMixinFull( data );

			case 'namespace':
				return this._dataFactory.getNamespaceFull( data );

			case 'module':
				return this._dataFactory.getModule( data );
		}
	}

	/**
	 * @protected
	 * @param {LongNameParts} parts
	 * @returns {String|null}
	 */
	_getUrlAddressFromLongname( parts ) {
		if ( !parts.longnameBase ) {
			return null;
		}

		// Get a doclet to which we link.
		const targetDoclet = findTargetDoclet( this._dataCollection, {
			structure: parts.longnameBase,
			member: parts.name,
			label: parts.label,
			query: {
				memberOnly: true
			}
		} );

		if ( !targetDoclet ) {
			return null;
		}

		return [
			targetDoclet.memberof,
			encodeURI( targetDoclet.extraId )
		].join( parts.isStatic ? '.' : '#' );
	}
};
