"use strict";

require("./helpers/warmup-webpack");

const fs = require("fs");
const path = require("path");
const util = require("util");
const vm = require("vm");
const rimraf = require("rimraf");
const expectNoDeprecations = require("./helpers/expectNoDeprecations");
const supportsObjectHasOwn = require("./helpers/supportsObjectHasOwn");
const supportsOptionalChaining = require("./helpers/supportsOptionalChaining");

const readdir = util.promisify(fs.readdir);
const writeFile = util.promisify(fs.writeFile);
const utimes = util.promisify(fs.utimes);
const mkdir = util.promisify(fs.mkdir);

describe("Persistent Caching", () => {
	expectNoDeprecations();

	const tempPath = path.resolve(__dirname, "js", "persistent-caching");
	const outputPath = path.resolve(tempPath, "output");
	const cachePath = path.resolve(tempPath, "cache");
	const srcPath = path.resolve(tempPath, "src");

	const config = {
		mode: "none",
		context: tempPath,
		cache: {
			type: "filesystem",
			buildDependencies: {
				// avoid rechecking build dependencies
				// for performance
				// this is already covered by another test case
				defaultWebpack: []
			},
			cacheLocation: cachePath
		},
		experiments: {
			css: true
		},
		resolve: {
			alias: {
				"image.png": false,
				"image1.png": false
			}
		},
		target: "node",
		output: {
			library: { type: "commonjs-module", export: "default" },
			path: outputPath,
			// bundles are executed in this Node.js process; avoid `?.` on Node < 14
			// and `Object.hasOwn` on Node < 16.9
			environment: {
				optionalChaining: supportsOptionalChaining(),
				hasOwn: supportsObjectHasOwn()
			}
		}
	};

	beforeEach((done) => {
		rimraf(tempPath, done);
	});

	const updateSrc = async (/** @type {Record<string, string>} */ data) => {
		const ts = new Date(Date.now() - 10000);
		await mkdir(srcPath, { recursive: true });
		for (const key of Object.keys(data)) {
			const p = path.resolve(srcPath, key);
			await writeFile(p, data[key]);
			await utimes(p, ts, ts);
		}
	};

	const compile = async (/** @type {EXPECTED_ANY} */ configAdditions = {}) =>
		new Promise((resolve, reject) => {
			const webpack = require("../");

			webpack(
				/** @type {import("../").Configuration} */ (
					/** @type {unknown} */ ({
						...config,
						...configAdditions,
						cache: {
							...config.cache,
							.../** @type {EXPECTED_ANY} */ (configAdditions).cache
						}
					})
				),
				(
					/** @type {Error | null} */ err,
					/** @type {import("../").Stats | undefined} */ _stats
				) => {
					if (err) return reject(err);
					const stats = /** @type {import("../").Stats} */ (_stats);
					if (stats.hasErrors()) {
						return reject(stats.toString({ preset: "errors-only" }));
					}
					resolve(stats);
				}
			);
		});

	const getCacheFileTimes = async () => {
		const cacheFiles = (await readdir(cachePath)).sort();
		return new Map(
			cacheFiles.map((f) => [
				f,
				fs.statSync(path.join(cachePath, f)).mtime.toString()
			])
		);
	};

	const execute = () => {
		/** @type {Record<string, { exports: unknown }>} */
		const cache = {};
		const require = (/** @type {string} */ name) => {
			if (cache[name]) return cache[name].exports;
			if (!name.endsWith(".js")) name += ".js";
			const p = path.resolve(outputPath, name);
			const source = fs.readFileSync(p, "utf8");
			const context = {};
			const fn =
				/** @type {(require: (name: string) => EXPECTED_ANY, module: { exports: unknown }, exports: unknown) => void} */ (
					/** @type {EXPECTED_ANY} */ (vm.runInThisContext)(
						`(function(require, module, exports) { ${source} })`,
						context,
						{
							filename: p
						}
					)
				);
			const m = { exports: /** @type {unknown} */ ({}) };
			cache[name] = m;
			fn(require, m, m.exports);
			return m.exports;
		};
		return require("./main");
	};

	it("should compile fine (warmup)", async () => {
		const data = {
			"index.js": `import file from "./file.js";
export default 40 + file;
`,
			"file.js": "export default 2;"
		};
		await updateSrc(data);
		await compile();
		expect(execute()).toBe(42);
	}, 100000);

	it("should merge multiple small files", async () => {
		const files = Array.from({ length: 30 }).map((_, i) => `file${i}.js`);
		const data = {
			"index.js": `
import * as style from "./style.modules.css";

${files.map((f, i) => `import f${i} from "./${f}";`).join("\n")}

export default ${files.map((_, i) => `f${i}`).join(" + ")};
export { style };
`,
			"style.modules.css": `.class {
	color: red;
	background: url('image.png');
}`
		};
		for (const file of files) {
			/** @type {Record<string, string>} */ (data)[file] = "export default 1;";
		}
		await updateSrc(data);
		await compile({ cache: { compression: false } });
		expect(execute()).toBe(30);
		for (let i = 0; i < 30; i++) {
			updateSrc({
				[files[i]]: "export default 2;",
				"style.modules.css": `.class-${i} { color: red; background: url('image1.png'); }`
			});
			await compile({ cache: { compression: false } });
			expect(execute()).toBe(31 + i);
		}
		const cacheFiles = await readdir(cachePath);
		expect(cacheFiles.length).toBeLessThan(20);
		expect(cacheFiles.length).toBeGreaterThan(10);
	}, 120000);

	it("should optimize unused content", async () => {
		const data = {
			"a.js": 'import "react-dom";',
			"b.js": 'import "acorn";',
			"c.js": 'import "core-js";',
			"d.js": 'import "date-fns";',
			"e.js": 'import "lodash";'
		};
		await updateSrc(data);
		const c = (/** @type {string} */ items) => {
			/** @type {Record<string, string>} */
			const entry = {};
			for (const item of items) entry[item] = `./src/${item}.js`;
			return compile({ entry, cache: { compression: false } });
		};
		await c("abcde");
		await c("abc");
		await c("cde");
		await c("acd");
		await c("bce");
		await c("abcde");
		const cacheFiles = await readdir(cachePath);
		expect(cacheFiles.length).toBeGreaterThan(4);
	}, 120000);

	it("should allow persistent caching of container related objects", async () => {
		const data = {
			"index.js":
				"export default import('container/src/exposed').then(m => m.default);",
			"exposed.js": "import lib from 'lib'; export default 21 + lib;",
			"lib.js": "export default 20",
			"lib2.js": "export default 21"
		};
		await updateSrc(data);

		const webpack = require("../");

		const configAdditions = {
			plugins: [
				new webpack.container.ModuleFederationPlugin({
					name: "container",
					library: { type: "commonjs-module" },
					exposes: ["./src/exposed"],
					remotes: {
						container: ["./no-container", "./container"]
					},
					shared: {
						lib: {
							import: "./src/lib",
							shareKey: "lib",
							version: "1.2.0",
							requiredVersion: "^1.0.0"
						},
						"./src/lib2": {
							shareKey: "lib",
							version: "1.2.3"
						}
					}
				})
			]
		};
		await compile(configAdditions);
		await expect(execute()).resolves.toBe(42);
		await updateSrc({
			"exposed.js": "module.exports = { ok: true };"
		});
		await compile(configAdditions);
		await expect(execute()).resolves.toEqual({ ok: true });
	}, 120000);

	it("should not overwrite cache files if readonly = true", async () => {
		await updateSrc({
			"main.js": `
import { sum } from 'lodash';

sum([1,2,3])
			`
		});
		await compile({ entry: "./src/main.js" });
		const firstCacheFileTimes = await getCacheFileTimes();

		await updateSrc({
			"main.js": `
import 'lodash';
			`
		});
		await compile({
			entry: "./src/main.js",
			cache: {
				...config.cache,
				readonly: true
			}
		});
		await expect(getCacheFileTimes()).resolves.toEqual(firstCacheFileTimes);
	}, 20000);

	it("should not invalidate cache files if timestamps changed with dynamic import()", async () => {
		const configAdditions = {
			entry: "./src/main.js",
			snapshot: {
				resolve: { hash: true },
				module: { hash: true },
				contextModule: { hash: true }
			}
		};
		await updateSrc({
			"newer.js": "export default 2;",
			// eslint-disable-next-line no-template-curly-in-string
			"main.js": 'const f = "newer.js"; import(`./${f}`);'
		});
		await compile(configAdditions);
		const firstCacheFileTimes = await getCacheFileTimes();

		await utimes(path.resolve(srcPath, "newer.js"), new Date(), new Date());

		await compile(configAdditions);
		await expect(getCacheFileTimes()).resolves.toEqual(firstCacheFileTimes);
	}, 20000);
});
