import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import module from "node:module";
import { MessageChannel } from "node:worker_threads";

import { TemplatePath } from "@11ty/eleventy-utils";

import EleventyBaseError from "../Errors/EleventyBaseError.js";
import eventBus from "../EventBus.js";

class EleventyImportError extends EleventyBaseError {}

const { port1, port2 } = new MessageChannel();

// ESM Cache Buster is an enhancement that works in Node 18.19+
// https://nodejs.org/docs/latest/api/module.html#moduleregisterspecifier-parenturl-options
// Fixes https://github.com/11ty/eleventy/issues/3270
// ENV variable for https://github.com/11ty/eleventy/issues/3371
if ("register" in module && !process?.env?.ELEVENTY_SKIP_ESM_RESOLVER) {
	module.register("./EsmResolver.js", import.meta.url, {
		parentURL: import.meta.url,
		data: {
			port: port2,
		},
		transferList: [port2],
	});
}

// important to clear the require.cache in CJS projects
const require = module.createRequire(import.meta.url);

const requestPromiseCache = new Map();

function getImportErrorMessage(filePath, type) {
	return `There was a problem importing '${path.relative(".", filePath)}' via ${type}`;
}

// Used for JSON imports, suffering from Node warning that import assertions experimental but also
// throwing an error if you try to import() a JSON file without an import assertion.
/**
 *
 * @returns {string|undefined}
 */
function loadContents(path, options = {}) {
	let rawInput;
	/** @type {string} */
	let encoding = "utf8"; // JSON is utf8
	if (options?.encoding || options?.encoding === null) {
		encoding = options.encoding;
	}

	try {
		// @ts-expect-error This is an error in the upstream types
		rawInput = fs.readFileSync(path, encoding);
	} catch (error) {
		// @ts-expect-error Temporary
		if (error?.code === "ENOENT") {
			// if file does not exist, return nothing
			return;
		}

		throw error;
	}

	// Can return a buffer, string, etc
	if (typeof rawInput === "string") {
		rawInput = rawInput.trim();
	}

	return rawInput;
}

let lastModifiedPaths = new Map();
eventBus.on("eleventy.importCacheReset", (fileQueue) => {
	for (let filePath of fileQueue) {
		let absolutePath = TemplatePath.absolutePath(filePath);
		let newDate = Date.now();
		lastModifiedPaths.set(absolutePath, newDate);

		// post to EsmResolver worker thread
		if (port1) {
			port1.postMessage({ path: absolutePath, newDate });
		}

		// ESM Eleventy when using `import()` on a CJS project file still adds to require.cache
		if (absolutePath in (require?.cache || {})) {
			delete require.cache[absolutePath];
		}
	}
});

