import chalk from "kleur";
import { performance } from "node:perf_hooks";
import debugUtil from "debug";
import { filesize } from "filesize";
import path from "node:path";

/* Eleventy Deps */
import { TemplatePath } from "@11ty/eleventy-utils";
import BundlePlugin from "@11ty/eleventy-plugin-bundle";

import TemplateData from "./Data/TemplateData.js";
import TemplateWriter from "./TemplateWriter.js";
import EleventyExtensionMap from "./EleventyExtensionMap.js";
import { EleventyErrorHandler } from "./Errors/EleventyErrorHandler.js";
import EleventyBaseError from "./Errors/EleventyBaseError.js";
import EleventyServe from "./EleventyServe.js";
import EleventyWatch from "./EleventyWatch.js";
import EleventyWatchTargets from "./EleventyWatchTargets.js";
import EleventyFiles from "./EleventyFiles.js";
import TemplatePassthroughManager from "./TemplatePassthroughManager.js";
import TemplateConfig from "./TemplateConfig.js";
import FileSystemSearch from "./FileSystemSearch.js";
import TemplateEngineManager from "./Engines/TemplateEngineManager.js";

/* Utils */
import ConsoleLogger from "./Util/ConsoleLogger.js";
import PathPrefixer from "./Util/PathPrefixer.js";
import ProjectDirectories from "./Util/ProjectDirectories.js";
import PathNormalizer from "./Util/PathNormalizer.js";
import { isGlobMatch } from "./Util/GlobMatcher.js";
import simplePlural from "./Util/Pluralize.js";
import checkPassthroughCopyBehavior from "./Util/PassthroughCopyBehaviorCheck.js";
import eventBus from "./EventBus.js";
import {
	getEleventyPackageJson,
	importJsonSync,
	getWorkingProjectPackageJsonPath,
} from "./Util/ImportJsonSync.js";
import { EleventyImport } from "./Util/Require.js";
import ProjectTemplateFormats from "./Util/ProjectTemplateFormats.js";
import { withResolvers } from "./Util/PromiseUtil.js";

/* Plugins */
import RenderPlugin, * as RenderPluginExtras from "./Plugins/RenderPlugin.js";
import I18nPlugin, * as I18nPluginExtras from "./Plugins/I18nPlugin.js";
import HtmlBasePlugin, * as HtmlBasePluginExtras from "./Plugins/HtmlBasePlugin.js";
import { TransformPlugin as InputPathToUrlTransformPlugin } from "./Plugins/InputPathToUrl.js";
import { IdAttributePlugin } from "./Plugins/IdAttributePlugin.js";
import FileSystemRemap from "./Util/GlobRemap.js";

const pkg = getEleventyPackageJson();
const debug = debugUtil("Eleventy");

/**
 * Eleventy’s programmatic API
 * @module 11ty/eleventy/Eleventy
 */

class Eleventy {
	/**
	 * Userspace package.json file contents
	 * @type {object|undefined}
	 */
	#projectPackageJson;
	/** @type {string} */
	#projectPackageJsonPath;
	/** @type {ProjectTemplateFormats|undefined} */
	#templateFormats;
	/** @type {ConsoleLogger|undefined} */
	#logger;
	/** @type {ProjectDirectories|undefined} */
	#directories;
	/** @type {boolean|undefined} */
	#verboseOverride;
	/** @type {boolean} */
	#isVerboseMode = true;
	/** @type {boolean|undefined} */
	#preInitVerbose;
	/** @type {boolean} */
	#hasConfigInitialized = false;
	/** @type {boolean} */
	#needsInit = true;
	/** @type {Promise|undefined} */
	#initPromise;
	/** @type {EleventyErrorHandler|undefined} */
	#errorHandler;
	/** @type {Map} */
	#privateCaches = new Map();
	/** @type {boolean} */
	#isStopping = false;
	/** @type {boolean|undefined} */
	#isEsm;

	/**
	 * @typedef {object} EleventyOptions
	 * @property {'cli'|'script'=} source
	 * @property {'build'|'serve'|'watch'=} runMode
	 * @property {boolean=} dryRun
	 * @property {string=} configPath
	 * @property {string=} pathPrefix
	 * @property {boolean=} quietMode
	 * @property {Function=} config
	 * @property {string=} inputDir

	 * @param {string} [input] - Directory or filename for input/sources files.
	 * @param {string} [output] - Directory serving as the target for writing the output files.
	 * @param {EleventyOptions} [options={}]
	 * @param {TemplateConfig} [eleventyConfig]
	 */
	constructor(input, output, options = {}, eleventyConfig = null) {
		/**
		 * @type {string|undefined}
		 * @description Holds the path to the input (might be a file or folder)
		 */
		this.rawInput = input || undefined;

		/**
		 * @type {string|undefined}
		 * @description holds the path to the output directory
		 */
		this.rawOutput = output || undefined;

		/**
		 * @type {module:11ty/eleventy/TemplateConfig}
		 * @description Override the config instance (for centralized config re-use)
		 */
		this.eleventyConfig = eleventyConfig;

		/**
		 * @type {EleventyOptions}
		 * @description Options object passed to the Eleventy constructor
		 * @default {}
		 */
		this.options = options;

		/**
		 * @type {'cli'|'script'}
		 * @description Called via CLI (`cli`) or Programmatically (`script`)
		 * @default "script"
		 */
		this.source = options.source || "script";

		/**
		 * @type {string}
		 * @description One of build, serve, or watch
		 * @default "build"
		 */
		this.runMode = options.runMode || "build";

		/**
		 * @type {boolean}
		 * @description Is Eleventy running in dry mode?
		 * @default false
		 */
		this.isDryRun = options.dryRun ?? false;

		/**
		 * @type {boolean}
		 * @description Is this an incremental build? (only operates on a subset of input files)
		 * @default false
		 */
		this.isIncremental = false;

		/**
		 * @type {string|undefined}
		 * @description If an incremental build, this is the file we’re operating on.
		 * @default null
		 */
		this.programmaticApiIncrementalFile = undefined;

		/**
		 * @type {boolean}
		 * @description Should we process files on first run? (The --ignore-initial feature)
		 * @default true
		 */
		this.isRunInitialBuild = true;

		/**
		 * @type {Number}
		 * @description Number of builds run on this instance.
		 * @default 0
		 */
		this.buildCount = 0;

		/**
		 * @member {String} - Force ESM or CJS mode instead of detecting from package.json. Either cjs, esm, or auto.
		 * @default "auto"
		 */
		this.loader = this.options.loader ?? "auto";

		/**
		 * @type {Number}
		 * @description The timestamp of Eleventy start.
		 */
		this.start = this.getNewTimestamp();
	}

