"use strict";

const fs = require("fs");
const build = require("verda").create();
const which = require("which");
const Path = require("path");
const toml = require("@iarna/toml");

///////////////////////////////////////////////////////////

const { task, file, oracle, computed, phony } = build.ruleTypes;
const { de, fu, sfu, ofu } = build.rules;
const { run, cd, cp, rm, fail, echo, silently } = build.actions;
const { FileList } = build.predefinedFuncs;

module.exports = build;

///////////////////////////////////////////////////////////

const BUILD = ".build";
const DIST = "dist";
const SNAPSHOT_TMP = ".build/snapshot";
const DIST_SUPER_TTC = "dist/.super-ttc";
const ARCHIVE_DIR = "release-archives";

const PATEL_C = ["node", "node_modules/patel/bin/patel-c"];
const TTCIZE = ["node", "node_modules/otb-ttc-bundle/bin/otb-ttc-bundle"];
const webfontFormats = [
	["woff2", "woff2"],
	["ttf", "truetype"]
];

const WIDTH_NORMAL = "normal";
const WEIGHT_NORMAL = "regular";
const SLOPE_NORMAL = "upright";
const DEFAULT_SUBFAMILY = "regular";

const BUILD_PLANS = "build-plans.toml";
const PRIVATE_BUILD_PLANS = "private-build-plans.toml";

// Save journal to build/
build.setJournal(`${BUILD}/.verda-build-journal`);
// Enable self-tracking
build.setSelfTracking();

///////////////////////////////////////////////////////////
//////                   Oracles                     //////
///////////////////////////////////////////////////////////

const Version = oracle(`oracle:version`, async target => {
	const [pj] = await target.need(sfu`package.json`);
	const package_json = JSON.parse(await fs.promises.readFile(pj.full, "utf-8"));
	return package_json.version;
});

const CheckTtfAutoHintExists = oracle(`oracle:check-ttfautohint-exists`, async target => {
	try {
		return await which("ttfautohint");
	} catch (e) {
		fail("External dependency <ttfautohint>, needed for building hinted font, does not exist.");
	}
});

///////////////////////////////////////////////////////////
//////                    Plans                      //////
///////////////////////////////////////////////////////////

const RawPlans = computed(`metadata:raw-plans`, async target => {
	await target.need(sfu(BUILD_PLANS), ofu(PRIVATE_BUILD_PLANS));

	const bp = await tryParseToml(BUILD_PLANS);
	bp.buildOptions = bp.buildOptions || {};

	if (fs.existsSync(PRIVATE_BUILD_PLANS)) {
		const privateBP = await tryParseToml(PRIVATE_BUILD_PLANS);
		Object.assign(bp.buildPlans, privateBP.buildPlans);
		Object.assign(bp.buildOptions, privateBP.buildOptions || {});
	}
	return bp;
});
async function tryParseToml(str) {
	try {
		return JSON.parse(JSON.stringify(toml.parse(fs.readFileSync(str, "utf-8"))));
	} catch (e) {
		throw new Error(
			`Failed to parse configuration file ${str}.\n` +
				`Please validate whether there's syntax error.\n` +
				`${e}`
		);
	}
}

const BuildPlans = computed("metadata:build-plans", async target => {
	const [rp] = await target.need(RawPlans);
	const rawBuildPlans = rp.buildPlans;

	const returnBuildPlans = {};
	const fileNameToBpMap = {};
	for (const prefix in rawBuildPlans) {
		const bp = { ...rawBuildPlans[prefix] };
		validateAndShimBuildPlans(prefix, bp, rp.weights, rp.slopes, rp.widths);

		bp.targets = [];
		const weights = bp.weights,
			slopes = bp.slopes,
			widths = bp.widths;
		const suffixMapping = getSuffixMapping(weights, slopes, widths);
		for (const suffix in suffixMapping) {
			const sfi = suffixMapping[suffix];
			if (weights && !weights[sfi.weight]) continue;
			if (slopes && !slopes[sfi.slope]) continue;
			const fileName = makeFileName(prefix, suffix);
			bp.targets.push(fileName);
			fileNameToBpMap[fileName] = { prefix, suffix };
		}
		returnBuildPlans[prefix] = bp;
	}
	return { fileNameToBpMap, buildPlans: returnBuildPlans };
});

const BuildPlanOf = computed.group("metadata:build-plan-of", async (target, gid) => {
	const [{ buildPlans }] = await target.need(BuildPlans);
	const plan = buildPlans[gid];
	if (!plan) fail(`Build plan for '${gid}' not found.` + whyBuildPlanIsnNotThere(gid));
	return plan;
});

