import { DepGraph as DependencyGraph } from "dependency-graph";
import { isPlainObject, TemplatePath } from "@11ty/eleventy-utils";
import debugUtil from "debug";

import TemplateCollection from "./TemplateCollection.js";
import EleventyErrorUtil from "./Errors/EleventyErrorUtil.js";
import UsingCircularTemplateContentReferenceError from "./Errors/UsingCircularTemplateContentReferenceError.js";
import EleventyBaseError from "./Errors/EleventyBaseError.js";
import DuplicatePermalinkOutputError from "./Errors/DuplicatePermalinkOutputError.js";
import TemplateData from "./Data/TemplateData.js";

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

class TemplateMapConfigError extends EleventyBaseError {}
class EleventyDataSchemaError extends EleventyBaseError {}

// These template URL filenames are allowed to exclude file extensions
const EXTENSIONLESS_URL_ALLOWLIST = [
	"/_redirects", // Netlify specific
	"/.htaccess", // Apache
	"/_headers", // Cloudflare
];

class TemplateMap {
	constructor(eleventyConfig) {
		if (!eleventyConfig) {
			throw new TemplateMapConfigError("Missing config argument.");
		}
		this.eleventyConfig = eleventyConfig;
		this.map = [];
		this.collectionsData = null;
		this.cached = false;
		this.verboseOutput = true;
		this.collection = new TemplateCollection();
	}

	set userConfig(config) {
		this._userConfig = config;
	}

	get userConfig() {
		if (!this._userConfig) {
			// TODO use this.config for this, need to add collections to mergeable props in userconfig
			this._userConfig = this.eleventyConfig.userConfig;
		}

		return this._userConfig;
	}

	get config() {
		if (!this._config) {
			this._config = this.eleventyConfig.getConfig();
		}
		return this._config;
	}

	static get tagPrefix() {
		return "___TAG___";
	}

	async add(template) {
		if (!template) {
			return;
		}

		let data = await template.getData();
		let entries = await template.getTemplateMapEntries(data);

		for (let map of entries) {
			this.map.push(map);
		}
	}

	getMap() {
		return this.map;
	}

	getTagTarget(str) {
		if (str.startsWith("collections.")) {
			return str.slice("collections.".length);
		}
		// Fixes #2851
		if (str.startsWith("collections['") || str.startsWith('collections["')) {
			return str.slice("collections['".length, -2);
		}
	}

	/* ---
	 * pagination:
	 *   data: collections
	 * ---
	 */
	isPaginationOverAllCollections(entry) {
		if (entry.data.pagination?.data) {
			return (
				entry.data.pagination.data === "collections" ||
				entry.data.pagination.data === "collections.all"
			);
		}
	}

	getPaginationTagTarget(entry) {
		if (entry.data.pagination?.data) {
			return this.getTagTarget(entry.data.pagination.data);
		}
	}

	addTagsToGraph(graph, inputPath, tags) {
		if (!Array.isArray(tags)) {
			return;
		}
		for (let tag of tags) {
			let tagWithPrefix = TemplateMap.tagPrefix + tag;
			if (!graph.hasNode(tagWithPrefix)) {
				graph.addNode(tagWithPrefix);
			}

			// Populates to collections.tagName
			// Dependency from tag to inputPath
			graph.addDependency(tagWithPrefix, inputPath);
		}
	}

	addDeclaredDependenciesToGraph(graph, inputPath, deps) {
		if (!Array.isArray(deps)) {
			return;
		}

		for (let tag of deps) {
			let tagWithPrefix = TemplateMap.tagPrefix + tag;
			if (!graph.hasNode(tagWithPrefix)) {
				graph.addNode(tagWithPrefix);
			}

			// Dependency from inputPath to collection/tag
			graph.addDependency(inputPath, tagWithPrefix);
		}
	}

	// Exclude: Pagination templates consuming `collections` or `collections.all`
	// Exclude: Pagination templates that consume config API collections