	/**
	 * @type {string|undefined}
	 * @description An override of Eleventy's default config file paths
	 * @default undefined
	 */
	get configPath() {
		return this.options.configPath;
	}

	/**
	 * @type {string}
	 * @description The top level directory the site pretends to reside in
	 * @default "/"
	 */
	get pathPrefix() {
		return this.options.pathPrefix || "/";
	}

	async initializeConfig(initOverrides) {
		if (!this.eleventyConfig) {
			this.eleventyConfig = new TemplateConfig(null, this.configPath);
		} else if (this.configPath) {
			await this.eleventyConfig.setProjectConfigPath(this.configPath);
		}

		this.eleventyConfig.setRunMode(this.runMode);
		this.eleventyConfig.setProjectUsingEsm(this.isEsm);
		this.eleventyConfig.setLogger(this.logger);
		this.eleventyConfig.setDirectories(this.directories);
		this.eleventyConfig.setTemplateFormats(this.templateFormats);

		if (this.pathPrefix || this.pathPrefix === "") {
			this.eleventyConfig.setPathPrefix(this.pathPrefix);
		}

		// Debug mode should always run quiet (all output goes to debug logger)
		if (process.env.DEBUG) {
			this.#verboseOverride = false;
		} else if (this.options.quietMode === true || this.options.quietMode === false) {
			this.#verboseOverride = !this.options.quietMode;
		}

		// Moved before config merges: https://github.com/11ty/eleventy/issues/3316
		if (this.#verboseOverride === true || this.#verboseOverride === false) {
			this.eleventyConfig.userConfig._setQuietModeOverride(!this.#verboseOverride);
		}

		this.eleventyConfig.userConfig.directories = this.directories;

		/* Programmatic API config */
		if (this.options.config && typeof this.options.config === "function") {
			debug("Running options.config configuration callback (passed to Eleventy constructor)");
			// TODO use return object here?
			await this.options.config(this.eleventyConfig.userConfig);
		}

		/**
		 * @type {object}
		 * @description Initialize Eleventy environment variables
		 * @default null
		 */
		// this.runMode need to be set before this
		this.env = this.getEnvironmentVariableValues();
		this.initializeEnvironmentVariables(this.env);

		// Async initialization of configuration
		await this.eleventyConfig.init(initOverrides);

		/**
		 * @type {object}
		 * @description Initialize Eleventy’s configuration, including the user config file
		 */
		this.config = this.eleventyConfig.getConfig();

		/**
		 * @type {object}
		 * @description Singleton BenchmarkManager instance
		 */
		this.bench = this.config.benchmarkManager;

		if (performance) {
			debug("Eleventy warm up time: %o (ms)", performance.now());
		}

		// Careful to make sure the previous server closes on SIGINT, issue #3873
		if (!this.eleventyServe) {
			/** @type {object} */
			this.eleventyServe = new EleventyServe();
		}
		this.eleventyServe.eleventyConfig = this.eleventyConfig;

		/** @type {object} */
		this.watchManager = new EleventyWatch();

		/** @type {object} */
		this.watchTargets = new EleventyWatchTargets(this.eleventyConfig);
		this.watchTargets.addAndMakeGlob(this.config.additionalWatchTargets);

		/** @type {object} */
		this.fileSystemSearch = new FileSystemSearch();

		this.#hasConfigInitialized = true;

		// after #hasConfigInitialized above
		this.setIsVerbose(this.#preInitVerbose ?? !this.config.quietMode);
	}

	getNewTimestamp() {
		if (performance) {
			return performance.now();
		}
		return new Date().getTime();
	}

	/** @type {ProjectDirectories} */
	get directories() {
		if (!this.#directories) {
			this.#directories = new ProjectDirectories();
			this.#directories.setInput(this.rawInput, this.options.inputDir);
			this.#directories.setOutput(this.rawOutput);

			if (this.source == "cli" && (this.rawInput !== undefined || this.rawOutput !== undefined)) {
				this.#directories.freeze();
			}
		}

		return this.#directories;
	}

	/** @type {string} */
	get input() {
		return this.directories.inputFile || this.directories.input || this.config.dir.input;
	}

	/** @type {string} */
	get inputFile() {
		return this.directories.inputFile;
	}

	/** @type {string} */
	get inputDir() {
		return this.directories.input;
	}

	// Not used internally, removed in 3.0.
	setInputDir() {
		throw new Error(
			"Eleventy->setInputDir was removed in 3.0. Use the inputDir option to the constructor",
		);
	}

	/** @type {string} */
	get outputDir() {
		return this.directories.output || this.config.dir.output;
	}

	/**
	 * Updates the dry-run mode of Eleventy.
	 *
	 * @param {boolean} isDryRun - Shall Eleventy run in dry mode?
	 */
	setDryRun(isDryRun) {
		this.isDryRun = !!isDryRun;
	}

	/**
	 * Sets the incremental build mode.
	 *
	 * @param {boolean} isIncremental - Shall Eleventy run in incremental build mode and only write the files that trigger watch updates
	 */
	setIncrementalBuild(isIncremental) {
		this.isIncremental = !!isIncremental;

		if (this.watchManager) {
			this.watchManager.incremental = !!isIncremental;
		}
		if (this.writer) {
			this.writer.setIncrementalBuild(this.isIncremental);
		}
	}

	/**
	 * Set whether or not to do an initial build
	 *
	 * @param {boolean} ignoreInitialBuild - Shall Eleventy ignore the default initial build before watching in watch/serve mode?
	 * @default true
	 */
	setIgnoreInitial(ignoreInitialBuild) {
		this.isRunInitialBuild = !ignoreInitialBuild;

		if (this.writer) {
			this.writer.setRunInitialBuild(this.isRunInitialBuild);
		}
	}

	/**
	 * Updates the path prefix used in the config.
	 *
	 * @param {string} pathPrefix - The new path prefix.
	 */
	setPathPrefix(pathPrefix) {
		if (pathPrefix || pathPrefix === "") {
			this.eleventyConfig.setPathPrefix(pathPrefix);
			// TODO reset config
			// this.config = this.eleventyConfig.getConfig();
		}
	}

	/**
	 * Restarts Eleventy.
	 */
	async restart() {
		debug("Restarting.");
		this.start = this.getNewTimestamp();

		this.extensionMap.reset();
		this.bench.reset();
		this.passthroughManager.reset();
		this.eleventyFiles.restart();
	}

	/**
	 * Logs some statistics after a complete run of Eleventy.
	 *
	 * @returns {string} ret - The log message.
	 */
	logFinished() {
		if (!this.writer) {
			throw new Error(
				"Did you call Eleventy.init to create the TemplateWriter instance? Hint: you probably didn’t.",
			);
		}

		let ret = [];

		let {
			copyCount,
			copySize,
			skipCount,
			writeCount,
			// renderCount, // files that render (costly) but may not write to disk
		} = this.writer.getMetadata();

		let slashRet = [];

		if (copyCount) {
			debug("Total passthrough copy aggregate size: %o", filesize(copySize));
			slashRet.push(`Copied ${chalk.bold(copyCount)}`);
		}

		slashRet.push(
			`Wrote ${chalk.bold(writeCount)} ${simplePlural(writeCount, "file", "files")}${
				skipCount ? ` (skipped ${skipCount})` : ""
			}`,
		);

		// slashRet.push(
		// 	`${renderCount} rendered`
		// )

		if (slashRet.length) {
			ret.push(slashRet.join(" "));
		}

		let time = (this.getNewTimestamp() - this.start) / 1000;
		ret.push(
			`in ${chalk.bold(time.toFixed(2))} ${simplePlural(time.toFixed(2), "second", "seconds")}`,
		);

		// More than 1 second total, show estimate of per-template time
		if (time >= 1 && writeCount > 1) {
			ret.push(`(${((time * 1000) / writeCount).toFixed(1)}ms each, v${pkg.version})`);
		} else {
			ret.push(`(v${pkg.version})`);
		}

		return ret.join(" ");
	}

	#cache(key, inst) {
		if (!("caches" in inst)) {
			throw new Error("To use #cache you need a `caches` getter object");
		}

		// Restore from cache
		if (this.#privateCaches.has(key)) {
			let c = this.#privateCaches.get(key);
			for (let cacheKey in c) {
				inst[cacheKey] = c[cacheKey];
			}
		} else {
			// Set cache
			let c = {};
			for (let cacheKey of inst.caches || []) {
				c[cacheKey] = inst[cacheKey];
			}
			this.#privateCaches.set(key, c);
		}
	}

	/**
	 * Starts Eleventy.
	 */
	async init(options = {}) {
		let { viaConfigReset } = Object.assign({ viaConfigReset: false }, options);
		if (!this.#hasConfigInitialized) {
			await this.initializeConfig();
		} else {
			// Note: Global event bus is different from user config event bus
			this.config.events.reset();
		}

		await this.config.events.emit("eleventy.config", this.eleventyConfig);

		if (this.env) {
			await this.config.events.emit("eleventy.env", this.env);
		}

		let formats = this.templateFormats.getTemplateFormats();
		let engineManager = new TemplateEngineManager(this.eleventyConfig);
		this.extensionMap = new EleventyExtensionMap(this.eleventyConfig);
		this.extensionMap.setFormats(formats);
		this.extensionMap.engineManager = engineManager;
		await this.config.events.emit("eleventy.extensionmap", this.extensionMap);

		// eleventyServe is always available, even when not in --serve mode
		// TODO directorynorm
		this.eleventyServe.setOutputDir(this.outputDir);

		// TODO
		// this.eleventyServe.setWatcherOptions(this.getChokidarConfig());

		this.templateData = new TemplateData(this.eleventyConfig);
		this.templateData.setProjectUsingEsm(this.isEsm);
		this.templateData.extensionMap = this.extensionMap;
		if (this.env) {
			this.templateData.environmentVariables = this.env;
		}
		this.templateData.setFileSystemSearch(this.fileSystemSearch);

		this.passthroughManager = new TemplatePassthroughManager(this.eleventyConfig);
		this.passthroughManager.setRunMode(this.runMode);
		this.passthroughManager.setDryRun(this.isDryRun);
		this.passthroughManager.extensionMap = this.extensionMap;
		this.passthroughManager.setFileSystemSearch(this.fileSystemSearch);

		this.eleventyFiles = new EleventyFiles(formats, this.eleventyConfig);
		this.eleventyFiles.setPassthroughManager(this.passthroughManager);
		this.eleventyFiles.setFileSystemSearch(this.fileSystemSearch);
		this.eleventyFiles.setRunMode(this.runMode);
		this.eleventyFiles.extensionMap = this.extensionMap;
		// This needs to be set before init or it’ll construct a new one
		this.eleventyFiles.templateData = this.templateData;
		this.eleventyFiles.init();

		if (checkPassthroughCopyBehavior(this.config, this.runMode)) {
			this.eleventyServe.watchPassthroughCopy(
				this.eleventyFiles.getGlobWatcherFilesForPassthroughCopy(),
			);
		}

		// Note these directories are all project root relative
		this.config.events.emit("eleventy.directories", this.directories.getUserspaceInstance());

		this.writer = new TemplateWriter(formats, this.templateData, this.eleventyConfig);

		if (!viaConfigReset) {
			// set or restore cache
			this.#cache("TemplateWriter", this.writer);
		}

		this.writer.logger = this.logger;
		this.writer.extensionMap = this.extensionMap;
		this.writer.setEleventyFiles(this.eleventyFiles);
		this.writer.setPassthroughManager(this.passthroughManager);
		this.writer.setRunInitialBuild(this.isRunInitialBuild);
		this.writer.setIncrementalBuild(this.isIncremental);

		let debugStr = `Directories:
  Input:
    Directory: ${this.directories.input}
    File: ${this.directories.inputFile || false}
    Glob: ${this.directories.inputGlob || false}
  Data: ${this.directories.data}
  Includes: ${this.directories.includes}
  Layouts: ${this.directories.layouts || false}
  Output: ${this.directories.output}
Template Formats: ${formats.join(",")}
Verbose Output: ${this.verboseMode}`;
		debug(debugStr);

		this.writer.setVerboseOutput(this.verboseMode);
		this.writer.setDryRun(this.isDryRun);

		this.#needsInit = false;
	}

	// These are all set as initial global data under eleventy.env.* (see TemplateData->environmentVariables)
	getEnvironmentVariableValues() {
		let values = {
			source: this.source,
			runMode: this.runMode,
		};

		let configPath = this.eleventyConfig.getLocalProjectConfigFile();
		if (configPath) {
			values.config = TemplatePath.absolutePath(configPath);
		}

		// Fixed: instead of configuration directory, explicit root or working directory
		values.root = TemplatePath.getWorkingDir();

		values.source = this.source;

		// Backwards compatibility
		Object.defineProperty(values, "isServerless", {
			enumerable: false,
			value: false,
		});

		return values;
	}

	/**
	 * Set process.ENV variables for use in Eleventy projects
	 *
	 * @method
	 */
	initializeEnvironmentVariables(env) {
		// Recognize that global data `eleventy.version` is coerced to remove prerelease tags
		// and this is the raw version (3.0.0 versus 3.0.0-alpha.6).
		// `eleventy.env.version` does not yet exist (unnecessary)
		process.env.ELEVENTY_VERSION = Eleventy.getVersion();

		process.env.ELEVENTY_ROOT = env.root;
		debug("Setting process.env.ELEVENTY_ROOT: %o", env.root);

		process.env.ELEVENTY_SOURCE = env.source;
		process.env.ELEVENTY_RUN_MODE = env.runMode;
	}

	/** @param {boolean} value */
	set verboseMode(value) {
		this.setIsVerbose(value);
	}

	/** @type {boolean} */
	get verboseMode() {
		return this.#isVerboseMode;
	}

	/** @type {ConsoleLogger} */
	get logger() {
		if (!this.#logger) {
			this.#logger = new ConsoleLogger();
			this.#logger.isVerbose = this.verboseMode;
		}

		return this.#logger;
	}

	/** @param {ConsoleLogger} logger */
	set logger(logger) {
		this.eleventyConfig.setLogger(logger);
		this.#logger = logger;
	}

	disableLogger() {
		this.logger.overrideLogger(false);
	}

	/** @type {EleventyErrorHandler} */
	get errorHandler() {
		if (!this.#errorHandler) {
			this.#errorHandler = new EleventyErrorHandler();
			this.#errorHandler.isVerbose = this.verboseMode;
			this.#errorHandler.logger = this.logger;
		}

		return this.#errorHandler;
	}

	/**
	 * Updates the verbose mode of Eleventy.
	 *
	 * @method
	 * @param {boolean} isVerbose - Shall Eleventy run in verbose mode?
	 */
	setIsVerbose(isVerbose) {
		if (!this.#hasConfigInitialized) {
			this.#preInitVerbose = !!isVerbose;
			return;
		}

		// always defer to --quiet if override happened
		isVerbose = this.#verboseOverride ?? !!isVerbose;

		this.#isVerboseMode = isVerbose;

		if (this.logger) {
			this.logger.isVerbose = isVerbose;
		}

		this.bench.setVerboseOutput(isVerbose);

		if (this.writer) {
			this.writer.setVerboseOutput(isVerbose);
		}

		if (this.errorHandler) {
			this.errorHandler.isVerbose = isVerbose;
		}

		// Set verbose mode in config file
		this.eleventyConfig.verbose = isVerbose;
	}

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

		return this.#templateFormats;
	}