const GroupFontsOf = computed.group("metadata:group-fonts-of", async (target, gid) => {
	const [plan] = await target.need(BuildPlanOf(gid));
	return plan.targets;
});

const FontInfoOf = computed.group("metadata:font-info-of", async (target, fileName) => {
	const [{ fileNameToBpMap, buildPlans }] = await target.need(BuildPlans);
	const [version] = await target.need(Version);

	const fi0 = fileNameToBpMap[fileName];
	if (!fi0) fail(`Build plan for '${fileName}' not found.` + whyBuildPlanIsnNotThere(fileName));

	const bp = buildPlans[fi0.prefix];
	if (!bp) fail(`Build plan for '${fileName}' not found.` + whyBuildPlanIsnNotThere(fileName));

	const sfi = getSuffixMapping(bp.weights, bp.slopes, bp.widths)[fi0.suffix];

	return {
		name: fileName,
		variants: bp.variants || null,
		derivingVariants: bp.derivingVariants,
		featureControl: {
			noCvSs: bp["no-cv-ss"] || false,
			noLigation: bp["no-ligation"] || false
		},
		// Ligations
		ligations: bp.ligations || null,
		// Shape
		shape: {
			serifs: bp.serifs || null,
			spacing: bp.spacing || null,
			weight: sfi.shapeWeight,
			slope: sfi.slope,
			width: sfi.shapeWidth
		},
		// Menu
		menu: {
			family: bp.family,
			version: version,
			width: sfi.menuWidth,
			slope: sfi.menuSlope,
			weight: sfi.menuWeight
		},
		// CSS
		css: {
			weight: sfi.cssWeight,
			stretch: sfi.cssStretch,
			style: sfi.cssStyle
		},
		hintParams: bp.hintParams || [],
		compatibilityLigatures: bp["compatibility-ligatures"] || null,
		metricOverride: bp["metric-override"] || null,
		excludedCharRanges: bp["exclude-chars"] ? bp["exclude-chars"].ranges || null : null
	};
});

function getSuffixMapping(weights, slopes, widths) {
	const mapping = {};
	for (const w in weights) {
		validateRecommendedWeight(w, weights[w].menu, "Menu");
		validateRecommendedWeight(w, weights[w].css, "CSS");
		for (const s in slopes) {
			for (const wd in widths) {
				const suffix = makeSuffix(w, wd, s, DEFAULT_SUBFAMILY);
				mapping[suffix] = getSuffixMappingItem(weights, w, slopes, s, widths, wd);
			}
		}
	}
	return mapping;
}
function getSuffixMappingItem(weights, w, slopes, s, widths, wd) {
	return {
		// Weights
		weight: w,
		shapeWeight: nValidate("Shape weight of " + w, weights[w].shape, VlShapeWeight),
		cssWeight: nValidate("CSS weight of " + w, weights[w].css, VlCssWeight),
		menuWeight: nValidate("Menu weight of " + w, weights[w].menu, VlMenuWeight),

		// Widths
		width: wd,
		shapeWidth: nValidate("Shape width of " + wd, widths[wd].shape, VlShapeWidth),
		cssStretch: widths[wd].css || wd,
		menuWidth: nValidate("Menu width of " + wd, widths[wd].menu, VlMenuWidth),

		// Slopes
		slope: s,
		cssStyle: slopes[s] || s,
		menuSlope: slopes[s] || s
	};
}

function makeFileName(prefix, suffix) {
	return prefix + "-" + suffix;
}
function makeSuffix(w, wd, s, fallback) {
	return (
		(wd === WIDTH_NORMAL ? "" : wd) +
			(w === WEIGHT_NORMAL ? "" : w) +
			(s === SLOPE_NORMAL ? "" : s) || fallback
	);
}

function whyBuildPlanIsnNotThere(gid) {
	if (!fs.existsSync(PRIVATE_BUILD_PLANS))
		return "\n        -- Possible reason: Config file 'private-build-plans.toml' does not exist.";
	return "";
}

///////////////////////////////////////////////////////////
//////                Font Building                  //////
///////////////////////////////////////////////////////////

const BuildTTF = file.make(
	(gr, fn) => `${BUILD}/ttf/${gr}/${fn}.ttf`,
	async (target, out, gr, fn) => {
		const charmap = out.dir + "/" + fn + ".charmap";
		const [fi] = await target.need(FontInfoOf(fn), de`${out.dir}`, Scripts);
		echo.action(echo.hl.command(`Create TTF`), fn, echo.hl.operator("->"), out.full);
		await silently.node("font-src/index", { o: out.full, oCharMap: charmap, ...fi });
	}
);