	// Include: Pagination templates that don’t consume config API collections
	// Include: Templates that don’t use Pagination
	getMappedDependencies() {
		let graph = new DependencyGraph();
		let tagPrefix = TemplateMap.tagPrefix;

		graph.addNode(tagPrefix + "all");

		for (let entry of this.map) {
			if (this.isPaginationOverAllCollections(entry)) {
				continue;
			}

			// using Pagination (but not targeting a user config collection)
			let paginationTagTarget = this.getPaginationTagTarget(entry);
			if (paginationTagTarget) {
				if (this.isUserConfigCollectionName(paginationTagTarget)) {
					// delay this one to the second stage
					continue;
				} else {
					// using pagination but over a tagged collection
					graph.addNode(entry.inputPath);
					if (!graph.hasNode(tagPrefix + paginationTagTarget)) {
						graph.addNode(tagPrefix + paginationTagTarget);
					}
					graph.addDependency(entry.inputPath, tagPrefix + paginationTagTarget);
				}
			} else {
				// not using pagination
				graph.addNode(entry.inputPath);
			}

			let collections = TemplateData.getIncludedCollectionNames(entry.data);
			this.addTagsToGraph(graph, entry.inputPath, collections);

			this.addDeclaredDependenciesToGraph(
				graph,
				entry.inputPath,
				entry.data.eleventyImport?.collections,
			);
		}

		return graph;
	}

	// Exclude: Pagination templates consuming `collections` or `collections.all`
	// Include: Pagination templates that consume config API collections
	getDelayedMappedDependencies() {
		let graph = new DependencyGraph();
		let tagPrefix = TemplateMap.tagPrefix;

		graph.addNode(tagPrefix + "all");

		let userConfigCollections = this.getUserConfigCollectionNames();

		// Add tags from named user config collections
		for (let tag of userConfigCollections) {
			graph.addNode(tagPrefix + tag);
		}

		for (let entry of this.map) {
			if (this.isPaginationOverAllCollections(entry)) {
				continue;
			}

			let paginationTagTarget = this.getPaginationTagTarget(entry);
			if (paginationTagTarget && this.isUserConfigCollectionName(paginationTagTarget)) {
				if (!graph.hasNode(entry.inputPath)) {
					graph.addNode(entry.inputPath);
				}
				graph.addDependency(entry.inputPath, tagPrefix + paginationTagTarget);

				let collections = TemplateData.getIncludedCollectionNames(entry.data);
				this.addTagsToGraph(graph, entry.inputPath, collections);

				this.addDeclaredDependenciesToGraph(
					graph,
					entry.inputPath,
					entry.data.eleventyImport?.collections,
				);
			}
		}

		return graph;
	}

	// Exclude: Pagination templates consuming `collections.all`
	// Include: Pagination templates consuming `collections`
	getPaginatedOverCollectionsMappedDependencies() {
		let graph = new DependencyGraph();
		let tagPrefix = TemplateMap.tagPrefix;
		let allNodeAdded = false;

		for (let entry of this.map) {
			if (this.isPaginationOverAllCollections(entry) && !this.getPaginationTagTarget(entry)) {
				if (!allNodeAdded) {
					graph.addNode(tagPrefix + "all");
					allNodeAdded = true;
				}

				if (!graph.hasNode(entry.inputPath)) {
					graph.addNode(entry.inputPath);
				}

				let collectionNames = TemplateData.getIncludedCollectionNames(entry.data);
				if (collectionNames.includes("all")) {
					// collections.all
					graph.addDependency(tagPrefix + "all", entry.inputPath);

					// Note that `tags` are otherwise ignored here
				}

				this.addDeclaredDependenciesToGraph(
					graph,
					entry.inputPath,
					entry.data.eleventyImport?.collections,
				);
			}
		}

		return graph;
	}