	/**
	 * Updates the template formats of Eleventy.
	 *
	 * @method
	 * @param {string} formats - The new template formats.
	 */
	setFormats(formats) {
		this.templateFormats.setViaCommandLine(formats);
	}

	/**
	 * Updates the run mode of Eleventy.
	 *
	 * @method
	 * @param {string} runMode - One of "build", "watch", or "serve"
	 */
	setRunMode(runMode) {
		this.runMode = runMode;
	}

	/**
	 * Set the file that needs to be rendered/compiled/written for an incremental build.
	 * This method is also wired up to the CLI --incremental=incrementalFile
	 *
	 * @method
	 * @param {string} incrementalFile - File path (added or modified in a project)
	 */
	setIncrementalFile(incrementalFile) {
		if (incrementalFile) {
			// This used to also setIgnoreInitial(true) but was changed in 3.0.0-alpha.14
			this.setIncrementalBuild(true);

			this.programmaticApiIncrementalFile = TemplatePath.addLeadingDotSlash(incrementalFile);

			this.eleventyConfig.setPreviousBuildModifiedFile(incrementalFile);
		}
	}

	unsetIncrementalFile() {
		// only applies to initial build, no re-runs (--watch/--serve)
		if (this.programmaticApiIncrementalFile) {
			// this.setIgnoreInitial(false);
			this.programmaticApiIncrementalFile = undefined;
		}

		// reset back to false
		this.setIgnoreInitial(false);
	}

