/**
 * @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' );
const { globSync } = require( 'glob' );
const importModule = require( '../helpers/import-module' );

const cache = new Map();

/**
 * Reads project's umberto.json config, validates it and adds various properties.
 *
 * @param {String} rootPath
 * @param {Object} [options={}]
 * @param {Boolean} [options.skipLiveSnippets=false] If set to `true`, the `snippetAdapter` module will not be added to the configuration.
 * @returns {Promise}
 */
module.exports = async ( rootPath, options = {} ) => {
	// Let's normalize the root path to fix mixed slashes and backslashes. Root path should contain only slashes.
	rootPath = rootPath.split( /[\\/]/g ).join( '/' );

	const configPath = findConfigurationPath( rootPath );

	if ( cache.has( configPath ) ) {
		return cache.get( configPath );
	}

	const config = getConfigurationFile( configPath );

	config.__configPath = configPath;

	// Get the `realImportPath()` function. It displays an import path of a class below the class title.
	// See: https://ckeditor.com/docs/ckeditor5/latest/api/module_editor-classic_classiceditor-ClassicEditor.html.
	await loadScriptToConfig( config, {
		scriptKey: 'import-path',
		configKey: 'getRealImportPath',
		configPath,
		onError( err ) {
			throw new Error(
				`[${ config.name }]: Failed to load getRealImportPath function from the path specified in umberto.json. ${ err }`
			);
		}
	} );

	// Load the `insertChangelog()` script that allows inserting a changelog to a guide.
	await loadScriptToConfig( config, {
		scriptKey: 'insert-changelog',
		configKey: 'insertChangelog',
		configPath,
		onError() {
			console.error( 'An error occurred while loading the insertChangelog function from the path specified in umberto.json.' );
		}
	} );

	// Load the snippet adapter only if snippets will be built.
	if ( !options.skipLiveSnippets ) {
		await loadScriptToConfig( config, {
			scriptKey: 'snippet-adapter',
			configKey: 'snippetAdapter',
			configPath,
			onError() {
				console.error( 'An error occurred while loading the snippetAdapter function from the path specified in umberto.json.' );
			}
		} );
	}

	// If the "reportIssueWidget" is missing, let's add. Default values will be filled in next steps.
	if ( !config.reportIssueWidget ) {
		config.reportIssueWidget = {};
	}

	// Prepare default values.
	config.reportIssueWidget.enabled = config.reportIssueWidget.enabled || false;
	config.reportIssueWidget.skipPages = config.reportIssueWidget.skipPages || [];

	// And check whether the `issueUrl` is available if the widget is enabled.
	if ( config.reportIssueWidget.enabled && !config.reportIssueWidget.issueUrl ) {
		throw new Error(
			`[${ config.name }]: The "reportIssueWidget.issueUrl" value must be specified if the report issue widget is enabled.`
		);
	}

	validateConfiguration( config );

	config.version = getProjectVersion( rootPath );

	parseGitHubUrls( config, rootPath );

	// Packages of the project. Packages can have their own guides and they need to be included in the documentation.
	config.items = config.items || getPackages( rootPath, config.slug, config.packagesDir );

	// Store the configuration.
	cache.set( configPath, config );

	return config;
};

/**
 * Returns an absolute path to the localization of the `umberto.json` file.
 *
 * @param {String} rootPath
 * @return {String}
 */
function findConfigurationPath( rootPath ) {
	// Looks for the configuration file in the project's root directory.
	if ( fs.existsSync( upath.join( rootPath, 'umberto.json' ) ) ) {
		return upath.join( rootPath, 'umberto.json' );
	}

	// If not found, checks the `docs/` directory.
	if ( fs.existsSync( upath.join( rootPath, 'docs', 'umberto.json' ) ) ) {
		return upath.join( rootPath, 'docs', 'umberto.json' );
	}

	// If still not found, it may not exist, let's throw an error.
	throw new Error( 'umberto.json config file not found.' );
}

/**
 * Returns an object with the configuration.
 *
 * @param {String} configPath
 * @return {Object}
 */
function getConfigurationFile( configPath ) {
	try {
		// The configuration file should be a proper JSON object.
		return JSON.parse( fs.readFileSync( configPath, 'utf8' ) );
	} catch ( err ) {
		throw new Error( 'Error when reading umberto.json: ' + err );
	}
}