	// Include: Pagination templates consuming `collections.all`
	getPaginatedOverAllCollectionMappedDependencies() {
		let graph = new DependencyGraph();
		let tagPrefix = TemplateMap.tagPrefix;
		let allNodeAdded = false;

		for (let entry of this.map) {
			if (
				this.isPaginationOverAllCollections(entry) &&
				this.getPaginationTagTarget(entry) === "all"
			) {
				if (!allNodeAdded) {
					graph.addNode(tagPrefix + "all");
					allNodeAdded = true;
				}

				if (!graph.hasNode(entry.inputPath)) {
					graph.addNode(entry.inputPath);
				}

				let collectionNames = TemplateData.getIncludedCollectionNames(entry.data);
				if (collectionNames.includes("all")) {
					// Populates into collections.all
					// This is circular!
					graph.addDependency(tagPrefix + "all", entry.inputPath);

					// Note that `tags` are otherwise ignored here
				}

				this.addDeclaredDependenciesToGraph(
					graph,
					entry.inputPath,
					entry.data.eleventyImport?.collections,
				);
			}
		}

		return graph;
	}

	getTemplateMapDependencyGraph() {
		return [
			this.getMappedDependencies(),
			this.getDelayedMappedDependencies(),
			this.getPaginatedOverCollectionsMappedDependencies(),
			this.getPaginatedOverAllCollectionMappedDependencies(),
		];
	}

	getFullTemplateMapOrder() {
		// convert dependency graphs to ordered arrays
		return this.getTemplateMapDependencyGraph().map((entry) => entry.overallOrder());
	}

	#addEntryToGlobalDependencyGraph(entry) {
		let paginationTagTarget = this.getPaginationTagTarget(entry);
		if (paginationTagTarget) {
			this.config.uses.addDependencyConsumesCollection(entry.inputPath, paginationTagTarget);
		}

		let collectionNames = TemplateData.getIncludedCollectionNames(entry.data);
		for (let name of collectionNames) {
			this.config.uses.addDependencyPublishesToCollection(entry.inputPath, name);
		}