	/**
	 * Reads the version of Eleventy.
	 *
	 * @static
	 * @returns {string} - The version of Eleventy.
	 */
	static getVersion() {
		return pkg.version;
	}

	/**
	 * @deprecated since 1.0.1, use static Eleventy.getVersion()
	 */
	getVersion() {
		return Eleventy.getVersion();
	}

	/**
	 * Shows a help message including usage.
	 *
	 * @static
	 * @returns {string} - The help message.
	 */
	static getHelp() {
		return `Usage: eleventy
       eleventy --input=. --output=./_site
       eleventy --serve

Arguments:

     --version

     --input=.
       Input template files (default: \`.\`)

     --output=_site
       Write HTML output to this folder (default: \`_site\`)

     --serve
       Run web server on --port (default 8080) and watch them too

     --port
       Run the --serve web server on this port (default 8080)

     --watch
       Wait for files to change and automatically rewrite (no web server)

     --incremental
       Only build the files that have changed. Best with watch/serve.

     --incremental=filename.md
       Does not require watch/serve. Run an incremental build targeting a single file.

     --ignore-initial
       Start without a build; build when files change. Works best with watch/serve/incremental.

     --formats=liquid,md
       Allow only certain template types (default: \`*\`)

     --quiet
       Don’t print all written files (off by default)

     --config=filename.js
       Override the eleventy config file path (default: \`.eleventy.js\`)

     --pathprefix='/'
       Change all url template filters to use this subdirectory.

     --dryrun
       Don’t write any files. Useful in DEBUG mode, for example: \`DEBUG=Eleventy* npx @11ty/eleventy --dryrun\`

     --loader
       Set to "esm" to force ESM mode, "cjs" to force CommonJS mode, or "auto" (default) to infer it from package.json.

     --to=json
     --to=ndjson
       Change the output to JSON or NDJSON (default: \`fs\`)

     --help`;
	}