const BuildCM = file.make(
	(gr, f) => `${BUILD}/ttf/${gr}/${f}.charmap`,
	async (target, output, gr, f) => {
		await target.need(BuildTTF(gr, f));
	}
);

///////////////////////////////////////////////////////////
//////              Font Distribution                //////
///////////////////////////////////////////////////////////

// Group-level
const GroupContents = task.group("contents", async (target, gr) => {
	await target.need(GroupFonts(gr), DistWebFontCSS(gr));
	return gr;
});

// Webfont CSS
const DistWebFontCSS = file.make(
	gr => `${DIST}/${gr}/${gr}.css`,
	async (target, out, gr) => {
		// Note: this target does NOT depend on the font files.
		const [bp, ts] = await target.need(BuildPlanOf(gr), GroupFontsOf(gr), de(out.dir));
		const hs = await target.need(...ts.map(FontInfoOf));
		echo.action(echo.hl.command(`Create WebFont CSS`), gr, echo.hl.operator("->"), out.full);
		await silently.node("utility/make-webfont-css.js", out.full, bp.family, hs, webfontFormats);
	}
);

// Content files
const GroupTTFs = task.group("ttf", async (target, gr) => {
	const [ts] = await target.need(GroupFontsOf(gr));
	await target.need(ts.map(tn => DistHintedTTF(gr, tn)));
});
const GroupUnhintedTTFs = task.group("ttf-unhinted", async (target, gr) => {
	const [ts] = await target.need(GroupFontsOf(gr));
	await target.need(ts.map(tn => DistUnhintedTTF(gr, tn)));
});
const GroupWebFonts = task.group("webfont", async (target, gr) => {
	const [ts] = await target.need(GroupFontsOf(gr));
	await target.need(GroupWoff2s(gr), DistWebFontCSS(gr));
});
const GroupWoff2s = task.group("woff2", async (target, gr) => {
	const [ts] = await target.need(GroupFontsOf(gr));
	await target.need(ts.map(tn => DistWoff2(gr, tn)));
});
const GroupFonts = task.group("fonts", async (target, gr) => {
	await target.need(GroupTTFs(gr), GroupUnhintedTTFs(gr), GroupWoff2s(gr));
});

// Per group file
const DistUnhintedTTF = file.make(
	(gr, fn) => `${DIST}/${gr}/ttf-unhinted/${fn}.ttf`,
	async (target, out, gr, f) => {
		const [from] = await target.need(BuildTTF(gr, f), de`${out.dir}`);
		await cp(from.full, out.full);
	}
);
const DistHintedTTF = file.make(
	(gr, fn) => `${DIST}/${gr}/ttf/${fn}.ttf`,
	async (target, out, gr, f) => {
		const [{ hintParams }, hint] = await target.need(FontInfoOf(f), CheckTtfAutoHintExists);
		const [from] = await target.need(BuildTTF(gr, f), de`${out.dir}`);
		echo.action(echo.hl.command(`Hint TTF`), from.full, echo.hl.operator("->"), out.full);
		await silently.run(hint, hintParams, from.full, out.full);
	}
);
const DistWoff2 = file.make(
	(gr, fn) => `${DIST}/${gr}/woff2/${fn}.woff2`,
	async (target, out, group, f) => {
		const [from] = await target.need(DistHintedTTF(group, f), de`${out.dir}`);
		echo.action(echo.hl.command("Create WOFF2"), from.full, echo.hl.operator("->"), out.full);
		await silently.node(`utility/ttf-to-woff2.js`, from.full, out.full);
	}
);

///////////////////////////////////////////////////////////
//////            Font Collection Plans              //////
///////////////////////////////////////////////////////////

const CollectPlans = computed(`metadata:collect-plans`, async target => {
	const [rawPlans, suffixMapping] = await target.need(RawPlans, StandardSuffixes);
	return await getCollectPlans(
		target,
		rawPlans.collectPlans,
		suffixMapping,
		rawPlans.collectConfig,
		fnStandardTtc
	);
});

const StandardSuffixes = computed(`metadata:standard-suffixes`, async target => {
	const [rp] = await target.need(RawPlans);
	return getSuffixMapping(rp.weights, rp.slopes, rp.widths);
});

