import TemplateEngine from "./TemplateEngine.js";
import getJavaScriptData from "../Util/GetJavaScriptData.js";

export default class CustomEngine extends TemplateEngine {
	constructor(name, eleventyConfig) {
		super(name, eleventyConfig);

		this.entry = this.getExtensionMapEntry();
		this.needsInit = "init" in this.entry && typeof this.entry.init === "function";

		this.setDefaultEngine(undefined);
	}

	getExtensionMapEntry() {
		if ("extensionMap" in this.config) {
			let name = this.name.toLowerCase();
			// Iterates over only the user config `addExtension` entries
			for (let entry of this.config.extensionMap) {
				let entryKey = (entry.aliasKey || entry.key || "").toLowerCase();
				if (entryKey === name) {
					return entry;
				}
			}
		}

		throw Error(
			`Could not find a custom extension for ${this.name}. Did you add it to your config file?`,
		);
	}

	setDefaultEngine(defaultEngine) {
		this._defaultEngine = defaultEngine;
	}

	get cacheable() {
		// Enable cacheability for this template
		if (this.entry?.compileOptions?.cache !== undefined) {
			return this.entry.compileOptions.cache;
		} else if (this.needsToReadFileContents()) {
			return true;
		} else if (this._defaultEngine?.cacheable !== undefined) {
			return this._defaultEngine.cacheable;
		}

		return super.cacheable;
	}

	async getInstanceFromInputPath(inputPath) {
		if (
			"getInstanceFromInputPath" in this.entry &&
			typeof this.entry.getInstanceFromInputPath === "function"
		) {
			// returns Promise
			return this.entry.getInstanceFromInputPath(inputPath);
		}

		// aliased upstream type
		if (
			this._defaultEngine &&
			"getInstanceFromInputPath" in this._defaultEngine &&
			typeof this._defaultEngine.getInstanceFromInputPath === "function"
		) {
			// returns Promise
			return this._defaultEngine.getInstanceFromInputPath(inputPath);
		}

		return false;
	}

	/**
	 * Whether to use the module loader directly
	 *
	 * @override
	 */
	useJavaScriptImport() {
		if ("useJavaScriptImport" in this.entry) {
			return this.entry.useJavaScriptImport;
		}

		if (
			this._defaultEngine &&
			"useJavaScriptImport" in this._defaultEngine &&
			typeof this._defaultEngine.useJavaScriptImport === "function"
		) {
			return this._defaultEngine.useJavaScriptImport();
		}

		return false;
	}

	/**
	 * @override
	 */
	needsToReadFileContents() {
		if ("read" in this.entry) {
			return this.entry.read;
		}

		// Handle aliases to `11ty.js` templates, avoid reading files in the alias, see #2279
		// Here, we are short circuiting fallback to defaultRenderer, does not account for compile
		// functions that call defaultRenderer explicitly
		if (this._defaultEngine && "needsToReadFileContents" in this._defaultEngine) {
			return this._defaultEngine.needsToReadFileContents();
		}

		return true;
	}

	// If we init from multiple places, wait for the first init to finish before continuing on.
	async _runningInit() {
		if (this.needsInit) {
			if (!this._initing) {
				this._initBench = this.benchmarks.aggregate.get(`Engine (${this.name}) Init`);
				this._initBench.before();
				this._initing = this.entry.init.bind({
					config: this.config,
					bench: this.benchmarks.aggregate,
				})();
			}
			await this._initing;
			this.needsInit = false;

			if (this._initBench) {
				this._initBench.after();
				this._initBench = undefined;
			}
		}
	}

	async getExtraDataFromFile(inputPath) {
		if (this.entry.getData === false) {
			return;
		}

		if (!("getData" in this.entry)) {
			// Handle aliases to `11ty.js` templates, use upstream default engine data fetch, see #2279
			if (this._defaultEngine && "getExtraDataFromFile" in this._defaultEngine) {
				return this._defaultEngine.getExtraDataFromFile(inputPath);
			}

			return;
		}

		await this._runningInit();

		if (typeof this.entry.getData === "function") {
			let dataBench = this.benchmarks.aggregate.get(
				`Engine (${this.name}) Get Data From File (Function)`,
			);
			dataBench.before();
			let data = this.entry.getData(inputPath);
			dataBench.after();
			return data;
		}

		let keys = new Set();
		if (this.entry.getData === true) {
			keys.add("data");
		} else if (Array.isArray(this.entry.getData)) {
			for (let key of this.entry.getData) {
				keys.add(key);
			}
		}

		let dataBench = this.benchmarks.aggregate.get(`Engine (${this.name}) Get Data From File`);
		dataBench.before();

		let inst = await this.getInstanceFromInputPath(inputPath);

		if (inst === false) {
			dataBench.after();

			return Promise.reject(
				new Error(
					`\`getInstanceFromInputPath\` callback missing from '${this.name}' template engine plugin. It is required when \`getData\` is in use. You can set \`getData: false\` to opt-out of this.`,
				),
			);
		}

		// override keys set at the plugin level in the individual template
		if (inst.eleventyDataKey) {
			keys = new Set(inst.eleventyDataKey);
		}

		let mixins;
		if (this.config) {
			// Object.assign usage: see TemplateRenderCustomTest.js: `JavaScript functions should not be mutable but not *that* mutable`
			mixins = Object.assign({}, this.config.javascriptFunctions);
		}

		let promises = [];
		for (let key of keys) {
			promises.push(
				getJavaScriptData(inst, inputPath, key, {
					mixins,
					isObjectRequired: key === "data",
				}),
			);
		}

		let results = await Promise.all(promises);
		let data = {};
		for (let result of results) {
			Object.assign(data, result);
		}
		dataBench.after();

		return data;
	}