	/**
	 * @deprecated since 1.0.1, use static Eleventy.getHelp()
	 */
	getHelp() {
		return Eleventy.getHelp();
	}

	/**
	 * Resets the config of Eleventy.
	 *
	 * @method
	 */
	resetConfig() {
		delete this.eleventyConfig;

		// ensures `initializeConfig()` will run when `init()` is called next
		this.#hasConfigInitialized = false;
	}

	/**
	 * @param {string} changedFilePath - File that triggered a re-run (added or modified)
	 * @param {boolean} [isResetConfig] - are we doing a config reset
	 */
	async #addFileToWatchQueue(changedFilePath, isResetConfig) {
		// Currently this is only for 11ty.js deps but should be extended with usesGraph
		let usedByDependants = [];
		if (this.watchTargets) {
			usedByDependants = this.watchTargets.getDependantsOf(
				TemplatePath.addLeadingDotSlash(changedFilePath),
			);
		}

		let relevantLayouts = this.eleventyConfig.usesGraph.getLayoutsUsedBy(changedFilePath);

		// `eleventy.templateModified` is no longer used internally, remove in a future major version.
		eventBus.emit("eleventy.templateModified", changedFilePath, {
			usedByDependants,
			relevantLayouts,
		});

		// These listeners are *global*, not cleared even on config reset
		eventBus.emit("eleventy.resourceModified", changedFilePath, usedByDependants, {
			viaConfigReset: isResetConfig,
			relevantLayouts,
		});

		this.config.events.emit("eleventy#templateModified", changedFilePath);

