/**
 * @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 hexoManager = require( '../hexo-manager' );
const getMainConfig = require( './get-main-config' );
const getProjectConfig = require( './get-project-config' );
const buildApi = require( './build-api-docs' );
const buildSdk = require( './build-sdk' );
const bindProjectLocals = require( '../hexo/filter/project-locals' );
const bindProjectGlobals = require( '../hexo/project-globals' );
const copyFiles = require( './copy-files' );
const copyProjectDocs = require( './copy-project-docs' );
const writeHtmlFiles = require( './write-html-files' );
const getExtraFiles = require( './get-extra-files' );
const createRedirectPage = require( './create-redirect-page' );
const cacheFiles = require( './cache-files' );
const overwriteApiGuides = require( './overwrite-api-guides' );
const cacheDir = upath.join( __dirname, '../../temp/cache' );
const validateLinks = require( './validate-links' );
const createSitemapStep = require( './create-sitemap-step' );
const buildSnippets = require( './build-snippets' );
const copyProjectIcons = require( './copy-project-icons' );
const executeHooks = require( './execute-hooks' );
const umbertoVersion = require( '../../package.json' ).version;

const { parseLinks } = require( '../../scripts/utils/parselinks' );
const getFilePatternsToProcess = require( '../helpers/get-file-patterns-to-process' );

/**
 * Main function building the documentation.
 * It has two main steps: building API docs and rendering guides (written in markdown) by hexo.
 * API docs are not created by hexo but instead are built by ApiBuilder class.
 * API docs are built from external data (JSON files generated by JSDoc or JSDuck) which is provided by every project.
 * Guides and articles are generated by hexo and their configuration relies on many hexo filters prepared for Umberto.
 * Hexo filters are responsible for example for outputting files to proper location, creating proper urls etc.
 * Before hexo generation, the guides are copied from project directories to hexo source directory,
 * so that hexo can run filters and generate documentation for all projects all at once.
 * This approach allows also creating links between different projects by using @link tags.
 * For more information check documentation of every filter.
 *
 * @param {Object} options.mainConfig umberto-main.json config file. Used to tell Umberto which projects it should build documentation for.
 * @param {String} options.rootPath Root directory of a project Umberto builds documentation for.
 * @param {Boolean} options.clean Umberto clears build directory by default, but when dev options==true,
 * clean option can be used to clear the build directory or not.
 * @param {Boolean} options.dev If true, it indicates a local build, skips minification, reuses already built files, allows to skip webpack.
 * @param {Boolean} options.skipApi Skip rendering API docs.
 * @param {Boolean} options.skipLiveSnippets Skip building live code snippets.
 * @param {Array.<String>|String} options.extraStyles Path/s to extra css.
 * @param {Array.<String>|String} options.extraScripts Path/s to extra js.
 * @param {Object} options.snippetOptions Additional options passed to snippetAdapter.
 * @param {Boolean} options.skipValidation Skip validating links.
 * @param {Boolean} options.skipGuides Skip processing guides. Useful for checking the layout changes.
 * @param {Boolean} options.verbose Provide more details during building documentation.
 * @param {Boolean} [options.watch] Flag which indicates that hexo should watch files.
 * @param {Array.<String>} [options.guides] An array containing names of guides that will be processed by Umberto. Useful when building
 * the documentation often to validate the visual aspect of a part of the documentation. All `index.md` files will be processed
 * automatically. An empty array (which is the default value) means that all guides should be processed.
 * @returns {Promise}
 */