async function getCollectPlans(target, rawCollectPlans, suffixMapping, config, fnFileName) {
	const glyfTtcComposition = {},
		ttcComposition = {},
		ttcContents = {},
		groupDecomposition = {};
	for (const collectPrefix in rawCollectPlans) {
		const groupFileList = new Set();
		const collect = rawCollectPlans[collectPrefix];
		if (!collect || !collect.from || !collect.from.length) continue;

		for (const prefix of collect.from) {
			const [gri] = await target.need(BuildPlanOf(prefix));
			const ttfFileNameSet = new Set(gri.targets);
			for (const suffix in suffixMapping) {
				const sfi = suffixMapping[suffix];
				const ttcFileName = fnFileName(
					config,
					collectPrefix,
					sfi.weight,
					sfi.width,
					sfi.slope
				);
				const glyfTtcFileName = fnFileName(
					{ ...config, distinguishWidths: true },
					collectPrefix,
					sfi.weight,
					sfi.width,
					sfi.slope
				);

				const ttfTargetName = makeFileName(prefix, suffix);
				if (!ttfFileNameSet.has(ttfTargetName)) continue;

				if (!glyfTtcComposition[glyfTtcFileName]) glyfTtcComposition[glyfTtcFileName] = [];
				glyfTtcComposition[glyfTtcFileName].push({ dir: prefix, file: ttfTargetName });
				if (!ttcComposition[ttcFileName]) ttcComposition[ttcFileName] = [];
				ttcComposition[ttcFileName].push(glyfTtcFileName);

				groupFileList.add(ttcFileName);
			}
		}
		ttcContents[collectPrefix] = [...groupFileList];
		groupDecomposition[collectPrefix] = [...collect.from];
	}
	return { glyfTtcComposition, ttcComposition, ttcContents, groupDecomposition };
}
function fnStandardTtc(collectConfig, prefix, w, wd, s) {
	const ttcSuffix = makeSuffix(
		collectConfig.distinguishWeights ? w : WEIGHT_NORMAL,
		collectConfig.distinguishWidths ? wd : WIDTH_NORMAL,
		collectConfig.distinguishSlope ? s : SLOPE_NORMAL,
		DEFAULT_SUBFAMILY
	);
	return `${prefix}-${ttcSuffix}`;
}

///////////////////////////////////////////////////////////
//////               Font Collection                 //////
///////////////////////////////////////////////////////////

const SpecificSuperTtc = task.group(`super-ttc`, async (target, cgr) => {
	await target.need(CollectedSuperTtcFile(cgr));
});
const CollectedSuperTtcFile = file.make(
	cgr => `${DIST_SUPER_TTC}/${cgr}.ttc`,
	async (target, out, cgr) => {
		const [cp] = await target.need(CollectPlans, de(out.dir));
		const parts = Array.from(new Set(cp.ttcContents[cgr]));
		const [inputs] = await target.need(parts.map(pt => CollectedTtcFile(cgr, pt)));
		await buildCompositeTtc(out, inputs);
	}
);
const CollectedTtcFile = file.make(
	(cgr, f) => `${BUILD}/ttc-collect/${cgr}/ttc/${f}.ttc`,
	async (target, out, gr, f) => {
		const [cp] = await target.need(CollectPlans, de`${out.dir}`);
		const parts = Array.from(new Set(cp.ttcComposition[f]));
		const [inputs] = await target.need(parts.map(pt => GlyfTtc(gr, pt)));
		await buildCompositeTtc(out, inputs);
	}
);
const GlyfTtc = file.make(
	(cgr, f) => `${BUILD}/glyf-ttc/${cgr}/${f}.ttc`,
	async (target, out, gr, f) => {
		const [cp] = await target.need(CollectPlans);
		const parts = cp.glyfTtcComposition[f];
		await buildGlyphSharingTtc(target, parts, out);
	}
);
async function buildCompositeTtc(out, inputs) {
	const inputPaths = inputs.map(f => f.full);
	await run(TTCIZE, ["-o", out.full], inputPaths);
}
async function buildGlyphSharingTtc(target, parts, out) {
	await target.need(de`${out.dir}`);
	const [ttfInputs] = await target.need(parts.map(part => BuildTTF(part.dir, part.file)));
	const tmpTtc = `${out.dir}/${out.name}.unhinted.ttc`;
	const ttfInputPaths = ttfInputs.map(p => p.full);
	await run(TTCIZE, "-u", ["-o", tmpTtc], ttfInputPaths);
	await run("ttfautohint", tmpTtc, out.full);
	await rm(tmpTtc);
}

///////////////////////////////////////////////////////////
//////                   Archives                    //////
///////////////////////////////////////////////////////////

