import os from "node:os";

import fs from "node:fs";
import matter from "gray-matter";
import lodash from "@11ty/lodash-custom";
import { TemplatePath } from "@11ty/eleventy-utils";
import debugUtil from "debug";

import TemplateData from "./Data/TemplateData.js";
import TemplateRender from "./TemplateRender.js";
import EleventyBaseError from "./Errors/EleventyBaseError.js";
import EleventyErrorUtil from "./Errors/EleventyErrorUtil.js";
import eventBus from "./EventBus.js";

import { withResolvers } from "./Util/PromiseUtil.js";

const { set: lodashSet } = lodash;
const debug = debugUtil("Eleventy:TemplateContent");
const debugDev = debugUtil("Dev:Eleventy:TemplateContent");

class TemplateContentFrontMatterError extends EleventyBaseError {}
class TemplateContentCompileError extends EleventyBaseError {}
class TemplateContentRenderError extends EleventyBaseError {}

class TemplateContent {
	#initialized = false;
	#config;
	#templateRender;
	#preprocessorEngine;
	#extensionMap;
	#configOptions;

	constructor(inputPath, templateConfig) {
		if (!templateConfig || templateConfig.constructor.name !== "TemplateConfig") {
			throw new Error("Missing or invalid `templateConfig` argument");
		}
		this.eleventyConfig = templateConfig;
		this.inputPath = inputPath;
	}

	async asyncTemplateInitialization() {
		if (!this.hasTemplateRender()) {
			await this.getTemplateRender();
		}

		if (this.#initialized) {
			return;
		}
		this.#initialized = true;

		let preprocessorEngineName = this.templateRender.getPreprocessorEngineName();
		if (preprocessorEngineName && this.templateRender.engine.getName() !== preprocessorEngineName) {
			let engine = await this.templateRender.getEngineByName(preprocessorEngineName);
			this.#preprocessorEngine = engine;
		}
	}

	resetCachedTemplate({ eleventyConfig }) {
		this.eleventyConfig = eleventyConfig;
	}

	get dirs() {
		return this.eleventyConfig.directories;
	}

	get inputDir() {
		return this.dirs.input;
	}

	get outputDir() {
		return this.dirs.output;
	}

	getResetTypes(types) {
		if (types) {
			return Object.assign(
				{
					data: false,
					read: false,
					render: false,
				},
				types,
			);
		}

		return {
			data: true,
			read: true,
			render: true,
		};
	}

	// Called during an incremental build when the template instance is cached but needs to be reset because it has changed
	resetCaches(types) {
		types = this.getResetTypes(types);

		if (types.read) {
			delete this.readingPromise;
			delete this.inputContent;
			delete this._frontMatterDataCache;
		}
		if (types.render) {
			this.#templateRender = undefined;
		}
	}

	get extensionMap() {
		if (!this.#extensionMap) {
			throw new Error("Internal error: Missing `extensionMap` in TemplateContent.");
		}
		return this.#extensionMap;
	}

	set extensionMap(map) {
		this.#extensionMap = map;
	}

	set eleventyConfig(config) {
		this.#config = config;

		if (this.#config.constructor.name === "TemplateConfig") {
			this.#configOptions = this.#config.getConfig();
		} else {
			throw new Error("Tried to get an TemplateConfig but none was found.");
		}
	}

	get eleventyConfig() {
		if (this.#config.constructor.name === "TemplateConfig") {
			return this.#config;
		}
		throw new Error("Tried to get an TemplateConfig but none was found.");
	}

	get config() {
		if (this.#config.constructor.name === "TemplateConfig" && !this.#configOptions) {
			this.#configOptions = this.#config.getConfig();
		}

		return this.#configOptions;
	}

	get bench() {
		return this.config.benchmarkManager.get("Aggregate");
	}

	get engine() {
		return this.templateRender.engine;
	}

	get templateRender() {
		if (!this.hasTemplateRender()) {
			throw new Error(`\`templateRender\` has not yet initialized on ${this.inputPath}`);
		}

		return this.#templateRender;
	}

	hasTemplateRender() {
		return !!this.#templateRender;
	}