module.exports = options => {
	const {
		mainConfig = getMainConfig( process.cwd() ),
		rootPath = process.cwd(),
		clean = !options.dev,
		dev = false,
		skipApi = false,
		skipLiveSnippets = false,
		extraStyles = [],
		extraScripts = [],
		snippetOptions = {},
		skipValidation = false,
		skipGuides = false,
		watch = false,
		verbose = false,
		guides = []
	} = options;

	const projectPaths = mainConfig.projects;

	hexoManager.init( { silent: !verbose } );

	return hexoManager.hexoInit()
		// Clear build directory if it's not a local build (dev==true) or it is a local build and clean==true.
		.then( () => clean ? hexoManager.clean() : Promise.resolve() )
		// Clear hexo source directory.
		// Hexo source directory is where all markdown files and assets land and are used by hexo to render pages.
		// It's possible to not delete them with every build to reuse files from previous builds.
		.then( () => clean || !skipLiveSnippets ? hexoManager.emptySourceDir() : Promise.resolve() )
		// Copies common files for multiple projects scenario, e.g.: robots.txt,
		// landing page (written in markdown like other guides), assets common for all projects etc.
		.then( () => {
			if ( mainConfig.commonFilesPath ) {
				return copyFiles( mainConfig.commonFilesPath, hexoManager.getSourceDir() );
			} else {
				return Promise.resolve();
			}
		} )
		// Add common options to hexo so that these options can be used by hexo filters.
		.then( () => {
			// Accept undefined, string or array, and convert all those cases to array form.
			function getIgnoredProjects() {
				if ( mainConfig.ignoredProjects === undefined ) {
					return [];
				}

				return typeof mainConfig.ignoredProjects === 'string' ?
					[ mainConfig.ignoredProjects ] :
					mainConfig.ignoredProjects;
			}

			return hexoManager.addCommonOptions( {
				skipLiveSnippets,
				snippetOptions,
				projectPaths,
				verbose,
				mainName: mainConfig.name,
				mainLogo: mainConfig.logo,
				isSingleProject: mainConfig.isSingleProject,
				canonicalUrlBeginning: mainConfig.canonicalUrlBeginning,
				ignoredProjects: getIgnoredProjects(),
				variables: mainConfig.variables
			} );
		} )
		.then( async () => {
			const configs = await getProjectConfigs( rootPath, projectPaths );

			return executeHooks( configs, 'beforeHexo' );
		} )
		.then( () => {
			return buildProjects( rootPath, projectPaths, {
				skipApi,
				skipLiveSnippets,
				dev,
				docSearch: mainConfig.docsearch,
				disableSearch: mainConfig.docsearch === false,
				googleoptimize: mainConfig.googleoptimize,
				googletagmanager: mainConfig.googletagmanager,
				googleanalytics: mainConfig.googleanalytics,
				promobar: mainConfig.promobar,
				sitemap: mainConfig.sitemap,
				vwo: mainConfig.vwo,
				feedbackWidget: mainConfig.feedbackWidget,
				extraStyles,
				extraScripts,
				og: mainConfig.og,
				additionalDocumentation: mainConfig.additionalDocumentation,
				canonicalUrlBeginning: mainConfig.canonicalUrlBeginning,
				macrosVariables: mainConfig.variables,
				guides,
				skipGuides
			} );
		} )
		.then( () => hexoManager.generate( {
			watch,
			concurrency: 50
		} ) )
		.then( () => buildSnippets( hexoManager.hexo.projectGlobals ) )
		.then( () => copyProjectIcons( hexoManager.hexo.projectGlobals, hexoManager.getPublicDir() ) )
		// A workaround for API guides when API generation is skipped.
		// Used for a local build (dev==true) to reuse existing API doc files.
		.then( async () => {
			if ( !dev ) {
				return Promise.resolve();
			}

			const projectConfigs = await getProjectConfigs( rootPath, projectPaths, {
				skipLiveSnippets
			} );
			const buildDir = hexoManager.getPublicDir();

			if ( !skipApi ) {
				// Cache API guides to reuse later if API generation was skipped.
				for ( const config of projectConfigs ) {
					const apiConfig = Array.isArray( config.groups ) && config.groups.find( g => g.id === 'api-reference' );
					const apiSlug = apiConfig ? apiConfig.slug : 'api';

					cacheFiles(
						upath.join( buildDir, config.slug, config.version, apiSlug ),
						upath.join( cacheDir, config.slug, config.version, apiSlug )
					);
				}
			} else {
				// Use cached API guides because Hexo generates them wrong if API generation was skipped.
				for ( const config of projectConfigs ) {
					const apiConfig = Array.isArray( config.groups ) && config.groups.find( g => g.id === 'api-reference' );
					const apiSlug = apiConfig ? apiConfig.slug : 'api';

					overwriteApiGuides(
						upath.join( cacheDir, config.slug, config.version, apiSlug ),
						upath.join( buildDir, config.slug, config.version, apiSlug )
					);
				}
			}

			return Promise.resolve();
		} )
		// Links validation.
		.then( async () => {
			const projectConfigs = await getProjectConfigs( rootPath, projectPaths, {
				skipLiveSnippets
			} );

			if ( skipValidation ) {
				return Promise.resolve();
			}

			// Do not verify links in the documentation if it is being built partially.
			if ( guides.length || options.skipGuides ) {
				return Promise.resolve();
			}

			return validateLinks( 'build/docs', {
				projectsInfo: projectConfigs.map( c => {
					return {
						slug: c.slug,
						version: c.version,
						basePath: upath.join( c.slug, c.version )
					};
				} ),
				skipApi,
				publicDir: hexoManager.getPublicDir()
			} );
		} )
		.then( async () => {
			const projectConfigs = await getProjectConfigs( rootPath, projectPaths );

			return createSitemapStep( { projectConfigs, verbose, mainConfig } );
		} )
		.then( async () => {
			const configs = await getProjectConfigs( rootPath, projectPaths );

			return executeHooks( configs, 'afterHexo' );
		} )
		.then( () => hexoManager );
};