/**
 * Loads a script specified in the `config.scripts` object.
 *
 * @param {Object} config
 * @param {Object.<String, String>} config.scripts
 * @param {Object} options
 * @param {String} options.scriptKey A key of the script specified in the `config.scripts` object.
 * @param {String} options.configKey A name of the property in the config object where the function will be loaded to.
 * @param {String} options.configPath An absolute path to the configuration file. It is used to obtain a path of the function to load.
 * @param {Function} options.onError A callback will be executed if the function could not be load.
 */
async function loadScriptToConfig( config, options ) {
	if ( !config.scripts ) {
		return;
	}

	const { scriptKey, configKey } = options;

	// Do not load anything if the script is not specified in the configuration file.
	if ( !config.scripts[ scriptKey ] ) {
		return;
	}

	try {
		// Tries to load the function from the current working directory.
		config[ configKey ] = await importModule( config.scripts[ scriptKey ] );
	} catch ( err ) {
		// If failed, let's resolve the path from the directory where the configuration file is located.
		const fnPath = upath.join( upath.dirname( options.configPath ), config.scripts[ scriptKey ] );

		try {
			config[ configKey ] = await importModule( fnPath );
		} catch ( nestedErr ) {
			// If still fails, let's call the callback and throw an error.
			options.onError( err );

			throw nestedErr;
		}
	}
}

/**
 * Loads URLs to GitHub to create an issue or contribute to a project if specified in the configuration file.
 * Otherwise, the `config.repoUrl` will be retrieved from the `package.json` file for the current processed project.
 *
 * @param {Object} config
 * @param {Object} [config.github]
 * @param {Object} [config.github.docsUrl]
 * @param {Object} [config.github.issueUrl]
 * @param {Object} [config.github.contributeUrl]
 * @param {String} rootPath
 */
function parseGitHubUrls( config, rootPath ) {
	if ( config.github ) {
		if ( config.github.url ) {
			config.repoUrl = config.github.url;
		}

		if ( config.github.docsUrl ) {
			config.repoUrlDocs = config.github.docsUrl;
		}

		if ( config.github.issueUrl ) {
			config.issueUrl = config.github.issueUrl;
		}

		if ( config.github.contributeUrl ) {
			config.contributeUrl = config.github.contributeUrl;
		}
	}

	if ( !config.repoUrl ) {
		config.repoUrl = getRepositoryUrl( rootPath );
	}
}

/**
 * Checks whether the configuration file contains required properties and defined groups and categories
 * represent unique id across the entire project.
 *
 * @param {Object} config
 */
function validateConfiguration( config ) {
	// A collection of unique groups/categories identifiers.
	const uniqueIds = new Set();

	if ( !isNonEmptyString( config.name ) || !isNonEmptyString( config.slug ) || !isNonEmptyString( config.path ) ) {
		throw new Error( 'umberto.json config is invalid.' );
	}

	// Does not check `#groups` if the value is not an array.
	if ( !isArray( config.groups ) ) {
		return;
	}

	for ( const singleGroup of config.groups ) {
		if ( !isNonEmptyString( singleGroup.name ) || !isNonEmptyString( singleGroup.slug ) || !isNonEmptyString( singleGroup.id ) ) {
			throw new Error( 'umberto.json config is invalid. Check groups config.' );
		}

		// Groups and categories must define an unique value for the `#id` property.
		if ( uniqueIds.has( singleGroup.id ) ) {
			throw new Error( 'The same id has been assigned to multiple groups or categories.' );
		}

		uniqueIds.add( singleGroup.id );

		// Check categories only if the value is an array.
		if ( !isArray( singleGroup.categories ) ) {
			continue;
		}

		for ( const singleCategory of singleGroup.categories ) {
			validateCategories( singleCategory, uniqueIds );
		}
	}
}

/**
 * Validates whether a specified category is valid. It means its name and identifier are defined, the `id` value is unique
 * across the entire project and nested categories follow the same rules.
 *
 * @param {Object} category
 * @param {Set.<String>} uniqueIds
 */