		this.watchManager.addToPendingQueue(changedFilePath);
	}

	shouldTriggerConfigReset(changedFiles) {
		let configFilePaths = new Set(this.eleventyConfig.getLocalProjectConfigFiles());
		let resetConfigGlobs = EleventyWatchTargets.normalizeToGlobs(
			Array.from(this.eleventyConfig.userConfig.watchTargetsConfigReset),
		);
		for (let filePath of changedFiles) {
			if (configFilePaths.has(filePath)) {
				return true;
			}
			if (isGlobMatch(filePath, resetConfigGlobs)) {
				return true;
			}
		}

		for (const configFilePath of configFilePaths) {
			// Any dependencies of the config file changed
			let configFileDependencies = new Set(this.watchTargets.getDependenciesOf(configFilePath));

			for (let filePath of changedFiles) {
				if (configFileDependencies.has(filePath)) {
					return true;
				}
			}
		}

		return false;
	}

	// Checks the build queue to see if any configuration related files have changed
	#shouldResetConfig(activeQueue = []) {
		if (!activeQueue.length) {
			return false;
		}

		return this.shouldTriggerConfigReset(
			activeQueue.map((path) => {
				return PathNormalizer.normalizeSeperator(TemplatePath.addLeadingDotSlash(path));
			}),
		);
	}

	async #watch(isResetConfig = false) {
		if (this.watchManager.isBuildRunning()) {
			return;
		}

		this.watchManager.setBuildRunning();

		let queue = this.watchManager.getActiveQueue();

		await this.config.events.emit("beforeWatch", queue);
		await this.config.events.emit("eleventy.beforeWatch", queue);

		// Clear `import` cache for all files that triggered the rebuild (sync event)
		this.watchTargets.clearImportCacheFor(queue);

		// reset and reload global configuration
		if (isResetConfig) {
			// important: run this before config resets otherwise the handlers will disappear.
			await this.config.events.emit("eleventy.reset");
			this.resetConfig();
		}

		await this.restart();
		await this.init({ viaConfigReset: isResetConfig });

		try {
			let [passthroughCopyResults, templateResults] = await this.write();

			this.watchTargets.reset();

			await this.#initWatchDependencies();

			// Add new deps to chokidar
			this.watcher.add(this.watchTargets.getNewTargetsSinceLastReset());

			// Is a CSS input file and is not in the includes folder
			// TODO check output path file extension of this template (not input path)
			// TODO add additional API for this, maybe a config callback?
			let onlyCssChanges = this.watchManager.hasAllQueueFiles((path) => {
				return (
					path.endsWith(".css") &&
					// TODO how to make this work with relative includes?
					!TemplatePath.startsWithSubPath(path, this.eleventyFiles.getIncludesDir())
				);
			});

			let files = this.watchManager.getActiveQueue();

			// Maps passthrough copy files to output URLs for CSS live reload
			let stylesheetUrls = new Set();
			for (let entry of passthroughCopyResults) {
				for (let filepath in entry.map) {
					if (
						filepath.endsWith(".css") &&
						files.includes(TemplatePath.addLeadingDotSlash(filepath))
					) {
						stylesheetUrls.add(
							"/" + TemplatePath.stripLeadingSubPath(entry.map[filepath], this.outputDir),
						);
					}
				}
			}

			let normalizedPathPrefix = PathPrefixer.normalizePathPrefix(this.config.pathPrefix);
			let matchingTemplates = templateResults
				.flat()
				.filter((entry) => Boolean(entry))
				.map((entry) => {
					// only `url`, `inputPath`, and `content` are used: https://github.com/11ty/eleventy-dev-server/blob/1c658605f75224fdc76f68aebe7a412eeb4f1bc9/client/reload-client.js#L140
					entry.url = PathPrefixer.joinUrlParts(normalizedPathPrefix, entry.url);
					delete entry.rawInput; // Issue #3481
					return entry;
				});

			await this.eleventyServe.reload({
				files,
				subtype: onlyCssChanges ? "css" : undefined,
				build: {
					stylesheets: Array.from(stylesheetUrls),
					templates: matchingTemplates,
				},
			});
		} catch (error) {
			this.eleventyServe.sendError({
				error,
			});
		}

		this.watchManager.setBuildFinished();

		let queueSize = this.watchManager.getPendingQueueSize();
		if (queueSize > 0) {
			this.logger.log(
				`You saved while Eleventy was running, let’s run again. (${queueSize} change${
					queueSize !== 1 ? "s" : ""
				})`,
			);
			await this.#watch();
		} else {
			this.logger.log("Watching…");
		}
	}

	/**
	 * @returns {module:11ty/eleventy/src/Benchmark/BenchmarkGroup~BenchmarkGroup}
	 */
	get watcherBench() {
		return this.bench.get("Watcher");
	}

	/**
	 * Set up watchers and benchmarks.
	 *
	 * @async
	 * @method
	 */
	async initWatch() {
		this.watchManager = new EleventyWatch();
		this.watchManager.incremental = this.isIncremental;

		if (this.projectPackageJsonPath) {
			this.watchTargets.add([
				path.relative(TemplatePath.getWorkingDir(), this.projectPackageJsonPath),
			]);
		}
		this.watchTargets.add(this.eleventyFiles.getGlobWatcherFiles());
		this.watchTargets.add(this.eleventyFiles.getIgnoreFiles());

		// Watch the local project config file
		this.watchTargets.add(this.eleventyConfig.getLocalProjectConfigFiles());

		// Template and Directory Data Files
		this.watchTargets.add(await this.eleventyFiles.getGlobWatcherTemplateDataFiles());

		let benchmark = this.watcherBench.get(
			"Watching JavaScript Dependencies (disable with `eleventyConfig.setWatchJavaScriptDependencies(false)`)",
		);
		benchmark.before();
		await this.#initWatchDependencies();
		benchmark.after();
	}

	// fetch from project’s package.json
	get projectPackageJsonPath() {
		if (this.#projectPackageJsonPath === undefined) {
			this.#projectPackageJsonPath = getWorkingProjectPackageJsonPath() || false;
		}
		return this.#projectPackageJsonPath;
	}

	get projectPackageJson() {
		if (!this.#projectPackageJson) {
			let p = this.projectPackageJsonPath;
			this.#projectPackageJson = p ? importJsonSync(p) : {};
		}
		return this.#projectPackageJson;
	}

	get isEsm() {
		if (this.#isEsm !== undefined) {
			return this.#isEsm;
		}
		if (this.loader == "esm") {
			this.#isEsm = true;
		} else if (this.loader == "cjs") {
			this.#isEsm = false;
		} else if (this.loader == "auto") {
			this.#isEsm = this.projectPackageJson?.type === "module";
		} else {
			throw new Error("The 'loader' option must be one of 'esm', 'cjs', or 'auto'");
		}
		return this.#isEsm;
	}

	/**
	 * Starts watching dependencies.
	 */
	async #initWatchDependencies() {
		if (!this.eleventyConfig.shouldSpiderJavaScriptDependencies()) {
			return;
		}

		// TODO use DirContains
		let dataDir = TemplatePath.stripLeadingDotSlash(this.templateData.getDataDir());
		function filterOutGlobalDataFiles(path) {
			return !dataDir || !TemplatePath.stripLeadingDotSlash(path).startsWith(dataDir);
		}

		// Lazy resolve isEsm only for --watch
		this.watchTargets.setProjectUsingEsm(this.isEsm);

		// Template files .11ty.js
		let templateFiles = await this.eleventyFiles.getWatchPathCache();
		await this.watchTargets.addDependencies(templateFiles);

		// Config file dependencies
		await this.watchTargets.addDependencies(
			this.eleventyConfig.getLocalProjectConfigFiles(),
			filterOutGlobalDataFiles,
		);

		// Deps from Global Data (that aren’t in the global data directory, everything is watched there)
		let globalDataDeps = this.templateData.getWatchPathCache();
		await this.watchTargets.addDependencies(globalDataDeps, filterOutGlobalDataFiles);

		await this.watchTargets.addDependencies(
			await this.eleventyFiles.getWatcherTemplateJavaScriptDataFiles(),
		);
	}

	/**
	 * Returns all watched files.
	 *
	 * @async
	 * @method
	 * @returns {Promise<Array>} targets - The watched files.
	 */
	async getWatchedFiles() {
		return this.watchTargets.getTargets();
	}

	getChokidarConfig() {
		let ignores = this.eleventyFiles.getGlobWatcherIgnores();
		debug("Ignoring watcher changes to: %o", ignores);

		let configOptions = this.config.chokidarConfig;

		// can’t override these yet
		// TODO maybe if array, merge the array?
		delete configOptions.ignored;

		return Object.assign(
			{
				ignored: ignores,
				ignoreInitial: true,
				awaitWriteFinish: {
					stabilityThreshold: 150,
					pollInterval: 25,
				},
			},
			configOptions,
		);
	}

	/**
	 * Start the watching of files
	 *
	 * @async
	 * @method
	 */
	async watch() {
		this.watcherBench.setMinimumThresholdMs(500);
		this.watcherBench.reset();

		// We use a string module name and try/catch here to hide this from the zisi and esbuild serverless bundlers
		let chokidar;
		// eslint-disable-next-line no-useless-catch
		try {
			let moduleName = "chokidar";
			let chokidarImport = await import(moduleName);
			chokidar = chokidarImport.default;
		} catch (e) {
			throw e;
		}

		// Note that watching indirectly depends on this for fetching dependencies from JS files
		// See: TemplateWriter:pathCache and EleventyWatchTargets
		await this.write();

		let initWatchBench = this.watcherBench.get("Start up --watch");
		initWatchBench.before();

		await this.initWatch();

		// TODO improve unwatching if JS dependencies are removed (or files are deleted)
		let rawFiles = await this.getWatchedFiles();
		debug("Watching for changes to: %o", rawFiles);

		let options = this.getChokidarConfig();

		// Remap all paths to `cwd` if in play (Issue #3854)
		let remapper = new FileSystemRemap(rawFiles);
		let cwd = remapper.getCwd();

		if (cwd) {
			options.cwd = cwd;

			rawFiles = remapper.getInput().map((entry) => {
				return TemplatePath.stripLeadingDotSlash(entry);
			});

			options.ignored = remapper.getRemapped(options.ignored || []).map((entry) => {
				return TemplatePath.stripLeadingDotSlash(entry);
			});
		}

		let watcher = chokidar.watch(rawFiles, options);

		initWatchBench.after();

		this.watcherBench.finish("Watch");

		this.logger.forceLog("Watching…");

		this.watcher = watcher;

		let watchDelay;
		let watchRun = async (path) => {
			path = TemplatePath.normalize(path);
			try {
				let isResetConfig = this.#shouldResetConfig([path]);
				this.#addFileToWatchQueue(path, isResetConfig);

				clearTimeout(watchDelay);

				let { promise, resolve, reject } = withResolvers();

				watchDelay = setTimeout(async () => {
					this.#watch(isResetConfig).then(resolve, reject);
				}, this.config.watchThrottleWaitTime);

				await promise;
			} catch (e) {
				if (e instanceof EleventyBaseError) {
					this.errorHandler.error(e, "Eleventy watch error");
					this.watchManager.setBuildFinished();
				} else {
					this.errorHandler.fatal(e, "Eleventy fatal watch error");
					await this.stopWatch();
				}
			}

			this.config.events.emit("eleventy.afterwatch");
		};

		watcher.on("change", async (path) => {
			// Emulated passthrough copy logs from the server
			if (!this.eleventyServe.isEmulatedPassthroughCopyMatch(path)) {
				this.logger.forceLog(`File changed: ${TemplatePath.standardizeFilePath(path)}`);
			}

			await watchRun(path);
		});

		watcher.on("add", async (path) => {
			// Emulated passthrough copy logs from the server
			if (!this.eleventyServe.isEmulatedPassthroughCopyMatch(path)) {
				this.logger.forceLog(`File added: ${TemplatePath.standardizeFilePath(path)}`);
			}

			this.fileSystemSearch.add(path);
			await watchRun(path);
		});

		watcher.on("unlink", (path) => {
			this.logger.forceLog(`File deleted: ${TemplatePath.standardizeFilePath(path)}`);
			this.fileSystemSearch.delete(path);
		});

		// wait for chokidar to be ready.
		await new Promise((resolve) => {
			watcher.on("ready", () => resolve());
		});

		// Returns for testability
		return watchRun;
	}

	async stopWatch() {
		// Prevent multiple invocations.
		if (this.#isStopping) {
			return this.#isStopping;
		}

		debug("Cleaning up chokidar and server instances, if they exist.");
		this.#isStopping = Promise.all([this.eleventyServe.close(), this.watcher?.close()]).then(() => {
			this.#isStopping = false;
		});

		return this.#isStopping;
	}

	/**
	 * Serve Eleventy on this port.
	 *
	 * @param {Number} port - The HTTP port to serve Eleventy from.
	 */
	async serve(port) {
		// Port is optional and in this case likely via --port on the command line
		// May defer to configuration API options `port` property
		return this.eleventyServe.serve(port);
	}

	/**
	 * Writes templates to the file system.
	 *
	 * @async
	 * @method
	 * @returns {Promise<{Array}>}
	 */
	async write() {
		return this.executeBuild("fs");
	}

	/**
	 * Renders templates to a JSON object.
	 *
	 * @async
	 * @method
	 * @returns {Promise<{Array}>}
	 */
	async toJSON() {
		return this.executeBuild("json");
	}

	/**
	 * Returns a stream of new line delimited (NDJSON) objects
	 *
	 * @async
	 * @method
	 * @returns {Promise<{ReadableStream}>}
	 */
	async toNDJSON() {
		return this.executeBuild("ndjson");
	}

	/**
	 * tbd.
	 *
	 * @async
	 * @method
	 * @returns {Promise<{Array,ReadableStream}>} ret - tbd.
	 */
	async executeBuild(to = "fs") {
		if (this.#needsInit) {
			if (!this.#initPromise) {
				this.#initPromise = this.init();
			}
			await this.#initPromise.then(() => {
				// #needsInit also set to false at the end of `init()`
				this.#needsInit = false;
				this.#initPromise = undefined;
			});
		}

		if (!this.writer) {
			throw new Error(
				"Internal error: Eleventy didn’t run init() properly and wasn’t able to create a TemplateWriter.",
			);
		}

		let incrementalFile =
			this.programmaticApiIncrementalFile || this.watchManager?.getIncrementalFile();
		if (incrementalFile) {
			this.writer.setIncrementalFile(incrementalFile);
		}

		let returnObj;
		let hasError = false;

		try {
			let directories = this.directories.getUserspaceInstance();
			let eventsArg = {
				directories,

				// v3.0.0-alpha.6, changed to use `directories` instead (this was only used by serverless plugin)
				inputDir: directories.input,

				// Deprecated (not normalized) use `directories` instead.
				dir: this.config.dir,

				runMode: this.runMode,
				outputMode: to,
				incremental: this.isIncremental,
			};

			await this.config.events.emit("beforeBuild", eventsArg);
			await this.config.events.emit("eleventy.before", eventsArg);

			let promise;
			if (to === "fs") {
				promise = this.writer.write();
			} else if (to === "json") {
				promise = this.writer.getJSON("json");
			} else if (to === "ndjson") {
				promise = this.writer.getJSON("ndjson");
			} else {
				throw new Error(
					`Invalid argument for \`Eleventy->executeBuild(${to})\`, expected "json", "ndjson", or "fs".`,
				);
			}

			let resolved = await promise;

			// Passing the processed output to the eleventy.after event (2.0+)
			eventsArg.results = resolved.templates;

			if (to === "ndjson") {
				// return a stream
				// TODO this outputs all ndjson rows after all the templates have been written to the stream
				returnObj = this.logger.closeStream();
			} else if (to === "json") {
				// Backwards compat
				returnObj = resolved.templates;
			} else {
				// Backwards compat
				returnObj = [resolved.passthroughCopy, resolved.templates];
			}

			this.unsetIncrementalFile();
			this.writer.resetIncrementalFile();

			eventsArg.uses = this.eleventyConfig.usesGraph.map;
			await this.config.events.emit("afterBuild", eventsArg);
			await this.config.events.emit("eleventy.after", eventsArg);

			this.buildCount++;
		} catch (error) {
			hasError = true;

			// Issue #2405: Don’t change the exitCode for programmatic scripts
			let errorSeverity = this.source === "script" ? "error" : "fatal";
			this.errorHandler.once(errorSeverity, error, "Problem writing Eleventy templates");

			// TODO ndjson should stream the error but https://github.com/11ty/eleventy/issues/3382
			throw error;
		} finally {
			this.bench.finish();

			if (to === "fs") {
				this.logger.logWithOptions({
					message: this.logFinished(),
					color: hasError ? "red" : "green",
					force: true,
				});
			}

			debug("Finished.");

			debug(`
Have a suggestion/feature request/feedback? Feeling frustrated? I want to hear it!
Open an issue: https://github.com/11ty/eleventy/issues/new`);
		}

		return returnObj;
	}
}