/**
 * Renders API pages. Prepares data needed by hexo to generate whole documentation.
 *
 * @param {String} rootPath
 * @param {Array.<String>} projectPaths
 * @param {Object} [options={}]
 * @param {Array.<String>} options.guides An array containing names of guides that will be processed by Umberto. Useful when building
 * the documentation often to validate the visual aspect of a part of the documentation. All `index.md` files will be processed
 * automatically. An empty array (which is the default value) means that all guides should be processed.
 * @param {Boolean} options.skipGuides Whether to skip processing guides.
 * @return {Promise}
 */
async function buildProjects( rootPath, projectPaths, options = {} ) {
	const projectConfigs = await getProjectConfigs( rootPath, projectPaths, options );
	const promises = [];

	// If the `guides` (non-empty array) or `skipGuides` options are specified, disable the `{@link...}` validator.
	if ( options.guides.length || options.skipGuides ) {
		hexoManager.hexo.projectGlobals.common._disabledFilters.add( parseLinks.name );
	}

	if ( !options.skipApi ) {
		promises.push(
			buildApis( projectConfigs, {
				docSearch: options.docSearch,
				extraStyles: options.extraStyles,
				extraScripts: options.extraScripts,
				disableSearch: options.disableSearch,
				googleoptimize: options.googleoptimize,
				googletagmanager: options.googletagmanager,
				googleanalytics: options.googleanalytics,
				promobar: options.promobar,
				sitemap: options.sitemap,
				vwo: options.vwo,
				feedbackWidget: options.feedbackWidget,
				og: options.og,
				canonicalUrlBeginning: options.canonicalUrlBeginning,
				macrosVariables: options.macrosVariables
			} )
		);
	}

	if ( !options.skipSdk ) {
		promises.push( buildSdks( projectConfigs, {
			dev: options.dev,
			docSearch: options.docSearch,
			extraStyles: options.extraStyles,
			extraScripts: options.extraScripts,
			disableSearch: options.disableSearch,
			googleoptimize: options.googleoptimize,
			googletagmanager: options.googletagmanager,
			googleanalytics: options.googleanalytics,
			promobar: options.promobar,
			sitemap: options.sitemap,
			vwo: options.vwo,
			feedbackWidget: options.feedbackWidget,
			og: options.og,
			canonicalUrlBeginning: options.canonicalUrlBeginning,
			macrosVariables: options.macrosVariables
		} ) );
	}

	const guidesToProcess = getFilePatternsToProcess( {
		guides: options.guides,
		skipGuides: options.skipGuides
	} );

	if ( !projectConfigs.length ) {
		hexoManager.hexo.extend.filter.register( 'template_locals', locals => {
			// Most probably this if is always true. Locals same for all projects.
			if ( !locals.projectsData ) {
				locals.projectsData = [];
			}
			locals.umbertoVersion = umbertoVersion;
			locals.pathJoin = upath.join;
		} );
	}

	projectConfigs.forEach( config => {
		const basePath = upath.join( config.slug, config.version );
		const extraStyles = getExtraFiles(
			upath.join( config.projectRootPath, config.path ),
			upath.join( config.slug, config.version ),
			config.extraStyles
		);

		const extraScripts = getExtraFiles(
			upath.join( config.projectRootPath, config.path ),
			upath.join( config.slug, config.version ),
			config.extraScripts
		);

		// Add needed data to be accessible by hexo.
		bindProjectGlobals( hexoManager.hexo, {
			rootPath: config.projectRootPath,
			basePath,
			config,
			errorsHtml: config.errorsHtml
		} );
		// Add needed data to be accessible in hexo templates.
		bindProjectLocals( hexoManager.hexo, {
			basePath,
			docSearch: options.docSearch,
			config,
			googleoptimize: options.googleoptimize,
			googletagmanager: options.googletagmanager,
			googleanalytics: options.googleanalytics,
			promobar: options.promobar,
			vwo: options.vwo,
			feedbackWidget: options.feedbackWidget,
			extraStylePaths: extraStyles,
			extraScriptsPaths: extraScripts,
			disableSearch: options.disableSearch,
			og: options.og,
			// Force displaying empty categories in the navigation tree when the documentation is being built partially.
			navigationShowEmptyCategories: Array.isArray( guidesToProcess )
		} );

		// Documentation sources (e.g. markdown guides) of each project need to be copied to hexo source directory.
		const projectPath = projectPaths.find( el => config.projectRootPath.includes( el ) );

		promises.push(
			copyProjectDocs( config.projectRootPath, config, {
				match: guidesToProcess,
				globalRootPath: rootPath,
				additionalDocumentation: options.additionalDocumentation ? options.additionalDocumentation[ projectPath ] : null
			} )
		);

		// Redirection page for urls like https://docs.ckeditor.com/ckeditor5/.
		// In this case it redirects to https://docs.ckeditor.com/ckeditor5/latest/.
		promises.push( createRedirectPage( {
			destination: upath.join( 'build', 'docs', config.slug, 'index.html' ),
			title: `${ config.name } Redirection Page`,
			url: options.dev ? `${ config.version }/` : 'latest/'
		} ) );
	} );

	return Promise.all( promises );
}