function validateCategories( category, uniqueIds ) {
	if ( !isNonEmptyString( category.name ) || !isNonEmptyString( category.id ) ) {
		throw new Error( 'umberto.json config is invalid. Check categories.' );
	}

	if ( uniqueIds.has( category.id ) ) {
		throw new Error( 'The same id has been assigned to multiple groups or categories.' );
	}

	uniqueIds.add( category.id );

	// The "navigationIncludeIndex" option is supported only for top-level categories.
	if ( typeof category.navigationIncludeIndex !== 'undefined' ) {
		throw new Error( `The "navigationIncludeIndex" option is supported only for top-level categories. Found in "${ category.id }".` );
	}

	// Check nested categories only if the value is an array.
	if ( !isArray( category.categories ) ) {
		return;
	}

	for ( const singleCategory of category.categories ) {
		validateCategories( singleCategory, uniqueIds );
	}
}

/**
 * Returns a project version if found in the `package.json` file. Otherwise, the "latest" version will be used.
 *
 * @param {String} rootPath
 * @return {String}
 */
function getProjectVersion( rootPath ) {
	const packageJson = getPackageJson( rootPath );
	const version = packageJson ? packageJson.version : 'latest';

	return version || 'latest';
}

/**
 * Tries to find a URL to the repository based on the value in `package.json`, under the "repository" object.
 *
 * @param {String} rootPath
 * @return {String}
 */
function getRepositoryUrl( rootPath ) {
	const packageJson = getPackageJson( rootPath );

	if ( packageJson && packageJson.repository && packageJson.repository.url ) {
		const match = packageJson.repository.url.match( /http\S*(?=\.git)/ );

		return match ? match[ 0 ] : '';
	}

	return '';
}

/**
 * Returns a content of the `package.json` file in the specified `rootPath`.
 * If the file does not exist, the `null` value will be returned instead.
 *
 * @param {String} rootPath
 * @return {Object|null}
 */
function getPackageJson( rootPath ) {
	try {
		return require( upath.join( rootPath, 'package.json' ) );
	} catch {
		return null;
	}
}

/**
 * Loads additional packages connected with the project.
 *
 * @param {String} rootPath An absolute path to the project root.
 * @param {String} projectSlug A project slug (used in the navigation to generate URLs).
 * @param {String|Array.<String>} [packagesDir='packages/*'] A string or an array of strings where to look for packages.
 * @return {Array.<Object>}
 */
function getPackages( rootPath, projectSlug, packagesDir = 'packages/*' ) {
	if ( Array.isArray( packagesDir ) ) {
		const allPackages = [];

		for ( const dir of packagesDir ) {
			allPackages.push( ...getPackagesPathsInfo( rootPath, projectSlug, dir ) );
		}

		return allPackages;
	}

	return getPackagesPathsInfo( rootPath, projectSlug, packagesDir );
}

/**
 * Loads additional packages connected with the project Returns an array with objects that contain two properties:
 *      - "path" - a relative path to the package,
 *      - "prefix" - a string that represents the package name without the slug value.
 *
 * @param {String} rootPath An absolute path to the project root.
 * @param {String} slug A project slug (used in the navigation to generate URLs).
 * @param {String} directory A string where to look for packages.
 * @return {Array.<Object>}
 */
function getPackagesPathsInfo( rootPath, slug, directory ) {
	// CKEditor 5 can have packages that do not start with `ckeditor5-` prefix.
	const slugOrRegExp = slug === 'ckeditor5' ? /ckeditor5?-/ : `${ slug }-`;

	return globSync( upath.resolve( rootPath, directory ) )
		.map( p => upath.normalize( p ) )
		.map( p => {
			const relativePath = p.replace( rootPath + '/', '' );

			// The `prefix` key is not prefix in fact. It's a package name without the prefix (`slug` in the function).
			// E.g.:
			// - relativePath: `/work/space/packages/ckeditor5-core`, `prefix` will be `core`
			// - relativePath: `/work/space/packages/ckeditor-foo`, `prefix` will be `foo`
			return {
				path: relativePath,
				prefix: relativePath.split( '/' ).slice( -1 )[ 0 ].replace( slugOrRegExp, '' )
			};
		} );
}

/**
 * Checks whether the specified `value` is non-empty string.
 *
 * @param {String} value
 * @return {Boolean}
 */
function isNonEmptyString( value ) {
	return typeof value === 'string' && value.length > 0;
}

/**
 * Checks whether specified `value` is an array.
 *
 * @param {*} value
 * @return {Boolean}
 */
function isArray( value ) {
	return Array.isArray( value );
}
