import path from "node:path";

import { isDynamicPattern } from "tinyglobby";
import { filesize } from "filesize";
import copy from "@11ty/recursive-copy";
import { TemplatePath } from "@11ty/eleventy-utils";
import debugUtil from "debug";

import EleventyBaseError from "./Errors/EleventyBaseError.js";
import checkPassthroughCopyBehavior from "./Util/PassthroughCopyBehaviorCheck.js";
import ProjectDirectories from "./Util/ProjectDirectories.js";

const debug = debugUtil("Eleventy:TemplatePassthrough");

class TemplatePassthroughError extends EleventyBaseError {}

class TemplatePassthrough {
	isDryRun = false;
	#isInputPathGlob;
	#benchmarks;
	#isAlreadyNormalized = false;
	#projectDirCheck = false;

	// paths already guaranteed from the autocopy plugin
	static factory(inputPath, outputPath, opts = {}) {
		let p = new TemplatePassthrough(
			{
				inputPath,
				outputPath,
				copyOptions: opts.copyOptions,
			},
			opts.templateConfig,
		);

		return p;
	}

	constructor(path, templateConfig) {
		if (!templateConfig || templateConfig.constructor.name !== "TemplateConfig") {
			throw new Error(
				"Internal error: Missing `templateConfig` or was not an instance of `TemplateConfig`.",
			);
		}
		this.templateConfig = templateConfig;

		this.rawPath = path;

		// inputPath is relative to the root of your project and not your Eleventy input directory.
		// TODO normalize these with forward slashes
		this.inputPath = this.normalizeIfDirectory(path.inputPath);
		this.#isInputPathGlob = isDynamicPattern(this.inputPath);

		this.outputPath = path.outputPath;
		this.copyOptions = path.copyOptions; // custom options for recursive-copy
	}

	get benchmarks() {
		if (!this.#benchmarks) {
			this.#benchmarks = {
				aggregate: this.config.benchmarkManager.get("Aggregate"),
			};
		}