// Gets every project's umberto.json config file.
async function getProjectConfigs( rootPath, projectPaths, options = {} ) {
	const promises = projectPaths.map( async pPath => {
		const projectRootPath = upath.join( rootPath, pPath );

		return Object.assign(
			{},
			{
				...await getProjectConfig( projectRootPath, {
					skipLiveSnippets: options.skipLiveSnippets
				} ),
				projectRootPath
			}
		);
	} );

	return Promise.all( promises );
}

// Renders API docs HTML. API docs are rendered separate from hexo so hexo filters and helpers are not available.
async function buildApis( projectConfigs, options = {} ) {
	// Basic project info like name and BASE_PATH required by templates.
	const basicProjectsData = getBasicProjectsData( projectConfigs );
	const promises = [];
	const outputDir = upath.relative( process.cwd(), hexoManager.getPublicDir() );

	for ( const config of projectConfigs ) {
		// If umberto.json doesn't have a config for API docs, we can skip here.
		if ( !config.groups || !config.groups.find( group => group.id === 'api-reference' ) ) {
			continue;
		}

		const apiConfig = config.groups.find( group => group.id === 'api-reference' );
		const BASE_PATH = upath.join( config.slug, config.version );
		const extraStyles = getExtraFiles(
			upath.join( config.projectRootPath, config.path ),
			upath.join( config.slug, config.version ),
			config.extraStyles
		);
		const extraScripts = getExtraFiles(
			upath.join( config.projectRootPath, config.path ),
			upath.join( config.slug, config.version ),
			config.extraScripts
		);

		if ( !apiConfig.sourceDir || !apiConfig.type ) {
			throw new Error( `Not enough api-reference data in umberto.json, project: ${ config.name }` );
		}

		const apiDocsPromise = buildApi( {
			src: upath.join( config.projectRootPath, config.path, apiConfig.sourceDir ),
			projectDst: upath.join( BASE_PATH, apiConfig.slug ),
			outputDir,
			themePath: hexoManager.getThemePath(),
			BASE_PATH,
			docSearch: options.docSearch,
			macrosVariables: options.macrosVariables,
			templateLocals: {
				projectsData: basicProjectsData,
				extraStylePaths: options.extraStyles,
				extraScriptsPaths: options.extraScripts,
				sitemap: config.sitemap || options.sitemap,
				umbertoVersion,
				projectLocals: {
					extraStylePaths: extraStyles,
					extraScriptsPaths: extraScripts,
					disableSearch: config.docsearch === false,
					repoUrl: config.repoUrl,
					og: config.og,
					projectSlug: config.slug,
					apiType: apiConfig.type
				},
				disableSearch: options.disableSearch,
				googleoptimize: options.googleoptimize,
				googletagmanager: options.googletagmanager,
				googleanalytics: options.googleanalytics,
				promobar: options.promobar,
				vwo: options.vwo,
				feedbackWidget: options.feedbackWidget,
				pathJoin: upath.join,
				mainOg: options.og || {},
				isLegacy: false,
				isNightly: options.docSearch?.indexName?.includes( 'nightly' ) || false
			},
			projectConfig: config,
			canonicalUrlBeginning: getCanonicalBeginning( { config, options, hexoManager } )
		} ).then( apiDocs => {
			// API docs navigation tree is rendered once and reused as HTML.
			config.navTree = apiDocs.getNavTree();
			// API errors documentation is stored here so that it's available in guides (and hexo filters).
			config.errorsHtml = apiDocs.getErrors();
			hexoManager.addProjectGlobalData( config.slug, 'doclets', apiDocs.getDataCollection() );

			bindProjectGlobals( hexoManager.hexo, {
				rootPath: config.projectRootPath,
				basePath: BASE_PATH,
				config,
				errorsHtml: config.errorsHtml
			} );
		} );

		promises.push( apiDocsPromise );
	}

	return Promise.all( promises );
}