// Collection Archives
const TtcArchiveFile = file.make(
	(cgr, version) => `${ARCHIVE_DIR}/ttc-${cgr}-${version}.zip`,
	async (target, out, cgr) => {
		const [collectPlans] = await target.need(CollectPlans, de`${out.dir}`);
		const ttcFiles = Array.from(new Set(collectPlans.ttcContents[cgr]));
		await target.need(ttcFiles.map(pt => CollectedTtcFile(cgr, pt)));

		// Packaging
		await rm(out.full);
		await cd(`${BUILD}/ttc-collect/${cgr}/ttc`).run(
			["7z", "a"],
			["-tzip", "-r", "-mx=9"],
			`../../../../${out.full}`,
			`./`
		);
	}
);
const SuperTtcArchiveFile = file.make(
	(cgr, version) => `${ARCHIVE_DIR}/super-ttc-${cgr}-${version}.zip`,
	async (target, out, cgr) => {
		await target.need(de`${out.dir}`, CollectedSuperTtcFile(cgr));

		// Packaging
		await rm(out.full);
		await cd(DIST_SUPER_TTC).run(
			["7z", "a"],
			["-tzip", "-r", "-mx=9"],
			Path.relative(DIST_SUPER_TTC, out.full),
			`${cgr}.ttc`
		);
	}
);

// Single-group Archives
const GroupTtfArchiveFile = file.make(
	(gr, version) => `${ARCHIVE_DIR}/ttf-${gr}-${version}.zip`,
	async (target, out, gr) => {
		await target.need(de`${out.dir}`);
		await target.need(GroupContents(gr));
		await CreateGroupArchiveFile(`${DIST}/${gr}/ttf`, out, "*.ttf");
	}
);
const GroupTtfUnhintedArchiveFile = file.make(
	(gr, version) => `${ARCHIVE_DIR}/ttf-unhinted-${gr}-${version}.zip`,
	async (target, out, gr) => {
		await target.need(de`${out.dir}`);
		await target.need(GroupContents(gr));
		await CreateGroupArchiveFile(`${DIST}/${gr}/ttf-unhinted`, out, "*.ttf");
	}
);
const GroupWebArchiveFile = file.make(
	(gr, version) => `${ARCHIVE_DIR}/webfont-${gr}-${version}.zip`,
	async (target, out, gr) => {
		await target.need(de`${out.dir}`);
		await target.need(GroupContents(gr));
		await CreateGroupArchiveFile(`${DIST}/${gr}`, out, "*.css", "ttf", "woff2");
	}
);
async function CreateGroupArchiveFile(dir, out, ...files) {
	const relOut = Path.relative(dir, out.full);
	await rm(out.full);
	await cd(dir).run(["7z", "a"], ["-tzip", "-r", "-mx=9"], relOut, ...files);
}

///////////////////////////////////////////////////////////
//////                    Exports                    //////
///////////////////////////////////////////////////////////

///////////////////////////////////////////////////////////
// Sample Images

const Pages = task(`pages`, async t => {
	await t.need(PagesDataExport, PagesFontExport);
});

const PagesDir = oracle(`pages-dir-path`, async t => {
	const [rp] = await t.need(RawPlans);
	if (!rp.buildOptions || !rp.buildOptions.__pagesDir) fail("Pages directory not found");
	return rp.buildOptions.__pagesDir;
});

const PagesDataExport = task(`pages:data-export`, async t => {
	const [pagesDir] = await t.need(PagesDir, Version, Parameters, UtilScripts);
	const [cm] = await t.need(BuildCM("iosevka", "iosevka-regular"));
	const [cmi] = await t.need(BuildCM("iosevka", "iosevka-italic"));
	const [cmo] = await t.need(BuildCM("iosevka", "iosevka-oblique"));
	await run(
		`node`,
		`utility/export-data/index`,
		cm.full,
		cmi.full,
		cmo.full,
		Path.resolve(pagesDir, "shared/data-import/raw/metadata.json"),
		Path.resolve(pagesDir, "shared/data-import/raw/coverage.json")
	);
});

const PagesFontExport = task(`pages:font-export`, async t => {
	const [pagesDir] = await t.need(PagesDir);
	const dirs = await t.need(
		GroupContents`iosevka`,
		GroupContents`iosevka-slab`,
		GroupContents`iosevka-aile`,
		GroupContents`iosevka-etoile`,
		GroupContents`iosevka-sparkle`
	);

	for (const dir of dirs) exportFontDir(pagesDir, dir);
});