		return this.#benchmarks;
	}

	get config() {
		return this.templateConfig.getConfig();
	}

	get directories() {
		return this.templateConfig.directories;
	}

	// inputDir is used when stripping from output path in `getOutputPath`
	get inputDir() {
		return this.templateConfig.directories.input;
	}

	get outputDir() {
		return this.templateConfig.directories.output;
	}

	// Skips `getFiles()` normalization
	setIsAlreadyNormalized(isNormalized) {
		this.#isAlreadyNormalized = Boolean(isNormalized);
	}

	setCheckSourceDirectory(check) {
		this.#projectDirCheck = Boolean(check);
	}

	/* { inputPath, outputPath } though outputPath is *not* the full path: just the output directory */
	getPath() {
		return this.rawPath;
	}

	async getOutputPath(inputFileFromGlob) {
		let { inputDir, outputDir, outputPath, inputPath } = this;

		if (outputPath === true) {
			// no explicit target, implied target
			if (this.isDirectory(inputPath)) {
				let inputRelativePath = TemplatePath.stripLeadingSubPath(
					inputFileFromGlob || inputPath,
					inputDir,
				);
				return ProjectDirectories.normalizeDirectory(
					TemplatePath.join(outputDir, inputRelativePath),
				);
			}

			return TemplatePath.normalize(
				TemplatePath.join(
					outputDir,
					TemplatePath.stripLeadingSubPath(inputFileFromGlob || inputPath, inputDir),
				),
			);
		}

		if (inputFileFromGlob) {
			return this.getOutputPathForGlobFile(inputFileFromGlob);
		}

		// Has explicit target

		// Bug when copying incremental file overwriting output directory (and making it a file)
		// e.g. public/test.css -> _site
		// https://github.com/11ty/eleventy/issues/2278
		let fullOutputPath = TemplatePath.normalize(TemplatePath.join(outputDir, outputPath));
		if (outputPath === "" || this.isDirectory(inputPath)) {
			fullOutputPath = ProjectDirectories.normalizeDirectory(fullOutputPath);
		}

		// TODO room for improvement here:
		if (
			!this.#isInputPathGlob &&
			this.isExists(inputPath) &&
			!this.isDirectory(inputPath) &&
			this.isDirectory(fullOutputPath)
		) {
			let filename = path.parse(inputPath).base;
			return TemplatePath.normalize(TemplatePath.join(fullOutputPath, filename));
		}

		return fullOutputPath;
	}

	async getOutputPathForGlobFile(inputFileFromGlob) {
		return TemplatePath.join(
			await this.getOutputPath(),
			TemplatePath.getLastPathSegment(inputFileFromGlob),
		);
	}

	setDryRun(isDryRun) {
		this.isDryRun = Boolean(isDryRun);
	}

	setRunMode(runMode) {
		this.runMode = runMode;
	}

	setFileSystemSearch(fileSystemSearch) {
		this.fileSystemSearch = fileSystemSearch;
	}

	async getFiles(glob) {
		debug("Searching for: %o", glob);
		let b = this.benchmarks.aggregate.get("Searching the file system (passthrough)");
		b.before();

		if (!this.fileSystemSearch) {
			throw new Error("Internal error: Missing `fileSystemSearch` property.");
		}

		// TODO perf this globs once per addPassthroughCopy entry
		let files = TemplatePath.addLeadingDotSlashArray(
			await this.fileSystemSearch.search("passthrough", glob, {
				ignore: [
					// *only* ignores output dir (not node_modules!)
					this.outputDir,
				],
			}),
		);
		b.after();
		return files;
	}

	isExists(filePath) {
		return this.templateConfig.existsCache.exists(filePath);
	}

	isDirectory(filePath) {
		return this.templateConfig.existsCache.isDirectory(filePath);
	}

	// dir is guaranteed to exist by context
	// dir may not be a directory
	normalizeIfDirectory(input) {
		if (typeof input === "string") {
			if (input.endsWith(path.sep) || input.endsWith("/")) {
				return input;
			}

			// When inputPath is a directory, make sure it has a slash for passthrough copy aliasing
			// https://github.com/11ty/eleventy/issues/2709
			if (this.isDirectory(input)) {
				return `${input}/`;
			}
		}

		return input;
	}

	// maps input paths to output paths
	async getFileMap() {
		if (this.#isAlreadyNormalized) {
			return [
				{
					inputPath: this.inputPath,
					outputPath: this.outputPath,
				},
			];
		}

		// TODO VirtualFileSystem candidate
		if (!isDynamicPattern(this.inputPath) && this.isExists(this.inputPath)) {
			return [
				{
					inputPath: this.inputPath,
					outputPath: await this.getOutputPath(),
				},
			];
		}

		let paths = [];
		// If not directory or file, attempt to get globs
		let files = await this.getFiles(this.inputPath);
		for (let filePathFromGlob of files) {
			paths.push({
				inputPath: filePathFromGlob,
				outputPath: await this.getOutputPath(filePathFromGlob),
			});
		}

		return paths;
	}

	/* Types:
	 * 1. via glob, individual files found
	 * 2. directory, triggers an event for each file
	 * 3. individual file
	 */
	async copy(src, dest, copyOptions) {
		if (this.#projectDirCheck && !this.directories.isFileInProjectFolder(src)) {
			return Promise.reject(
				new TemplatePassthroughError(
					"Source file is not in the project directory. Check your passthrough paths.",
				),
			);
		}

		if (!this.directories.isFileInOutputFolder(dest)) {
			return Promise.reject(
				new TemplatePassthroughError(
					"Destination is not in the site output directory. Check your passthrough paths.",
				),
			);
		}

		let fileCopyCount = 0;
		let fileSizeCount = 0;
		let map = {};
		let b = this.benchmarks.aggregate.get("Passthrough Copy File");

		// returns a promise
		return copy(src, dest, copyOptions)
			.on(copy.events.COPY_FILE_START, (copyOp) => {
				// Access to individual files at `copyOp.src`
				map[copyOp.src] = copyOp.dest;
				b.before();
			})
			.on(copy.events.COPY_FILE_COMPLETE, (copyOp) => {
				fileCopyCount++;
				fileSizeCount += copyOp.stats.size;
				if (copyOp.stats.size > 5000000) {
					debug(`Copied %o (⚠️ large) file from %o`, filesize(copyOp.stats.size), copyOp.src);
				} else {
					debug(`Copied %o file from %o`, filesize(copyOp.stats.size), copyOp.src);
				}
				b.after();
			})
			.then(
				() => {
					return {
						count: fileCopyCount,
						size: fileSizeCount,
						map,
					};
				},
				(error) => {
					if (copyOptions.overwrite === false && error.code === "EEXIST") {
						// just ignore if the output already exists and overwrite: false
						debug("Overwrite error ignored: %O", error);
						return {
							count: 0,
							size: 0,
							map,
						};
					}

					return Promise.reject(error);
				},
			);
	}

	async write() {
		if (this.isDryRun) {
			return Promise.resolve({
				count: 0,
				map: {},
			});
		}

		debug("Copying %o", this.inputPath);
		let fileMap = await this.getFileMap();

		// default options for recursive-copy
		// see https://www.npmjs.com/package/recursive-copy#arguments
		let copyOptionsDefault = {
			overwrite: true, // overwrite output. fails when input is directory (mkdir) and output is file
			dot: true, // copy dotfiles
			junk: false, // copy cache files like Thumbs.db
			results: false,
			expand: false, // follow symlinks (matches recursive-copy default)
			debug: false, // (matches recursive-copy default)

			// Note: `filter` callback function only passes in a relative path, which is unreliable
			// See https://github.com/timkendrick/recursive-copy/blob/4c9a8b8a4bf573285e9c4a649a30a2b59ccf441c/lib/copy.js#L59
			// e.g. `{ filePaths: [ './img/coolkid.jpg' ], relativePaths: [ '' ] }`
		};

		let copyOptions = Object.assign(copyOptionsDefault, this.copyOptions);

		let promises = fileMap.map((entry) => {
			// For-free passthrough copy
			if (checkPassthroughCopyBehavior(this.config, this.runMode)) {
				let aliasMap = {};
				aliasMap[entry.inputPath] = entry.outputPath;

				return Promise.resolve({
					count: 0,
					map: aliasMap,
				});
			}

			// Copy the files (only in build mode)
			return this.copy(entry.inputPath, entry.outputPath, copyOptions);
		});

		// IMPORTANT: this returns an array of promises, does not await for promise to finish
		return Promise.all(promises).then(
			(results) => {
				// collate the count and input/output map results from the array.
				let count = 0;
				let size = 0;
				let map = {};

				for (let result of results) {
					count += result.count;
					size += result.size;
					Object.assign(map, result.map);
				}

				return {
					count,
					size,
					map,
				};
			},
			(err) => {
				throw new TemplatePassthroughError(`Error copying passthrough files: ${err.message}`, err);
			},
		);
	}
}

export default TemplatePassthrough;