function buildSdks( projectConfigs, options = {} ) {
	const promises = [];
	const basicProjectsData = getBasicProjectsData( projectConfigs );
	const outputDir = upath.relative( process.cwd(), hexoManager.getPublicDir() );

	projectConfigs.forEach( config => {
		const sdkGroup = config.groups && config.groups.find( group => group.id === 'sdk' );

		if ( !sdkGroup ) {
			return;
		}

		const BASE_PATH = upath.join( config.slug, config.version );
		const extraStyles = getExtraFiles(
			upath.join( config.projectRootPath, config.path ),
			upath.join( config.slug, config.version ),
			config.extraStyles
		);
		const extraScripts = getExtraFiles(
			upath.join( config.projectRootPath, config.path ),
			upath.join( config.slug, config.version ),
			config.extraScripts
		);

		const sdk = buildSdk( {
			outputDir,
			srcPath: sdkGroup.sourceDir,
			projectRootPath: config.projectRootPath,
			projectPath: config.path,
			BASE_PATH,
			docSearch: options.docSearch,
			themePath: hexoManager.getThemePath(),
			macrosVariables: Object.assign( {}, config.variables, options.macrosVariables ),
			templateLocals: {
				umbertoVersion,
				extraStylePaths: options.extraStyles,
				extraScriptsPaths: options.extraScripts,
				sitemap: config.sitemap || options.sitemap,
				projectLocals: {
					disableSearch: config.docsearch === false,
					extraStylePaths: extraStyles,
					extraScriptsPaths: extraScripts
				},
				disableSearch: options.disableSearch,
				projectsData: basicProjectsData,
				googleoptimize: options.googleoptimize,
				googletagmanager: options.googletagmanager,
				googleanalytics: options.googleanalytics,
				promobar: options.promobar,
				vwo: options.vwo,
				feedbackWidget: options.feedbackWidget,
				pathJoin: upath.join,
				mainOg: options.og || {}
			},
			projectConfig: config,
			canonicalUrlBeginning: getCanonicalBeginning( { config, options, hexoManager } )
		} );

		promises.push(
			writeHtmlFiles( sdk.getFiles() )
		);

		// Copy assets
		copyFiles(
			upath.join( config.projectRootPath, config.path, sdkGroup.sourceDir, 'assets' ),
			upath.join( '.', outputDir, BASE_PATH, sdkGroup.slug, 'assets' )
		);

		// Copy CKEditor from presets
		copyFiles(
			upath.join( config.projectRootPath, config.path, sdkGroup.sourceDir, 'vendors' ),
			upath.join( '.', outputDir, 'vendors', config.version )
		);

		config.sdkNavTree = sdk.navigationHtml;
	} );

	return Promise.all( promises );
}

function getBasicProjectsData( projectConfigs ) {
	return projectConfigs.map( config => {
		return {
			name: config.name,
			slug: config.slug,
			BASE_PATH: upath.join( config.slug, config.version ),
			latestBasePath: upath.join( config.slug, 'latest' ),
			startPage: config.startPage ? '/' + config.startPage : '/index.html'
		};
	} );
}

function getCanonicalBeginning( { options, config, hexoManager } ) {
	return (
		hexoManager.hexo.projectGlobals.common.isSingleProject ? config.canonicalUrlBeginning : options.canonicalUrlBeginning
	) || 'https://ckeditor.com/docs/';
}
