import fs from "node:fs";
import chalk from "kleur";
import { Merge, TemplatePath, isPlainObject } from "@11ty/eleventy-utils";
import debugUtil from "debug";

import { EleventyImportRaw, EleventyImportRawFromEleventy } from "./Util/Require.js";
import EleventyBaseError from "./Errors/EleventyBaseError.js";
import UserConfig from "./UserConfig.js";
import GlobalDependencyMap from "./GlobalDependencyMap.js";
import ExistsCache from "./Util/ExistsCache.js";
import eventBus from "./EventBus.js";
import ProjectTemplateFormats from "./Util/ProjectTemplateFormats.js";

const debug = debugUtil("Eleventy:TemplateConfig");
const debugDev = debugUtil("Dev:Eleventy:TemplateConfig");

/**
 * @module 11ty/eleventy/TemplateConfig
 */

/**
 * Config as used by the template.
 * @typedef {object} module:11ty/eleventy/TemplateConfig~TemplateConfig~config
 * @property {String} [pathPrefix] - The path prefix.
 */

/**
 * Errors in eleventy config.
 * @ignore
 */
class EleventyConfigError extends EleventyBaseError {}

/**
 * Errors in eleventy plugins.
 * @ignore
 */
class EleventyPluginError extends EleventyBaseError {}

/**
 * Config for a template.
 * @ignore
 * @param {{}} customRootConfig - tbd.
 * @param {String} projectConfigPath - Path to local project config.
 */
class TemplateConfig {
	#templateFormats;
	#runMode;
	#configManuallyDefined = false;
	/** @type {UserConfig} */
	#userConfig = new UserConfig();

	constructor(customRootConfig, projectConfigPath) {
		/** @type {object} */
		this.overrides = {};

		/**
		 * @type {String}
		 * @description Path to local project config.
		 * @default .eleventy.js
		 */
		if (projectConfigPath !== undefined) {
			this.#configManuallyDefined = true;

			if (!projectConfigPath) {
				// falsy skips config files
				this.projectConfigPaths = [];
			} else {
				this.projectConfigPaths = [projectConfigPath];
			}
		} else {
			this.projectConfigPaths = [
				".eleventy.js",
				"eleventy.config.js",
				"eleventy.config.mjs",
				"eleventy.config.cjs",
			];
		}

		if (customRootConfig) {
			/**
			 * @type {object}
			 * @description Custom root config.
			 */
			this.customRootConfig = customRootConfig;
			debug("Warning: Using custom root config!");
		} else {
			this.customRootConfig = null;
		}

		this.hasConfigMerged = false;
		this.isEsm = false;
	}

	get userConfig() {
		return this.#userConfig;
	}

	get aggregateBenchmark() {
		return this.userConfig.benchmarks.aggregate;
	}

	/* Setter for Logger */
	setLogger(logger) {
		this.logger = logger;
		this.userConfig.logger = this.logger;
	}

	/* Setter for Directories instance */
	setDirectories(directories) {
		this.directories = directories;
		this.userConfig.directories = directories.getUserspaceInstance();
	}

	/* Setter for TemplateFormats instance */
	setTemplateFormats(templateFormats) {
		this.#templateFormats = templateFormats;
	}

	get templateFormats() {
		if (!this.#templateFormats) {
			this.#templateFormats = new ProjectTemplateFormats();
		}
		return this.#templateFormats;
	}

	/* Backwards compat */
	get inputDir() {
		return this.directories.input;
	}

	setRunMode(runMode) {
		this.#runMode = runMode;
	}