	async compile(str, inputPath, ...args) {
		await this._runningInit();
		let defaultCompilationFn;
		if (this._defaultEngine) {
			defaultCompilationFn = async (data) => {
				const renderFn = await this._defaultEngine.compile(str, inputPath, ...args);
				return renderFn(data);
			};
		}

		// Fall back to default compiler if the user does not provide their own
		if (!this.entry.compile) {
			if (defaultCompilationFn) {
				return defaultCompilationFn;
			} else {
				throw new Error(
					`Missing \`compile\` property for custom template syntax definition eleventyConfig.addExtension("${this.name}"). This is not necessary when aliasing to an existing template syntax.`,
				);
			}
		}

		// TODO generalize this (look at JavaScript.js)
		let compiledFn = this.entry.compile.bind({
			config: this.config,
			addDependencies: (from, toArray = []) => {
				this.config.uses.addDependency(from, toArray);
			},
			defaultRenderer: defaultCompilationFn, // bind defaultRenderer to compile function
		})(str, inputPath);

		// Support `undefined` to skip compile/render
		if (compiledFn) {
			// Bind defaultRenderer to render function
			if ("then" in compiledFn && typeof compiledFn.then === "function") {
				// Promise, wait to bind
				return compiledFn.then((fn) => {
					if (typeof fn === "function") {
						return fn.bind({
							defaultRenderer: defaultCompilationFn,
						});
					}
					return fn;
				});
			} else if ("bind" in compiledFn && typeof compiledFn.bind === "function") {
				return compiledFn.bind({
					defaultRenderer: defaultCompilationFn,
				});
			}
		}

		return compiledFn;
	}

	get defaultTemplateFileExtension() {
		return this.entry.outputFileExtension ?? "html";
	}

	// Whether or not to wrap in Eleventy layouts
	useLayouts() {
		// TODO future change fallback to `this.defaultTemplateFileExtension === "html"`
		return this.entry.useLayouts ?? true;
	}

	hasDependencies(inputPath) {
		if (this.config.uses.getDependencies(inputPath) === false) {
			return false;
		}
		return true;
	}

	isFileRelevantTo(inputPath, comparisonFile, includeLayouts) {
		return this.config.uses.isFileRelevantTo(inputPath, comparisonFile, includeLayouts);
	}

	getCompileCacheKey(str, inputPath) {
		let lastModifiedFile = this.eleventyConfig.getPreviousBuildModifiedFile();
		// Return this separately so we know whether or not to use the cached version
		// but still return a key to cache this new render for next time
		let isRelevant = this.isFileRelevantTo(inputPath, lastModifiedFile, false);
		let useCache = !isRelevant;

		if (this.entry.compileOptions && "getCacheKey" in this.entry.compileOptions) {
			if (typeof this.entry.compileOptions.getCacheKey !== "function") {
				throw new Error(
					`\`compileOptions.getCacheKey\` must be a function in addExtension for the ${this.name} type`,
				);
			}

			return {
				useCache,
				key: this.entry.compileOptions.getCacheKey(str, inputPath),
			};
		}

		let { key } = super.getCompileCacheKey(str, inputPath);
		return {
			useCache,
			key,
		};
	}

	permalinkNeedsCompilation(/*str*/) {
		if (this.entry.compileOptions && "permalink" in this.entry.compileOptions) {
			let p = this.entry.compileOptions.permalink;
			if (p === "raw") {
				return false;
			}

			// permalink: false is aliased to permalink: () => false
			if (p === false) {
				return () => false;
			}

			return this.entry.compileOptions.permalink;
		}

		// Breaking: default changed from `true` to `false` in 3.0.0-alpha.13
		// Note: `false` is the same as "raw" here.
		return false;
	}

	static shouldSpiderJavaScriptDependencies(entry) {
		if (entry.compileOptions && "spiderJavaScriptDependencies" in entry.compileOptions) {
			return entry.compileOptions.spiderJavaScriptDependencies;
		}

		return false;
	}
}