export default Eleventy;

// extend for exporting to CJS
Object.assign(RenderPlugin, RenderPluginExtras);
Object.assign(I18nPlugin, I18nPluginExtras);
Object.assign(HtmlBasePlugin, HtmlBasePluginExtras);

// Removed plugins

const EleventyServerlessBundlerPlugin = function () {
	throw new Error(
		"Following feedback from our Community Survey, low interest in this plugin prompted its removal from Eleventy core in 3.0 as we refocus on static sites. Learn more: https://v3.11ty.dev/docs/plugins/serverless/",
	);
};

const EleventyEdgePlugin = function () {
	throw new Error(
		"Following feedback from our Community Survey, low interest in this plugin prompted its removal from Eleventy core in 3.0 as we refocus on static sites. Learn more: https://v3.11ty.dev/docs/plugins/edge/",
	);
};

export {
	Eleventy,
	EleventyImport as ImportFile,

	// Error messages for removed plugins
	EleventyServerlessBundlerPlugin as EleventyServerless,
	EleventyServerlessBundlerPlugin,
	EleventyEdgePlugin,

	/**
	 * @type {module:11ty/eleventy/Plugins/RenderPlugin}
	 */
	RenderPlugin as EleventyRenderPlugin, // legacy name
	/**
	 * @type {module:11ty/eleventy/Plugins/RenderPlugin}
	 */
	RenderPlugin,

	/**
	 * @type {module:11ty/eleventy/Plugins/I18nPlugin}
	 */
	I18nPlugin as EleventyI18nPlugin, // legacy name
	/**
	 * @type {module:11ty/eleventy/Plugins/I18nPlugin}
	 */
	I18nPlugin,

	/**
	 * @type {module:11ty/eleventy/Plugins/HtmlBasePlugin}
	 */
	HtmlBasePlugin as EleventyHtmlBasePlugin, // legacy name
	/**
	 * @type {module:11ty/eleventy/Plugins/HtmlBasePlugin}
	 */
	HtmlBasePlugin,

	/**
	 * @type {module:11ty/eleventy/Plugins/InputPathToUrlTransformPlugin}
	 */
	InputPathToUrlTransformPlugin,

	/**
	 * @type {module:11ty/eleventy-plugin-bundle}
	 */
	BundlePlugin,

	/**
	 * @type {module:11ty/eleventy/Plugins/IdAttributePlugin}
	 */
	IdAttributePlugin,
};