// raw means we don’t normalize away the `default` export
async function dynamicImportAbsolutePath(absolutePath, options = {}) {
	let { type, returnRaw, cacheBust } = Object.assign(
		{
			type: undefined,
			returnRaw: false,
			cacheBust: false, // force cache bust
		},
		options,
	);

	// Short circuit for JSON files (that are optional and can be empty)
	if (absolutePath.endsWith(".json") || type === "json") {
		try {
			// https://v8.dev/features/import-assertions#dynamic-import() is still experimental in Node 20
			let rawInput = loadContents(absolutePath);
			if (!rawInput) {
				// should not error when file exists but is _empty_
				return;
			}
			return JSON.parse(rawInput);
		} catch (e) {
			return Promise.reject(
				new EleventyImportError(getImportErrorMessage(absolutePath, "fs.readFile(json)"), e),
			);
		}
	}

	// Removed a `require` short circuit from this piece originally added
	// in https://github.com/11ty/eleventy/pull/3493 Was a bit faster but
	// error messaging was worse for require(esm)

	let urlPath;
	try {
		let u = new URL(`file:${absolutePath}`);

		// Bust the import cache if this is the last modified file (or cache busting is forced)
		if (cacheBust) {
			lastModifiedPaths.set(absolutePath, Date.now());
		}

		if (cacheBust || lastModifiedPaths.has(absolutePath)) {
			u.searchParams.set("_cache_bust", lastModifiedPaths.get(absolutePath));
		}

		urlPath = u.toString();
	} catch (e) {
		urlPath = absolutePath;
	}

	let promise;
	if (requestPromiseCache.has(urlPath)) {
		promise = requestPromiseCache.get(urlPath);
	} else {
		promise = import(urlPath);
		requestPromiseCache.set(urlPath, promise);
	}

	return promise.then(
		(target) => {
			if (returnRaw) {
				return target;
			}

			// If the only export is `default`, elevate to top (for ESM and CJS)
			if (Object.keys(target).length === 1 && "default" in target) {
				return target.default;
			}

			// When using import() on a CommonJS file that exports an object sometimes it
			// returns duplicated values in `default` key, e.g. `{ default: {key: value}, key: value }`

			// A few examples:
			// module.exports = { key: false };
			//    returns `{ default: {key: false}, key: false }` as not expected.
			// module.exports = { key: true };
			// module.exports = { key: null };
			// module.exports = { key: undefined };
			// module.exports = { key: class {} };

			// A few examples where it does not duplicate:
			// module.exports = { key: 1 };
			//    returns `{ default: {key: 1} }` as expected.
			// module.exports = { key: "value" };
			// module.exports = { key: {} };
			// module.exports = { key: [] };

			if (type === "cjs" && "default" in target) {
				let match = true;
				for (let key in target) {
					if (key === "default") {
						continue;
					}
					if (key === "module.exports") {
						continue;
					}
					if (target[key] !== target.default[key]) {
						match = false;
					}
				}

				if (match) {
					return target.default;
				}
			}

			// Otherwise return { default: value, named: value }
			// Object.assign here so we can add things to it in JavaScript.js
			return Object.assign({}, target);
		},
		(error) => {
			return Promise.reject(
				new EleventyImportError(getImportErrorMessage(absolutePath, `import(${type})`), error),
			);
		},
	);
}

function normalizeFilePathInEleventyPackage(file) {
	// Back up relative paths from ./src/Util/Require.js
	return path.resolve(fileURLToPath(import.meta.url), "../../../", file);
}

async function dynamicImportFromEleventyPackage(file) {
	// points to files relative to the top level Eleventy directory
	let filePath = normalizeFilePathInEleventyPackage(file);

	// Returns promise
	return dynamicImportAbsolutePath(filePath, { type: "esm" });
}

async function dynamicImport(localPath, type, options = {}) {
	let absolutePath = TemplatePath.absolutePath(localPath);
	options.type = type;

	// Returns promise
	return dynamicImportAbsolutePath(absolutePath, options);
}

/* Used to import default Eleventy configuration file, raw means we don’t normalize away the `default` export */
async function dynamicImportRawFromEleventyPackage(file) {
	// points to files relative to the top level Eleventy directory
	let filePath = normalizeFilePathInEleventyPackage(file);

	// Returns promise
	return dynamicImportAbsolutePath(filePath, { type: "esm", returnRaw: true });
}

/* Used to import app configuration files, raw means we don’t normalize away the `default` export */
async function dynamicImportRaw(localPath, type) {
	let absolutePath = TemplatePath.absolutePath(localPath);

	// Returns promise
	return dynamicImportAbsolutePath(absolutePath, { type, returnRaw: true });
}

export {
	loadContents as EleventyLoadContent,
	dynamicImport as EleventyImport,
	dynamicImportRaw as EleventyImportRaw,
	normalizeFilePathInEleventyPackage,

	// no longer used in core
	dynamicImportFromEleventyPackage as EleventyImportFromEleventy,
	dynamicImportRawFromEleventyPackage as EleventyImportRawFromEleventy,
};