const PagesFastFontExport = task.group(`pages:fast-font-export`, async (target, g) => {
	const [pagesDir] = await target.need(PagesDir);
	if (!pagesDir) return;
	const dirs = await target.need(GroupContents(g));
	for (const dir of dirs) exportFontDir(pagesDir, dir);
});

async function exportFontDir(pagesDir, dir) {
	await cp(`${DIST}/${dir}`, Path.resolve(pagesDir, "shared/fonts", dir));
}

///////////////////////////////////////////////////////////
// Sample Images

const SampleImages = task(`sample-images`, async target => {
	const [cfgP, sh] = await target.need(PackageSnapshotConfig, SnapShotHtml, TakeSampleImages);
	const de = JSON.parse(fs.readFileSync(`${sh.dir}/${sh.name}.data.json`));
	await target.need(
		cfgP.map(opt => ScreenShot(opt.name)),
		de.readmeSnapshotTasks.map(opt => ScreenShot(opt.name))
	);
});

const SampleImagesPre = task(`sample-images:pre`, async target => {
	const [sans, slab, aile, etoile, sparkle] = await target.need(
		GroupContents`iosevka`,
		GroupContents`iosevka-slab`,
		GroupContents`iosevka-aile`,
		GroupContents`iosevka-etoile`,
		GroupContents`iosevka-sparkle`,
		SnapShotStatic("index.js"),
		SnapShotStatic("get-snap.js"),
		SnapShotJson,
		SnapShotCSS,
		SnapShotHtml,
		de`images`,
		de(SNAPSHOT_TMP)
	);
	await cp(`${DIST}/${sans}`, `${SNAPSHOT_TMP}/${sans}`);
	await cp(`${DIST}/${slab}`, `${SNAPSHOT_TMP}/${slab}`);
	await cp(`${DIST}/${aile}`, `${SNAPSHOT_TMP}/${aile}`);
	await cp(`${DIST}/${etoile}`, `${SNAPSHOT_TMP}/${etoile}`);
	await cp(`${DIST}/${sparkle}`, `${SNAPSHOT_TMP}/${sparkle}`);
});

const PackageSnapshotConfig = computed(`package-snapshot-config`, async target => {
	const [plan] = await target.need(BuildPlans);
	const cfg = [];
	for (const key in plan.buildPlans) {
		const p = plan.buildPlans[key];
		if (!p || !p.snapshotFamily) continue;
		cfg.push({
			el: "#packaging-sampler",
			applyClass: p.snapshotFamily,
			applyFeature: p.snapshotFeature,
			name: key
		});
	}
	return cfg;
});
const SnapShotJson = file(`${SNAPSHOT_TMP}/packaging-tasks.json`, async (target, out) => {
	const [cfg] = await target.need(PackageSnapshotConfig, de(out.dir));
	fs.writeFileSync(out.full, JSON.stringify(cfg, null, "  "));
});
const SnapShotHtml = file(`${SNAPSHOT_TMP}/index.html`, async (target, out) => {
	await target.need(Parameters, UtilScripts, SnapshotTemplates, de(out.dir));
	const [cm, cmi, cmo] = await target.need(
		BuildCM("iosevka", "iosevka-regular"),
		BuildCM("iosevka", "iosevka-italic"),
		BuildCM("iosevka", "iosevka-oblique")
	);
	await run(
		`node`,
		`utility/generate-snapshot-page/index.js`,
		"snapshot-src/templates",
		out.full,
		`${out.dir}/${out.name}.data.json`
	);
	await run(`node`, `utility/amend-readme/index`, cm.full, cmi.full, cmo.full);
});
const SnapShotStatic = file.make(
	x => `${SNAPSHOT_TMP}/${x}`,
	async (target, out) => {
		const [$1] = await target.need(sfu`snapshot-src/${out.base}`, de(out.dir));
		await cp($1.full, `${out.dir}/${$1.base}`);
	}
);
const SnapShotCSS = file(`${SNAPSHOT_TMP}/index.css`, async (target, out) => {
	const [$1] = await target.need(sfu`snapshot-src/index.styl`, de(out.dir));
	await cp($1.full, `${out.dir}/${$1.base}`);
	await run(`npx`, `stylus`, `${out.dir}/${$1.base}`, `-c`);
});
const TakeSampleImages = task(`sample-images:take`, async target => {
	await target.need(SampleImagesPre);
	await cd(SNAPSHOT_TMP).run("npx", "electron", "get-snap.js", "../../images");
});
const ScreenShot = file.make(
	img => `images/${img}.png`,
	async (target, { full }) => {
		await target.need(TakeSampleImages);
		await run("optipng", full);
	}
);