	shouldSpiderJavaScriptDependencies() {
		// not for a standard build
		return (
			(this.#runMode === "watch" || this.#runMode === "serve") &&
			this.userConfig.watchJavaScriptDependencies
		);
	}

	/**
	 * Normalises local project config file path.
	 *
	 * @method
	 * @returns {String|undefined} - The normalised local project config file path.
	 */
	getLocalProjectConfigFile() {
		let configFiles = this.getLocalProjectConfigFiles();
		// Add the configFiles[0] in case of a test, where no file exists on the file system
		let configFile = configFiles.find((path) => path && fs.existsSync(path)) || configFiles[0];
		if (configFile) {
			return configFile;
		}
	}

	getLocalProjectConfigFiles() {
		if (this.projectConfigPaths?.length > 0) {
			return TemplatePath.addLeadingDotSlashArray(this.projectConfigPaths.filter((path) => path));
		}
		return [];
	}

	setProjectUsingEsm(isEsmProject) {
		this.isEsm = !!isEsmProject;
		this.usesGraph.setIsEsm(isEsmProject);
	}

	getIsProjectUsingEsm() {
		return this.isEsm;
	}

	/**
	 * Resets the configuration.
	 */
	async reset() {
		debugDev("Resetting configuration: TemplateConfig and UserConfig.");
		this.userConfig.reset();
		// await this.initializeRootConfig();
		await this.forceReloadConfig();
		this.usesGraph.reset();

		// Clear the compile cache
		eventBus.emit("eleventy.compileCacheReset");
	}

	/**
	 * Resets the configuration while in watch mode.
	 *
	 * @todo Add implementation.
	 */
	resetOnWatch() {
		// nothing yet
	}

	hasInitialized() {
		return this.hasConfigMerged;
	}

	/**
	 * Async-friendly init method
	 */
	async init(overrides) {
		await this.initializeRootConfig();

		if (overrides) {
			this.appendToRootConfig(overrides);
		}

		this.config = await this.mergeConfig();
		this.hasConfigMerged = true;
	}

	/**
	 * Force a reload of the configuration object.
	 */
	async forceReloadConfig() {
		this.hasConfigMerged = false;
		await this.init();
	}

	/**
	 * Returns the config object.
	 *
	 * @returns {{}} - The config object.
	 */
	getConfig() {
		if (!this.hasConfigMerged) {
			throw new Error("Invalid call to .getConfig(). Needs an .init() first.");
		}

		return this.config;
	}

	/**
	 * Overwrites the config path.
	 *
	 * @param {String} path - The new config path.
	 */
	async setProjectConfigPath(path) {
		this.#configManuallyDefined = true;

		if (path !== undefined) {
			this.projectConfigPaths = [path];
		} else {
			this.projectConfigPaths = [];
		}

		if (this.hasConfigMerged) {
			// merge it again
			debugDev("Merging in getConfig again after setting the local project config path.");
			await this.forceReloadConfig();
		}
	}

	/**
	 * Overwrites the path prefix.
	 *
	 * @param {String} pathPrefix - The new path prefix.
	 */
	setPathPrefix(pathPrefix) {
		if (pathPrefix && pathPrefix !== "/") {
			debug("Setting pathPrefix to %o", pathPrefix);
			this.overrides.pathPrefix = pathPrefix;
		}
	}

	/**
	 * Gets the current path prefix denoting the root folder the output will be deployed to
	 *
	 *  @returns {String} - The path prefix string
	 */
	getPathPrefix() {
		if (this.overrides.pathPrefix) {
			return this.overrides.pathPrefix;
		}

		if (!this.hasConfigMerged) {
			throw new Error("Config has not yet merged. Needs `init()`.");
		}

		return this.config?.pathPrefix;
	}

	/**
	 * Bootstraps the config object.
	 */
	async initializeRootConfig() {
		this.rootConfig = this.customRootConfig;
		if (!this.rootConfig) {
			let { default: cfg } = await EleventyImportRawFromEleventy("./src/defaultConfig.js");
			this.rootConfig = cfg;
		}

		if (typeof this.rootConfig === "function") {
			// Not yet using async in defaultConfig.js
			this.rootConfig = this.rootConfig.call(this, this.userConfig);
		}

		debug("Default Eleventy config %o", this.rootConfig);
	}

	/*
	 * Add additional overrides to the root config object, used for testing
	 *
	 * @param {object} - a subset of the return Object from the user’s config file.
	 */
	appendToRootConfig(obj) {
		Object.assign(this.rootConfig, obj);
	}

	/*
	 * Process the userland plugins from the Config
	 *
	 * @param {object} - the return Object from the user’s config file.
	 */
	async processPlugins({ dir, pathPrefix }) {
		this.userConfig.dir = dir;
		this.userConfig.pathPrefix = pathPrefix;

		// for Nested addPlugin calls, Issue #1925
		this.userConfig._enablePluginExecution();

		let storedActiveNamespace = this.userConfig.activeNamespace;
		for (let { plugin, options, pluginNamespace } of this.userConfig.plugins) {
			try {
				this.userConfig.activeNamespace = pluginNamespace;
				await this.userConfig._executePlugin(plugin, options);
			} catch (e) {
				let name = this.userConfig._getPluginName(plugin);
				let namespaces = [storedActiveNamespace, pluginNamespace].filter((entry) => !!entry);

				let namespaceStr = "";
				if (namespaces.length) {
					namespaceStr = ` (namespace: ${namespaces.join(".")})`;
				}

				throw new EleventyPluginError(
					`Error processing ${name ? `the \`${name}\`` : "a"} plugin${namespaceStr}`,
					e,
				);
			}
		}

		this.userConfig.activeNamespace = storedActiveNamespace;

		this.userConfig._disablePluginExecution();
	}

	/**
	 * Fetches and executes the local configuration file
	 *
	 * @returns {Promise<object>} merged - The merged config file object.
	 */
	async requireLocalConfigFile() {
		let localConfig = {};
		let exportedConfig = {};

		let path = this.projectConfigPaths.filter((path) => path).find((path) => fs.existsSync(path));

		if (this.projectConfigPaths.length > 0 && this.#configManuallyDefined && !path) {
			throw new EleventyConfigError(
				"A configuration file was specified but not found: " + this.projectConfigPaths.join(", "),
			);
		}

		debug(`Merging default config with ${path}`);
		if (path) {
			try {
				let { default: configDefaultReturn, config: exportedConfigObject } =
					await EleventyImportRaw(path, this.isEsm ? "esm" : "cjs");

				exportedConfig = exportedConfigObject || {};

				if (this.directories && Object.keys(exportedConfigObject?.dir || {}).length > 0) {
					debug(
						"Setting directories via `config.dir` export from config file: %o",
						exportedConfigObject.dir,
					);
					this.directories.setViaConfigObject(exportedConfigObject.dir);
				}

				if (typeof configDefaultReturn === "function") {
					localConfig = await configDefaultReturn(this.userConfig);
				} else {
					localConfig = configDefaultReturn;
				}

				// Removed a check for `filters` in 3.0.0-alpha.6 (now using addTransform instead) https://v3.11ty.dev/docs/config/#transforms
			} catch (err) {
				let isModuleError =
					err instanceof Error && (err?.message || "").includes("Cannot find module");

				// TODO the error message here is bad and I feel bad (needs more accurate info)
				return Promise.reject(
					new EleventyConfigError(
						`Error in your Eleventy config file '${path}'.` +
							(isModuleError ? chalk.cyan(" You may need to run `npm install`.") : ""),
						err,
					),
				);
			}
		} else {
			debug(
				"Project config file not found (not an error—skipping). Looked in: %o",
				this.projectConfigPaths,
			);
		}

		return {
			localConfig,
			exportedConfig,
		};
	}

	/**
	 * Merges different config files together.
	 *
	 * @returns {Promise<object>} merged - The merged config file.
	 */
	async mergeConfig() {
		let { localConfig, exportedConfig } = await this.requireLocalConfigFile();

		// Merge `export const config = {}` with `return {}` in config callback
		if (isPlainObject(exportedConfig)) {
			localConfig = Merge(localConfig || {}, exportedConfig);
		}

		if (this.directories) {
			if (Object.keys(this.userConfig.directoryAssignments || {}).length > 0) {
				debug(
					"Setting directories via set*Directory configuration APIs %o",
					this.userConfig.directoryAssignments,
				);
				this.directories.setViaConfigObject(this.userConfig.directoryAssignments);
			}

			if (localConfig && Object.keys(localConfig?.dir || {}).length > 0) {
				debug(
					"Setting directories via `dir` object return from configuration file: %o",
					localConfig.dir,
				);
				this.directories.setViaConfigObject(localConfig.dir);
			}
		}

		// `templateFormats` is an override via `setTemplateFormats`
		if (this.userConfig?.templateFormats) {
			this.templateFormats.setViaConfig(this.userConfig.templateFormats);
		} else if (localConfig?.templateFormats || this.rootConfig?.templateFormats) {
			// Local project config or defaultConfig.js
			this.templateFormats.setViaConfig(
				localConfig.templateFormats || this.rootConfig?.templateFormats,
			);
		}

		// `templateFormatsAdded` is additive via `addTemplateFormats`
		if (this.userConfig?.templateFormatsAdded) {
			this.templateFormats.addViaConfig(this.userConfig.templateFormatsAdded);
		}

		let mergedConfig = Merge({}, this.rootConfig, localConfig);

		// Setup a few properties for plugins:

		// Set frozen templateFormats
		mergedConfig.templateFormats = Object.freeze(this.templateFormats.getTemplateFormats());

		// Setup pathPrefix set via command line for plugin consumption
		if (this.overrides.pathPrefix) {
			mergedConfig.pathPrefix = this.overrides.pathPrefix;
		}

		// Returning a falsy value (e.g. "") from user config should reset to the default value.
		if (!mergedConfig.pathPrefix) {
			mergedConfig.pathPrefix = this.rootConfig.pathPrefix;
		}

		// This is not set in UserConfig.js so that getters aren’t converted to strings
		// We want to error if someone attempts to use a setter there.
		if (this.directories) {
			mergedConfig.directories = this.directories.getUserspaceInstance();
		}

		// Delay processing plugins until after the result of localConfig is returned
		// But BEFORE the rest of the config options are merged
		// this way we can pass directories and other template information to plugins

		await this.userConfig.events.emit("eleventy.beforeConfig", this.userConfig);

		let pluginsBench = this.aggregateBenchmark.get("Processing plugins in config");
		pluginsBench.before();
		await this.processPlugins(mergedConfig);
		pluginsBench.after();

		// Template formats added via plugins
		if (this.userConfig?.templateFormatsAdded) {
			this.templateFormats.addViaConfig(this.userConfig.templateFormatsAdded);
			mergedConfig.templateFormats = Object.freeze(this.templateFormats.getTemplateFormats());
		}

		let eleventyConfigApiMergingObject = this.userConfig.getMergingConfigObject();

		if ("templateFormats" in eleventyConfigApiMergingObject) {
			throw new Error(
				"Internal error: templateFormats should not return from `getMergingConfigObject`",
			);
		}

		// Overrides are only used by pathPrefix
		debug("Configuration overrides: %o", this.overrides);
		Merge(mergedConfig, eleventyConfigApiMergingObject, this.overrides);

		debug("Current configuration: %o", mergedConfig);

		// Add to the merged config too
		mergedConfig.uses = this.usesGraph;

		// this is used for the layouts event
		this.usesGraph.setConfig(mergedConfig);

		return mergedConfig;
	}

	get usesGraph() {
		if (!this._usesGraph) {
			this._usesGraph = new GlobalDependencyMap();
			this._usesGraph.setIsEsm(this.isEsm);
			this._usesGraph.setTemplateConfig(this);
		}
		return this._usesGraph;
	}

	get uses() {
		if (!this.usesGraph) {
			throw new Error("The Eleventy Global Dependency Graph has not yet been initialized.");
		}
		return this.usesGraph;
	}

	get existsCache() {
		if (!this._existsCache) {
			this._existsCache = new ExistsCache();
			this._existsCache.setDirectoryCheck(true);
		}
		return this._existsCache;
	}
}

export default TemplateConfig;