	async getTemplateRender() {
		if (!this.#templateRender) {
			this.#templateRender = new TemplateRender(this.inputPath, this.eleventyConfig);
			this.#templateRender.extensionMap = this.extensionMap;

			return this.#templateRender.init().then(() => {
				return this.#templateRender;
			});
		}

		return this.#templateRender;
	}

	// For monkey patchers
	get frontMatter() {
		if (this.frontMatterOverride) {
			return this.frontMatterOverride;
		} else {
			throw new Error(
				"Unfortunately you’re using code that monkey patched some Eleventy internals and it isn’t async-friendly. Change your code to use the async `read()` method on the template instead!",
			);
		}
	}

	// For monkey patchers
	set frontMatter(contentOverride) {
		this.frontMatterOverride = contentOverride;
	}

	getInputPath() {
		return this.inputPath;
	}

	getInputDir() {
		return this.inputDir;
	}

	isVirtualTemplate() {
		let def = this.getVirtualTemplateDefinition();
		return !!def;
	}

	getVirtualTemplateDefinition() {
		let inputDirRelativeInputPath =
			this.eleventyConfig.directories.getInputPathRelativeToInputDirectory(this.inputPath);
		return this.config.virtualTemplates[inputDirRelativeInputPath];
	}

	async #read() {
		let content = await this.inputContent;

		if (content || content === "") {
			let tr = await this.getTemplateRender();
			if (tr.engine.useJavaScriptImport()) {
				return {
					data: {},
					content,
				};
			}

			let options = this.config.frontMatterParsingOptions || {};
			let fm;
			try {
				// Added in 3.0, passed along to front matter engines
				options.filePath = this.inputPath;
				fm = matter(content, options);
			} catch (e) {
				throw new TemplateContentFrontMatterError(
					`Having trouble reading front matter from template ${this.inputPath}`,
					e,
				);
			}

			if (options.excerpt && fm.excerpt) {
				let excerptString = fm.excerpt + (options.excerpt_separator || "---");
				if (fm.content.startsWith(excerptString + os.EOL)) {
					// with an os-specific newline after excerpt separator
					fm.content = fm.excerpt.trim() + "\n" + fm.content.slice((excerptString + os.EOL).length);
				} else if (fm.content.startsWith(excerptString + "\n")) {
					// with a newline (\n) after excerpt separator
					// This is necessary for some git configurations on windows
					fm.content = fm.excerpt.trim() + "\n" + fm.content.slice((excerptString + 1).length);
				} else if (fm.content.startsWith(excerptString)) {
					// no newline after excerpt separator
					fm.content = fm.excerpt + fm.content.slice(excerptString.length);
				}

				// alias, defaults to page.excerpt
				let alias = options.excerpt_alias || "page.excerpt";
				lodashSet(fm.data, alias, fm.excerpt);
			}

			// For monkey patchers that used `frontMatter` 🤧
			// https://github.com/11ty/eleventy/issues/613#issuecomment-999637109
			// https://github.com/11ty/eleventy/issues/2710#issuecomment-1373854834
			// Removed this._frontMatter monkey patcher help in 3.0.0-alpha.7

			return fm;
		} else {
			return {
				data: {},
				content: "",
				excerpt: "",
			};
		}
	}

	async read() {
		if (!this.readingPromise) {
			if (!this.inputContent) {
				// @cachedproperty
				this.inputContent = this.getInputContent();
			}

			// @cachedproperty
			this.readingPromise = this.#read();
		}

		return this.readingPromise;
	}

	/* Incremental builds cache the Template instances (in TemplateWriter) but
	 * these template specific caches are important for Pagination */
	static cache(path, content) {
		this._inputCache.set(TemplatePath.absolutePath(path), content);
	}

	static getCached(path) {
		return this._inputCache.get(TemplatePath.absolutePath(path));
	}

	static deleteFromInputCache(path) {
		this._inputCache.delete(TemplatePath.absolutePath(path));
	}

	// Used via clone
	setInputContent(content) {
		this.inputContent = content;
	}

	async getInputContent() {
		let tr = await this.getTemplateRender();

		let virtualTemplateDefinition = this.getVirtualTemplateDefinition();
		if (virtualTemplateDefinition) {
			let { content } = virtualTemplateDefinition;
			return content;
		}

		if (
			tr.engine.useJavaScriptImport() &&
			typeof tr.engine.getInstanceFromInputPath === "function"
		) {
			return tr.engine.getInstanceFromInputPath(this.inputPath);
		}

		if (!tr.engine.needsToReadFileContents()) {
			return "";
		}

		let templateBenchmark = this.bench.get("Template Read");
		templateBenchmark.before();

		let content;

		if (this.config.useTemplateCache) {
			content = TemplateContent.getCached(this.inputPath);
		}

		if (!content && content !== "") {
			let contentBuffer = fs.readFileSync(this.inputPath);

			content = contentBuffer.toString("utf8");

			if (this.config.useTemplateCache) {
				TemplateContent.cache(this.inputPath, content);
			}
		}

		templateBenchmark.after();

		return content;
	}

	async _testGetFrontMatter() {
		let fm = this.frontMatterOverride ? this.frontMatterOverride : await this.read();

		return fm;
	}

	async getPreRender() {
		let fm = this.frontMatterOverride ? this.frontMatterOverride : await this.read();

		return fm.content;
	}

	async #getFrontMatterData() {
		let fm = await this.read();

		// gray-matter isn’t async-friendly but can return a promise from custom front matter
		if (fm.data instanceof Promise) {
			fm.data = await fm.data;
		}

		let tr = await this.getTemplateRender();
		let extraData = await tr.engine.getExtraDataFromFile(this.inputPath);

		let virtualTemplateDefinition = this.getVirtualTemplateDefinition();
		let virtualTemplateData;
		if (virtualTemplateDefinition) {
			virtualTemplateData = virtualTemplateDefinition.data;
		}

		let data = Object.assign(fm.data, extraData, virtualTemplateData);

		TemplateData.cleanupData(data, {
			file: this.inputPath,
			isVirtualTemplate: Boolean(virtualTemplateData),
		});

		return {
			data,
			excerpt: fm.excerpt,
		};
	}

	async getFrontMatterData() {
		if (!this._frontMatterDataCache) {
			// @cachedproperty
			this._frontMatterDataCache = this.#getFrontMatterData();
		}

		return this._frontMatterDataCache;
	}

	async getEngineOverride() {
		return this.getFrontMatterData().then((data) => {
			return data[this.config.keys.engineOverride];
		});
	}

	// checks engines
	isTemplateCacheable() {
		if (this.#preprocessorEngine) {
			return this.#preprocessorEngine.cacheable;
		}
		return this.engine.cacheable;
	}

	_getCompileCache(str) {
		// Caches used to be bifurcated based on engine name, now they’re based on inputPath
		// TODO does `cacheable` need to help inform whether a cache is used here?
		let inputPathMap = TemplateContent._compileCache.get(this.inputPath);
		if (!inputPathMap) {
			inputPathMap = new Map();
			TemplateContent._compileCache.set(this.inputPath, inputPathMap);
		}

		let cacheable = this.isTemplateCacheable();
		let { useCache, key } = this.engine.getCompileCacheKey(str, this.inputPath);

		// We also tie the compile cache key to the UserConfig instance, to alleviate issues with global template cache
		// Better to move the cache to the Eleventy instance instead, no?
		// (This specifically failed I18nPluginTest cases with filters being cached across tests and not having access to each plugin’s options)
		key = this.eleventyConfig.userConfig._getUniqueId() + key;

		return [cacheable, key, inputPathMap, useCache];
	}

	async compile(str, options = {}) {
		let { type, bypassMarkdown, engineOverride } = options;

		// Must happen before cacheable fetch below
		// Likely only necessary for Eleventy Layouts, see TemplateMap->initDependencyMap
		await this.asyncTemplateInitialization();

		// this.templateRender is guaranteed here
		let tr = await this.getTemplateRender();
		if (engineOverride !== undefined) {
			debugDev("%o overriding template engine to use %o", this.inputPath, engineOverride);
			await tr.setEngineOverride(engineOverride, bypassMarkdown);
		} else {
			tr.setUseMarkdown(!bypassMarkdown);
		}
		if (bypassMarkdown && !this.engine.needsCompilation(str)) {
			return function () {
				return str;
			};
		}

		debugDev("%o compile() using engine: %o", this.inputPath, tr.engineName);

		try {
			let res;
			if (this.config.useTemplateCache) {
				let [cacheable, key, cache, useCache] = this._getCompileCache(str);
				if (cacheable && key) {
					if (useCache && cache.has(key)) {
						this.bench.get("(count) Template Compile Cache Hit").incrementCount();
						return cache.get(key);
					}

					this.bench.get("(count) Template Compile Cache Miss").incrementCount();

					// Compile cache is cleared when the resource is modified (below)

					// Compilation is async, so we eagerly cache a Promise that eventually
					// resolves to the compiled function
					let withRes = withResolvers();
					res = withRes.resolve;

					cache.set(key, withRes.promise);
				}
			}

			let typeStr = type ? ` ${type}` : "";
			let templateBenchmark = this.bench.get(`Template Compile${typeStr}`);
			let inputPathBenchmark = this.bench.get(`> Compile${typeStr} > ${this.inputPath}`);
			templateBenchmark.before();
			inputPathBenchmark.before();

			let fn = await tr.getCompiledTemplate(str);
			inputPathBenchmark.after();
			templateBenchmark.after();
			debugDev("%o getCompiledTemplate function created", this.inputPath);
			if (this.config.useTemplateCache && res) {
				res(fn);
			}
			return fn;
		} catch (e) {
			let [cacheable, key, cache] = this._getCompileCache(str);
			if (cacheable && key) {
				cache.delete(key);
			}
			debug(`Having trouble compiling template ${this.inputPath}: %O`, str);
			throw new TemplateContentCompileError(
				`Having trouble compiling template ${this.inputPath}`,
				e,
			);
		}
	}

	getParseForSymbolsFunction(str) {
		let engine = this.engine;

		// Don’t use markdown as the engine to parse for symbols
		// TODO pass in engineOverride here
		if (this.#preprocessorEngine) {
			engine = this.#preprocessorEngine;
		}

		if ("parseForSymbols" in engine) {
			return () => {
				if (Array.isArray(str)) {
					return str
						.filter((entry) => typeof entry === "string")
						.map((entry) => engine.parseForSymbols(entry))
						.flat();
				}
				if (typeof str === "string") {
					return engine.parseForSymbols(str);
				}
				return [];
			};
		}
	}

	// used by computed data or for permalink functions
	async _renderFunction(fn, ...args) {
		let mixins = Object.assign({}, this.config.javascriptFunctions);
		let result = await fn.call(mixins, ...args);

		// normalize Buffer away if returned from permalink
		if (Buffer.isBuffer(result)) {
			return result.toString();
		}

		return result;
	}

	async renderComputedData(str, data) {
		if (typeof str === "function") {
			return this._renderFunction(str, data);
		}

		return this._render(str, data, {
			type: "Computed Data",
			bypassMarkdown: true,
		});
	}

	async renderPermalink(permalink, data) {
		let tr = await this.getTemplateRender();
		let permalinkCompilation = tr.engine.permalinkNeedsCompilation(permalink);

		// No string compilation:
		//    ({ compileOptions: { permalink: "raw" }})
		// These mean `permalink: false`, which is no file system writing:
		//    ({ compileOptions: { permalink: false }})
		//    ({ compileOptions: { permalink: () => false }})
		//    ({ compileOptions: { permalink: () => (() = > false) }})
		if (permalinkCompilation === false && typeof permalink !== "function") {
			return permalink;
		}

		/* Custom `compile` function for permalinks, usage:
		permalink: function(permalinkString, inputPath) {
			return async function(data) {
				return "THIS IS MY RENDERED PERMALINK";
			}
		}
		*/
		if (permalinkCompilation && typeof permalinkCompilation === "function") {
			permalink = await this._renderFunction(permalinkCompilation, permalink, this.inputPath);
		}

		// Raw permalink function (in the app code data cascade)
		if (typeof permalink === "function") {
			return this._renderFunction(permalink, data);
		}

		return this._render(permalink, data, {
			type: "Permalink",
			bypassMarkdown: true,
		});
	}

	async render(str, data, bypassMarkdown) {
		return this._render(str, data, {
			type: "Content",
			bypassMarkdown,
		});
	}

	_getPaginationLogSuffix(data) {
		let suffix = [];
		if ("pagination" in data) {
			suffix.push(" (");
			if (data.pagination.pages) {
				suffix.push(
					`${data.pagination.pages.length} page${data.pagination.pages.length !== 1 ? "s" : ""}`,
				);
			} else {
				suffix.push("Pagination");
			}
			suffix.push(")");
		}
		return suffix.join("");
	}

	async _render(str, data, options = {}) {
		let { bypassMarkdown, type } = options;

		try {
			if (bypassMarkdown && !this.engine.needsCompilation(str)) {
				return str;
			}

			let fn = await this.compile(str, {
				bypassMarkdown,
				engineOverride: data[this.config.keys.engineOverride],
				type,
			});

			if (fn === undefined) {
				return;
			} else if (typeof fn !== "function") {
				throw new Error(`The \`compile\` function did not return a function. Received ${fn}`);
			}

			// Benchmark
			let templateBenchmark = this.bench.get("Render");
			let inputPathBenchmark = this.bench.get(
				`> Render${type ? ` ${type}` : ""} > ${this.inputPath}${this._getPaginationLogSuffix(data)}`,
			);

			templateBenchmark.before();
			if (inputPathBenchmark) {
				inputPathBenchmark.before();
			}

			let rendered = await fn(data);

			if (inputPathBenchmark) {
				inputPathBenchmark.after();
			}
			templateBenchmark.after();
			debugDev("%o getCompiledTemplate called, rendered content created", this.inputPath);
			return rendered;
		} catch (e) {
			if (EleventyErrorUtil.isPrematureTemplateContentError(e)) {
				return Promise.reject(e);
			} else {
				let tr = await this.getTemplateRender();
				let engine = tr.getReadableEnginesList();
				debug(`Having trouble rendering ${engine} template ${this.inputPath}: %O`, str);
				return Promise.reject(
					new TemplateContentRenderError(
						`Having trouble rendering ${engine} template ${this.inputPath}`,
						e,
					),
				);
			}
		}
	}

	getExtensionEntries() {
		return this.engine.extensionEntries;
	}

	isFileRelevantToThisTemplate(incrementalFile, metadata = {}) {
		// always relevant if incremental file not set (build everything)
		if (!incrementalFile) {
			return true;
		}

		let hasDependencies = this.engine.hasDependencies(incrementalFile);

		let isRelevant = this.engine.isFileRelevantTo(this.inputPath, incrementalFile);

		debug(
			"Test dependencies to see if %o is relevant to %o: %o",
			this.inputPath,
			incrementalFile,
			isRelevant,
		);

		let extensionEntries = this.getExtensionEntries().filter((entry) => !!entry.isIncrementalMatch);
		if (extensionEntries.length) {
			for (let entry of extensionEntries) {
				if (
					entry.isIncrementalMatch.call(
						{
							inputPath: this.inputPath,
							isFullTemplate: metadata.isFullTemplate,
							isFileRelevantToInputPath: isRelevant,
							doesFileHaveDependencies: hasDependencies,
						},
						incrementalFile,
					)
				) {
					return true;
				}
			}

			return false;
		} else {
			// Not great way of building all templates if this is a layout, include, JS dependency.
			// TODO improve this for default template syntaxes

			// This is the fallback way of determining if something is incremental (no isIncrementalMatch available)
			// This will be true if the inputPath and incrementalFile are the same
			if (isRelevant) {
				return true;
			}

			// only return true here if dependencies are not known
			if (!hasDependencies && !metadata.isFullTemplate) {
				return true;
			}
		}

		return false;
	}
}

TemplateContent._inputCache = new Map();
TemplateContent._compileCache = new Map();
eventBus.on("eleventy.resourceModified", (path) => {
	// delete from input cache
	TemplateContent.deleteFromInputCache(path);

	// delete from compile cache
	let normalized = TemplatePath.addLeadingDotSlash(path);
	let compileCache = TemplateContent._compileCache.get(normalized);
	if (compileCache) {
		compileCache.clear();
	}
});

// Used when the configuration file reset https://github.com/11ty/eleventy/issues/2147
eventBus.on("eleventy.compileCacheReset", () => {
	TemplateContent._compileCache = new Map();
});

export default TemplateContent;