///////////////////////////////////////////////////////////
// Release notes

const ReleaseNotes = task(`release:release-note`, async t => {
	const [version] = await t.need(Version);
	await t.need(ReleaseNotesFile(version));
});
const ReleaseNotesFile = file.make(
	version => `${ARCHIVE_DIR}/release-notes-${version}.md`,
	async (t, out, version) => {
		await t.need(UtilScripts, de(ARCHIVE_DIR));
		const [changeFiles, rpFiles] = await t.need(ChangeFileList(), ReleaseNotePackagesFile);
		await t.need(changeFiles.map(fu));
		await run("node", "utility/generate-release-note/index", version, rpFiles.full, out.full);
	}
);
const ReleaseNotePackagesFile = file(`${BUILD}/release-packages.json`, async (t, out) => {
	const [collectPlans] = await t.need(CollectPlans);
	const [{ buildPlans }] = await t.need(BuildPlans);
	let releaseNoteGroups = {};
	for (const [k, g] of Object.entries(collectPlans.groupDecomposition)) {
		const primePlan = buildPlans[g[0]];
		let subGroups = {};
		for (const gr of g) {
			const bp = buildPlans[gr];
			subGroups[gr] = {
				family: bp.family,
				desc: bp.desc,
				spacing: buildPlans[gr].spacing || "type"
			};
		}
		releaseNoteGroups[k] = {
			subGroups,
			slab: primePlan.serifs === "slab",
			quasiProportional: primePlan.spacing === "quasi-proportional"
		};
	}
	await fs.promises.writeFile(out.full, JSON.stringify(releaseNoteGroups, null, "  "));
});
const ChangeLog = task(`release:change-log`, async t => {
	await t.need(ChangeLogFile);
});
const ChangeLogFile = file(`CHANGELOG.md`, async (t, out) => {
	const [version] = await t.need(Version);
	await t.need(UtilScripts, de(ARCHIVE_DIR));
	const [changeFiles] = await t.need(ChangeFileList());
	await t.need(changeFiles.map(fu));
	await run("node", "utility/generate-change-log/index", version, out.full);
});
const ChangeFileList = oracle.make(
	() => `release:change-file-list`,
	target => FileList({ under: "changes", pattern: "*.md" })(target)
);

///////////////////////////////////////////////////////////
//////                   Entries                     //////
///////////////////////////////////////////////////////////

phony(`clean`, async () => {
	await rm(BUILD);
	await rm(DIST);
	await rm(ARCHIVE_DIR);
	build.deleteJournal();
});
phony(`release`, async target => {
	const [version, collectPlans] = await target.need(Version, CollectPlans);
	let goals = [];
	for (const [cgr, subGroups] of Object.entries(collectPlans.groupDecomposition)) {
		goals.push(TtcArchiveFile(cgr, version), SuperTtcArchiveFile(cgr, version));
		for (const gr of subGroups) {
			goals.push(
				GroupTtfArchiveFile(gr, version),
				GroupTtfUnhintedArchiveFile(gr, version),
				GroupWebArchiveFile(gr, version)
			);
		}
	}
	await target.need(goals);
	await target.need(SampleImages, Pages, ReleaseNotes, ChangeLog);
});

///////////////////////////////////////////////////////////
//////               Script Building                 //////
///////////////////////////////////////////////////////////

const MARCOS = [fu`font-src/meta/macros.ptl`];
const ScriptsUnder = oracle.make(
	(ext, dir) => `${ext}-scripts-under::${dir}`,
	(target, ext, dir) => FileList({ under: dir, pattern: `**/*.${ext}` })(target)
);
const UtilScriptFiles = computed("util-script-files", async target => {
	const [js, ejs, md] = await target.need(
		ScriptsUnder("js", "utility"),
		ScriptsUnder("ejs", "utility"),
		ScriptsUnder("md", "utility")
	);
	return [...js, ...ejs, ...md];
});
const SnapshotTemplateFiles = computed("snapshot-templates", async target => {
	const [js, ejs, md] = await target.need(
		ScriptsUnder("js", "snapshot-src"),
		ScriptsUnder("ejs", "snapshot-src"),
		ScriptsUnder("md", "snapshot-src")
	);
	return [...js, ...ejs, ...md];
});
const ScriptFiles = computed.group("script-files", async (target, ext) => {
	const [ss] = await target.need(ScriptsUnder(ext, `font-src`));
	return ss;
});
const JavaScriptFromPtl = computed("scripts-js-from-ptl", async target => {
	const [ptl] = await target.need(ScriptFiles("ptl"));
	return ptl.map(x => replaceExt(".js", x));
});
function replaceExt(extNew, file) {
	return Path.posix.join(Path.dirname(file), Path.basename(file, Path.extname(file)) + extNew);
}