		if (Array.isArray(entry.data.eleventyImport?.collections)) {
			for (let tag of entry.data.eleventyImport.collections) {
				this.config.uses.addDependencyConsumesCollection(entry.inputPath, tag);
			}
		}
	}

	addAllToGlobalDependencyGraph() {
		for (let entry of this.map) {
			this.#addEntryToGlobalDependencyGraph(entry);
		}
	}

	async setCollectionByTagName(tagName) {
		if (this.isUserConfigCollectionName(tagName)) {
			// async
			this.collectionsData[tagName] = await this.getUserConfigCollection(tagName);
		} else {
			this.collectionsData[tagName] = this.getTaggedCollection(tagName);
		}

		let precompiled = this.config.precompiledCollections;
		if (precompiled?.[tagName]) {
			if (
				tagName === "all" ||
				!Array.isArray(this.collectionsData[tagName]) ||
				this.collectionsData[tagName].length === 0
			) {
				this.collectionsData[tagName] = precompiled[tagName];
			}
		}
	}

	// TODO(slightlyoff): major bottleneck
	async initDependencyMap(dependencyMap) {
		let tagPrefix = TemplateMap.tagPrefix;
		for (let depEntry of dependencyMap) {
			if (depEntry.startsWith(tagPrefix)) {
				// is a tag (collection) entry
				let tagName = depEntry.slice(tagPrefix.length);
				await this.setCollectionByTagName(tagName);
			} else {
				// is a template entry
				let map = this.getMapEntryForInputPath(depEntry);
				map._pages = await map.template.getTemplates(map.data);

				if (map._pages.length === 0) {
					// Reminder: a serverless code path was removed here.
				} else {
					let counter = 0;
					for (let page of map._pages) {
						// Copy outputPath to map entry
						// This is no longer used internally, just for backwards compatibility
						// Error added in v3 for https://github.com/11ty/eleventy/issues/3183
						if (map.data.pagination) {
							if (!Object.prototype.hasOwnProperty.call(map, "outputPath")) {
								Object.defineProperty(map, "outputPath", {
									get() {
										throw new Error(
											"Internal error: `.outputPath` on a paginated map entry is not consistent. Use `_pages[…].outputPath` instead.",
										);
									},
								});
							}
						} else if (!map.outputPath) {
							map.outputPath = page.outputPath;
						}

						if (counter === 0 || map.data.pagination?.addAllPagesToCollections) {
							if (map.data.eleventyExcludeFromCollections !== true) {
								// is in *some* collections
								this.collection.add(page);
							}
						}

						counter++;
					}
				}
			}
		}
	}

	async cache() {
		debug("Caching collections objects.");
		this.collectionsData = {};

		for (let entry of this.map) {
			entry.data.collections = this.collectionsData;
		}

		let [dependencyMap, delayedDependencyMap, firstPaginatedDepMap, secondPaginatedDepMap] =
			this.getFullTemplateMapOrder();

		await this.initDependencyMap(dependencyMap);
		await this.initDependencyMap(delayedDependencyMap);
		await this.initDependencyMap(firstPaginatedDepMap);
		await this.initDependencyMap(secondPaginatedDepMap);

		await this.resolveRemainingComputedData();

		let orderedPaths = this.getOrderedInputPaths(
			dependencyMap,
			delayedDependencyMap,
			firstPaginatedDepMap,
			secondPaginatedDepMap,
		);

		let orderedMap = orderedPaths.map((inputPath) => {
			return this.getMapEntryForInputPath(inputPath);
		});

		await this.config.events.emitLazy("eleventy.contentMap", () => {
			return {
				inputPathToUrl: this.generateInputUrlContentMap(orderedMap),
				urlToInputPath: this.generateUrlMap(orderedMap),
			};
		});

		await this.runDataSchemas(orderedMap);
		await this.populateContentDataInMap(orderedMap);

		this.populateCollectionsWithContent();
		this.cached = true;

		this.checkForDuplicatePermalinks();
		this.checkForMissingFileExtensions();

		await this.config.events.emitLazy("eleventy.layouts", () => this.generateLayoutsMap());
	}

	generateInputUrlContentMap(orderedMap) {
		let entries = {};
		for (let entry of orderedMap) {
			entries[entry.inputPath] = entry._pages.map((entry) => entry.url);
		}
		return entries;
	}

	generateUrlMap(orderedMap) {
		let entries = {};
		for (let entry of orderedMap) {
			for (let page of entry._pages) {
				// duplicate urls throw an error, so we can return non array here
				entries[page.url] = {
					inputPath: entry.inputPath,
					groupNumber: page.groupNumber,
				};
			}
		}
		return entries;
	}

	// TODO(slightlyoff): hot inner loop?
	getMapEntryForInputPath(inputPath) {
		for (let map of this.map) {
			if (map.inputPath === inputPath) {
				return map;
			}
		}
	}

	// Filter out any tag nodes
	getOrderedInputPaths(...maps) {
		let orderedMap = [];
		let tagPrefix = TemplateMap.tagPrefix;

		for (let map of maps) {
			for (let dep of map) {
				if (!dep.startsWith(tagPrefix)) {
					orderedMap.push(dep);
				}
			}
		}
		return orderedMap;
	}

	async runDataSchemas(orderedMap) {
		for (let map of orderedMap) {
			if (!map._pages) {
				continue;
			}

			for (let pageEntry of map._pages) {
				// Data Schema callback #879
				if (typeof pageEntry.data[this.config.keys.dataSchema] === "function") {
					try {
						await pageEntry.data[this.config.keys.dataSchema](pageEntry.data);
					} catch (e) {
						throw new EleventyDataSchemaError(
							`Error in the data schema for: ${map.inputPath} (via \`eleventyDataSchema\`)`,
							e,
						);
					}
				}
			}
		}
	}

	async populateContentDataInMap(orderedMap) {
		let usedTemplateContentTooEarlyMap = [];

		// Note that empty pagination templates will be skipped here as not renderable
		let filteredMap = orderedMap.filter((entry) => entry.template.isRenderable());

		for (let map of filteredMap) {
			if (!map._pages) {
				throw new Error(`Internal error: _pages not found for ${map.inputPath}`);
			}

			// IMPORTANT: this is where template content is rendered
			try {
				for (let pageEntry of map._pages) {
					pageEntry.templateContent =
						await pageEntry.template.renderPageEntryWithoutLayout(pageEntry);
				}
			} catch (e) {
				if (EleventyErrorUtil.isPrematureTemplateContentError(e)) {
					usedTemplateContentTooEarlyMap.push(map);

					// Reset cached render promise
					for (let pageEntry of map._pages) {
						pageEntry.template.resetCaches({ render: true });
					}
				} else {
					throw e;
				}
			}
			debugDev("Added this.map[...].templateContent, outputPath, et al for one map entry");
		}

		for (let map of usedTemplateContentTooEarlyMap) {
			try {
				for (let pageEntry of map._pages) {
					pageEntry.templateContent =
						await pageEntry.template.renderPageEntryWithoutLayout(pageEntry);
				}
			} catch (e) {
				if (EleventyErrorUtil.isPrematureTemplateContentError(e)) {
					throw new UsingCircularTemplateContentReferenceError(
						`${map.inputPath} contains a circular reference (using collections) to its own templateContent.`,
					);
				} else {
					// rethrow?
					throw e;
				}
			}
		}
	}

	getTaggedCollection(tag) {
		let result;
		if (!tag || tag === "all") {
			result = this.collection.getAllSorted();
		} else {
			result = this.collection.getFilteredByTag(tag);
		}
		debug(`Collection: collections.${tag || "all"} size: ${result.length}`);

		return result;
	}

	/* 3.0.0-alpha.1: setUserConfigCollections method removed (was only used for testing) */
	isUserConfigCollectionName(name) {
		let collections = this.userConfig.getCollections();
		return name && !!collections[name];
	}

	getUserConfigCollectionNames() {
		return Object.keys(this.userConfig.getCollections());
	}

	async getUserConfigCollection(name) {
		let configCollections = this.userConfig.getCollections();

		// This works with async now
		let result = await configCollections[name](this.collection);

		debug(`Collection: collections.${name} size: ${result.length}`);
		return result;
	}

	populateCollectionsWithContent() {
		for (let collectionName in this.collectionsData) {
			// skip custom collections set in configuration files that have arbitrary types
			if (!Array.isArray(this.collectionsData[collectionName])) {
				continue;
			}

			for (let item of this.collectionsData[collectionName]) {
				// skip custom collections set in configuration files that have arbitrary types
				if (!isPlainObject(item) || !("inputPath" in item)) {
					continue;
				}

				let entry = this.getMapEntryForInputPath(item.inputPath);
				// This check skips precompiled collections
				if (entry) {
					let index = item.pageNumber || 0;
					let content = entry._pages[index]._templateContent;
					if (content !== undefined) {
						item.templateContent = content;
					}
				}
			}
		}
	}

	async resolveRemainingComputedData() {
		let promises = [];
		for (let entry of this.map) {
			for (let pageEntry of entry._pages) {
				if (this.config.keys.computed in pageEntry.data) {
					promises.push(await pageEntry.template.resolveRemainingComputedData(pageEntry.data));
				}
			}
		}
		return Promise.all(promises);
	}

	async generateLayoutsMap() {
		let layouts = {};

		for (let entry of this.map) {
			for (let page of entry._pages) {
				let tmpl = page.template;
				let layoutKey = page.data[this.config.keys.layout];
				if (layoutKey) {
					let layout = tmpl.getLayout(layoutKey);
					let layoutChain = await layout.getLayoutChain();
					let priors = [];
					for (let filepath of layoutChain) {
						if (!layouts[filepath]) {
							layouts[filepath] = new Set();
						}
						layouts[filepath].add(page.inputPath);
						for (let prior of priors) {
							layouts[filepath].add(prior);
						}
						priors.push(filepath);
					}
				}
			}
		}

		for (let key in layouts) {
			layouts[key] = Array.from(layouts[key]);
		}

		return layouts;
	}

	#onEachPage(callback) {
		for (let template of this.map) {
			for (let page of template._pages) {
				callback(page, template);
			}
		}
	}

	checkForDuplicatePermalinks() {
		let inputs = {};
		let permalinks = {};
		let warnings = {};
		this.#onEachPage((page, template) => {
			if (page.outputPath === false || page.url === false) {
				// do nothing (also serverless)
			} else {
				// Make sure output doesn’t overwrite input (e.g. --input=. --output=.)
				// Related to https://github.com/11ty/eleventy/issues/3327
				if (page.outputPath === page.inputPath) {
					throw new DuplicatePermalinkOutputError(
						`The template at "${page.inputPath}" attempted to overwrite itself.`,
					);
				} else if (inputs[page.outputPath]) {
					throw new DuplicatePermalinkOutputError(
						`The template at "${page.inputPath}" attempted to overwrite an existing template at "${page.outputPath}".`,
					);
				}
				inputs[page.inputPath] = true;

				if (!permalinks[page.outputPath]) {
					permalinks[page.outputPath] = [template.inputPath];
				} else {
					warnings[page.outputPath] = `Output conflict: multiple input files are writing to \`${
						page.outputPath
					}\`. Use distinct \`permalink\` values to resolve this conflict.
  1. ${template.inputPath}
${permalinks[page.outputPath]
	.map(function (inputPath, index) {
		return `  ${index + 2}. ${inputPath}\n`;
	})
	.join("")}
`;
					permalinks[page.outputPath].push(template.inputPath);
				}
			}
		});

		let warningList = Object.values(warnings);
		if (warningList.length) {
			// throw one at a time
			throw new DuplicatePermalinkOutputError(warningList[0]);
		}
	}

	checkForMissingFileExtensions() {
		// disabled in config
		if (this.userConfig?.errorReporting?.allowMissingExtensions === true) {
			return;
		}

		this.#onEachPage((page) => {
			if (
				page.outputPath === false ||
				page.url === false ||
				page.data.eleventyAllowMissingExtension ||
				EXTENSIONLESS_URL_ALLOWLIST.some((url) => page.url.endsWith(url))
			) {
				// do nothing (also serverless)
			} else {
				if (TemplatePath.getExtension(page.outputPath) === "") {
					let e =
						new Error(`The template at '${page.inputPath}' attempted to write to '${page.outputPath}'${page.data.permalink ? ` (via \`permalink\` value: '${page.data.permalink}')` : ""}, which is a target on the file system that does not include a file extension.

You *probably* want to add a file extension to your permalink so that hosts will know how to correctly serve this file to web browsers. Without a file extension, this file may not be reliably deployed without additional hosting configuration (it won’t have a mime type) and may also cause local development issues if you later attempt to write to a subdirectory of the same name.

Learn more: https://v3.11ty.dev/docs/permalinks/#trailing-slashes

This is usually but not *always* an error so if you’d like to disable this error message, add \`eleventyAllowMissingExtension: true\` somewhere in the data cascade for this template or use \`eleventyConfig.configureErrorReporting({ allowMissingExtensions: true });\` to disable this feature globally.`);
					e.skipOriginalStack = true;
					throw e;
				}
			}
		});
	}

	// TODO move these into TemplateMapTest.js
	_testGetAllTags() {
		let allTags = {};
		for (let map of this.map) {
			let tags = map.data.tags;
			if (Array.isArray(tags)) {
				for (let tag of tags) {
					allTags[tag] = true;
				}
			}
		}
		return Object.keys(allTags);
	}

	async _testGetUserConfigCollectionsData() {
		let collections = {};
		let configCollections = this.userConfig.getCollections();

		for (let name in configCollections) {
			collections[name] = configCollections[name](this.collection);

			debug(`Collection: collections.${name} size: ${collections[name].length}`);
		}

		return collections;
	}

	async _testGetTaggedCollectionsData() {
		let collections = {};
		collections.all = this.collection.getAllSorted();
		debug(`Collection: collections.all size: ${collections.all.length}`);

		let tags = this._testGetAllTags();
		for (let tag of tags) {
			collections[tag] = this.collection.getFilteredByTag(tag);
			debug(`Collection: collections.${tag} size: ${collections[tag].length}`);
		}
		return collections;
	}

	async _testGetAllCollectionsData() {
		let collections = {};
		let taggedCollections = await this._testGetTaggedCollectionsData();
		Object.assign(collections, taggedCollections);

		let userConfigCollections = await this._testGetUserConfigCollectionsData();
		Object.assign(collections, userConfigCollections);

		return collections;
	}

	async _testGetCollectionsData() {
		if (!this.cached) {
			await this.cache();
		}

		return this.collectionsData;
	}
}

export default TemplateMap;