const CompiledJs = file.make(
	p => p,
	async (target, out) => {
		const ptl = replaceExt(".ptl", out.full);
		if (/\/glyphs\//.test(out.full)) await target.need(MARCOS);
		await target.need(sfu(ptl));
		echo.action(echo.hl.command("Compile Script"), ptl);
		await silently.run(PATEL_C, "--strict", ptl, "-o", out.full);
	}
);
const Scripts = task("scripts", async target => {
	const [jsFromPtlList] = await target.need(JavaScriptFromPtl);
	const [jsList] = await target.need(ScriptFiles("js"));
	const jsFromPtlSet = new Set(jsFromPtlList);

	let subGoals = [];
	for (const js of jsFromPtlSet) subGoals.push(CompiledJs(js));
	for (const js of jsList) if (!jsFromPtlSet.has(js)) subGoals.push(sfu(js));
	await target.need(Parameters, subGoals);
});
const UtilScripts = task("util-scripts", async target => {
	const [files] = await target.need(UtilScriptFiles);
	await target.need(files.map(fu));
});
const SnapshotTemplates = task("snapshot-templates", async target => {
	const [files] = await target.need(SnapshotTemplateFiles);
	await target.need(files.map(fu));
});
const Parameters = task(`meta:parameters`, async target => {
	await target.need(
		Version,
		sfu`params/parameters.toml`,
		sfu`params/shape-weight.toml`,
		sfu`params/shape-width.toml`,
		ofu`params/private-parameters.toml`,
		sfu`params/variants.toml`,
		sfu`params/ligation-set.toml`
	);
});

///////////////////////////////////////////////////////////
//////              Config Validation                //////
///////////////////////////////////////////////////////////

// Build plan validation
function validateAndShimBuildPlans(prefix, bp, dWeights, dSlopes, dWidths) {
	if (!bp.family) {
		fail(`Build plan for ${prefix} does not have a family name. Exit.`);
	}
	if (!bp.slopes && bp.slants) {
		echo.warn(
			`Build plan for ${prefix} uses legacy "slants" to define slopes. ` +
				`Use "slopes" instead.`
		);
	}

	bp.weights = bp.weights || dWeights;
	bp.slopes = bp.slopes || bp.slants || dSlopes;
	bp.widths = bp.widths || dWidths;
}

// Recommended weight validation
function validateRecommendedWeight(w, value, label) {
	const RecommendedMenuWeights = {
		thin: 100,
		extralight: 200,
		light: 300,
		regular: 400,
		book: 450,
		medium: 500,
		semibold: 600,
		bold: 700,
		extrabold: 800,
		heavy: 900
	};
	if (RecommendedMenuWeights[w] && RecommendedMenuWeights[w] !== value) {
		echo.warn(
			`${label} weight settings of ${w} ( = ${value}) doesn't match ` +
				`the recommended value ( = ${RecommendedMenuWeights[w]}).`
		);
	}
}

// Value validation
function nValidate(key, v, validator) {
	if (validator.fixup) v = validator.fix(v);
	if (typeof v !== "number" || !isFinite(v) || !validator.validate(v)) {
		throw new TypeError(`${key} = ${v} is not a valid number.`);
	}
	return v;
}

const VlShapeWeight = { validate: x => x >= 100 && x <= 900 };
const VlCssWeight = { validate: x => x > 0 && x < 1000 };
const VlMenuWeight = VlCssWeight;

const g_widthFixupMemory = new Map();
const VlShapeWidth = {
	validate: x => x >= 433 && x <= 665,
	fix(x) {
		if (x >= 3 && x <= 9) {
			if (g_widthFixupMemory.has(x)) return g_widthFixupMemory.get(x);
			const xCorrected = Math.round(500 * Math.pow(Math.sqrt(576 / 500), x - 5));
			echo.warn(
				`The build plan is using legacy width grade ${x}. ` +
					`Converting to unit width ${xCorrected}.`
			);
			g_widthFixupMemory.set(x, xCorrected);
			return xCorrected;
		} else {
			return x;
		}
	}
};
const VlMenuWidth = { validate: x => x >= 1 && x <= 9 && x % 1 === 0 };
