const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { execSync } = require("child_process");
const esbuild = require("esbuild");
const { resolveVersion } = require("./script/lib/version");
const {
    BACKGROUND_ENTRY_PREFIX,
    FORBIDDEN_INPUTS,
    assertTableWellFormed,
} = require("./script/lib/forbiddenBundleInputs");

const DIST = path.join(__dirname, "dist");
const DIST_CHROME = path.join(DIST, "chrome");
const DIST_FIREFOX = path.join(DIST, "firefox");
const SRC = path.join(__dirname, "src");

// The module whose compiled DEBUG state script/verify-build asserts. Which
// bundles contain it is derived from esbuild's own dependency graph rather
// than from a hardcoded list, so it tracks the bundle layout instead of
// rotting with it.
const AUDITED_MODULE = "src/shared/constants.js";

// FORBIDDEN_INPUTS — what each entry point's bundle may not contain, and what
// that covers — lives in script/lib/forbiddenBundleInputs.js, because the
// ESLint rule reads the same table and two literal copies of a path drift.
//
// This is the authoritative check, and it is here rather than in the linter
// because it consults the resolution esbuild actually performed. Any specifier
// syntax, any hop, any resolution rule that puts the module in the bundle fails
// the build, whether or not a text matcher would have recognized it. A
// background entry point the table does not name fails as well, so a second
// worker is protected by default rather than by someone remembering this file.
// Dockerfile:42 runs `make build`, so it is enforced in CI.

// The build receipt: every file this build emits, with its sha256 and whether
// it is one of the audited bundles. script/verify-build is handed this and
// checks dist/ against it, so the file list comes from the build that just ran
// rather than being read back out of the tree it is supposed to vouch for.
//
// The path is supplied by the caller, not chosen here, and the Makefile makes
// a fresh one per invocation outside the repo: that is what ties a receipt to
// one build rather than leaving a standing file anyone can write.
const RECEIPT_HEADER = "autistmask-build-receipt v1";
const RECEIPT_ENV = "AUTISTMASK_BUILD_RECEIPT";

// Every emitted path must be plainly nameable, because the receipt is a
// line-oriented text file consumed by a POSIX shell script and a path with a
// space or a newline in it could not be read back unambiguously. Nothing this
// build emits looks like that; if that ever changes, the build fails here
// rather than writing a receipt that cannot be checked.
const SAFE_EMITTED_PATH = /^dist\/[A-Za-z0-9._][A-Za-z0-9._/-]*$/;

function ensureDir(dir) {
    fs.mkdirSync(dir, { recursive: true });
}

// Repo-relative, forward-slashed, so the manifest reads the same on every
// platform and can be consumed by a POSIX shell script without further work.
function repoRelative(p) {
    return path.relative(__dirname, p).split(path.sep).join("/");
}

// Collect the outputs of one esbuild run that bundle AUDITED_MODULE. esbuild
// reports every input that contributed to an output in the metafile, which is
// the authoritative answer to "is constants.js in this bundle" — unlike
// searching the minified text, it does not depend on what survived minification.
//
// The ".js" filter below is the only place that assumption lives:
// script/verify-build reads every file the receipt names, whatever its
// extension, and fails on any that carries a debug marker without being
// recorded as an audited bundle — so a bundle emitted under some other
// extension fails there rather than escaping both checks at once.
function outputsContainingAuditedModule(metafile) {
    return Object.entries(metafile.outputs)
        .filter(([outFile, info]) => {
            if (!outFile.endsWith(".js")) return false;
            return Object.keys(info.inputs).some(
                (input) => repoRelative(input) === AUDITED_MODULE,
            );
        })
        .map(([outFile]) => repoRelative(outFile));
}

// Shortest import chain from `entryInput` to `target` through the metafile's
// own input graph, or null when there is none. The message this feeds is the
// point of the check: "state.js is in the worker bundle" is not actionable on
// its own, "index.js -> chainSwitchFields.js -> state.js" is.
function importChain(metafile, entryInput, target) {
    const graph = new Map(
        Object.entries(metafile.inputs).map(([input, info]) => [
            repoRelative(input),
            (info.imports || []).map((i) => repoRelative(i.path)),
        ]),
    );
    const start = repoRelative(entryInput);
    const seen = new Set([start]);
    const queue = [[start]];
    while (queue.length > 0) {
        const chain = queue.shift();
        for (const next of graph.get(chain[chain.length - 1]) || []) {
            if (next === target) return chain.concat([next]);
            if (seen.has(next)) continue;
            seen.add(next);
            queue.push(chain.concat([next]));
        }
    }
    return null;
}

// What the forbidden-input checks accumulate over a whole build: which
// FORBIDDEN_INPUTS keys were actually bundled, and every input of every output
// this build emitted. Both are read by assertForbiddenTableCovered() at the
// end — a table entry naming something that is not there any more enforces
// nothing, and must fail rather than pass quietly.
function newForbiddenRecord() {
    return { entriesChecked: new Set(), bundledInputs: new Set() };
}

// Note every input of every output of one esbuild run. Deliberately not
// restricted to the entry points named in FORBIDDEN_INPUTS: it is the POPUP
// that legitimately bundles src/shared/state.js, and that is what makes
// "the forbidden module still exists at this path" checkable at all.
function recordBundledInputs(metafile, record) {
    for (const info of Object.values(metafile.outputs)) {
        for (const input of Object.keys(info.inputs)) {
            record.bundledInputs.add(repoRelative(input));
        }
    }
}

// Fail the build when an entry point's bundle contains a module it is
// prohibited from reaching. The inputs come from esbuild's metafile, so this is
// the resolution the shipped bundle was built from and not a guess at it.
//
// A background entry point with no line in the table fails here too. The five
// defects this exists to prevent were accidents, and so is adding a second
// worker entry point without knowing that a table somewhere needs a line: the
// protection has to be the default for that directory rather than something
// the next author must opt into.
function assertNoForbiddenInputs(
    entryPoint,
    outfile,
    metafile,
    record,
    table = FORBIDDEN_INPUTS,
) {
    const entry = repoRelative(entryPoint);
    const forbidden = table[entry];
    if (!forbidden) {
        if (!entry.startsWith(BACKGROUND_ENTRY_PREFIX)) return;
        throw new Error(
            `${entry} is a background entry point with no line in ` +
                `FORBIDDEN_INPUTS, so nothing stops its bundle from ` +
                `containing the shared state singleton. Add it to ` +
                `script/lib/forbiddenBundleInputs.js. The MV3 worker never ` +
                `populates that singleton, so reading it serves ` +
                `DEFAULT_STATE; use getState()/updateState() from ` +
                `src/background/state.js instead.`,
        );
    }

    const out = repoRelative(outfile);
    const entryOutput = Object.entries(metafile.outputs).find(
        ([outFile]) => repoRelative(outFile) === out,
    );
    if (!entryOutput) {
        throw new Error(`esbuild reported no metafile output for ${out}`);
    }
    const inputs = new Set(
        Object.keys(entryOutput[1].inputs).map(repoRelative),
    );

    // Recorded only once the bundle's inputs are actually in hand. Marking the
    // entry checked any earlier — as this did — means an early return above
    // satisfies assertForbiddenTableCovered() with a bundle nobody examined,
    // and the coverage half cannot tell that from a real check. The lookup
    // above is the fragile step: repoRelative() resolves against process.cwd()
    // while esbuild's output keys are cwd-relative, so a change to where the
    // build runs from could miss.
    record.entriesChecked.add(entry);

    for (const module of forbidden) {
        if (!inputs.has(module)) continue;
        const chain = importChain(metafile, entryPoint, module);
        throw new Error(
            `${out} bundles ${module}, which ${entry} must not reach` +
                `${chain ? `: ${chain.join(" -> ")}` : ""}. The MV3 worker ` +
                `never populates the shared state singleton, so reading it ` +
                `serves DEFAULT_STATE. Use getState()/updateState() from ` +
                `src/background/state.js instead.`,
        );
    }
}

// Fail the build when the table has rotted away from the tree it describes.
// Both halves of an entry rot independently, and either one turns the whole
// prohibition into a pass that checks nothing:
//
//   - the KEY, when no bundled entry point matches it: the entry point was
//     renamed or is no longer built, and no bundle was ever tested against the
//     list;
//   - the MODULE, when this build bundled it nowhere: the module was renamed,
//     moved or deleted, so "is it an input of the background bundle" is asked
//     about a path nothing resolves to and is answered no forever. The popup
//     legitimately bundles src/shared/state.js, which is what makes this
//     checkable — and it is stronger than an existsSync(), because it also
//     fails when the file is still there but has dropped out of every bundle.
//
// This matters concretely: https://git.eeqj.de/sneak/AutistMask/issues/311
// rewrites this persistence layer, and a rename that quietly disarmed the
// guarantee would put the singleton back within reach of the worker with every
// check in the repo still green.
//
// The third way — an entry that lists no modules at all — is refused where the
// table is defined, at require time, because that one also empties the ESLint
// rule's forbidden set and so has to fail before either layer runs. It is
// re-checked here so the build's own half does not depend on the table having
// been loaded from that file.
function assertForbiddenTableCovered(record, table = FORBIDDEN_INPUTS) {
    assertTableWellFormed(table);
    for (const [entry, modules] of Object.entries(table)) {
        if (!record.entriesChecked.has(entry)) {
            throw new Error(
                `${entry} is listed in FORBIDDEN_INPUTS but was not bundled, ` +
                    `so nothing checked it`,
            );
        }
        for (const module of modules) {
            if (record.bundledInputs.has(module)) continue;
            throw new Error(
                `${module} is listed in FORBIDDEN_INPUTS for ${entry}, but ` +
                    `this build bundled it nowhere, so the prohibition names ` +
                    `a module that is not in this tree at that path and ` +
                    `nothing enforces it. If the module moved, move it in ` +
                    `script/lib/forbiddenBundleInputs.js too, which both ` +
                    `this check and the ESLint rule read.`,
            );
        }
    }
}

// Every file this build writes under dist/, recorded as it is written. This is
// the build's own account of what it emitted; it is never recovered by
// listing dist/, because a file that is in dist/ without this build having put
// it there is exactly what the receipt exists to expose.
const emittedFiles = [];

function recordEmitted(absPath) {
    emittedFiles.push(absPath);
}

// Copying is the only other way a file reaches dist/; esbuild and the Tailwind
// CLI record their outputs where they are invoked.
function copyEmitted(src, dest) {
    fs.copyFileSync(src, dest);
    recordEmitted(dest);
}

function sha256File(absPath) {
    return crypto
        .createHash("sha256")
        .update(fs.readFileSync(absPath))
        .digest("hex");
}

// Write the receipt for the files this build emitted. Deliberately records no
// build mode: which mode was asked for is script/verify-build's argument, so
// build.js cannot vouch for build.js. All the receipt says is "these bytes,
// under these names, are what I wrote, and these ones bundle constants.js".
function writeReceipt(receiptPath, auditedBundles) {
    const audited = new Set(auditedBundles);
    const paths = [...new Set(emittedFiles.map(repoRelative))].sort();

    for (const p of paths) {
        if (!SAFE_EMITTED_PATH.test(p)) {
            throw new Error(
                `emitted path cannot be written to a build receipt: ${JSON.stringify(p)}`,
            );
        }
    }

    // A bundle esbuild reported but that nothing recorded as emitted means the
    // two halves have drifted apart, and the receipt would then leave an
    // audited bundle out. Fail rather than emit a short receipt.
    for (const bundle of audited) {
        if (!paths.includes(bundle)) {
            throw new Error(
                `${bundle} contains ${AUDITED_MODULE} but was not recorded as emitted`,
            );
        }
    }
    if (audited.size === 0) {
        throw new Error(
            `no emitted bundle contains ${AUDITED_MODULE}, which is never correct`,
        );
    }

    const lines = [RECEIPT_HEADER, `root ${fs.realpathSync(__dirname)}`];
    for (const p of paths) {
        const flag = audited.has(p) ? "A" : "P";
        lines.push(`file ${sha256File(path.join(__dirname, p))} ${flag} ${p}`);
    }
    fs.writeFileSync(receiptPath, lines.map((l) => `${l}\n`).join(""));

    console.log(
        `Build receipt: ${paths.length} emitted file(s), ${audited.size} ` +
            `containing ${AUDITED_MODULE} (${receiptPath})`,
    );
}

// Where the receipt goes, decided before anything is emitted so a build that
// cannot produce a checkable receipt fails before it writes any artifacts.
// Inside dist/ is refused: a receipt that lives in the tree it describes can
// be rewritten by whoever rewrites the tree, which is the hole this replaces.
function receiptTarget() {
    const requested = process.env[RECEIPT_ENV];
    if (!requested) {
        return null;
    }
    const resolved = path.resolve(requested);
    if (resolved === DIST || resolved.startsWith(DIST + path.sep)) {
        throw new Error(
            `${RECEIPT_ENV} points inside dist/ (${resolved}). The receipt ` +
                `describes dist/ and must not live in it.`,
        );
    }
    return resolved;
}

// DEBUG is a build-time flag, off unless explicitly requested. It is the only
// thing that makes the hardcoded test mnemonic reachable, so the opt-in must be
// exact: anything other than the literal "1" (unset, empty, "true", a typo)
// produces a release build. Failing towards the safe mode is deliberate.
function isDebugBuild() {
    return process.env.AUTISTMASK_DEBUG === "1";
}

// A short git output, or null when git cannot answer. Distinguishing "git said
// nothing" from "git could not be asked" matters below: a working tree whose
// state is unknown must not be stamped as clean.
function git(args) {
    try {
        return execSync(`git ${args}`, {
            encoding: "utf8",
            stdio: ["ignore", "pipe", "ignore"],
        }).trim();
    } catch {
        // not a git repo, or git not available
        return null;
    }
}

// The working-tree state, as a suffix for the displayed commit: "" when the
// tree matches HEAD, "-dirty" when it does not, "-unknown" when git answered
// the hash but not the status. Without this a build from a modified tree
// stamped a clean hash, so the About screen named a commit whose contents were
// not what was running — the one thing that stamp exists to establish.
//
// git status --porcelain honours .gitignore, so dist/ and node_modules/ do not
// make every build dirty; an untracked file that is NOT ignored does, and
// correctly: it may well be in the bundle.
function worktreeSuffix() {
    const status = git("status --porcelain");
    if (status === null) return "-unknown";
    return status === "" ? "" : "-dirty";
}

function getBuildInfo() {
    const pkg = JSON.parse(
        fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
    );
    const commitHashFull = git("rev-parse HEAD") || "unknown";
    const shortHash = git("rev-parse --short HEAD") || "unknown";
    // The full hash is left clean because it is the href of the commit link in
    // the About screen, and "abc123-dirty" is not a commit anyone can fetch.
    // The displayed short hash carries the marker, so the screen says the tree
    // was modified while still linking somewhere real.
    const commitHash =
        shortHash === "unknown" ? shortHash : shortHash + worktreeSuffix();
    return {
        // Fails the build when package.json and the two manifests disagree;
        // see script/lib/version.js. Called before anything is emitted, so a
        // tree with no single version never reaches dist/.
        version: resolveVersion(__dirname),
        license: pkg.license,
        author: pkg.author,
        commitHash,
        commitHashFull,
        buildDate: new Date().toISOString().slice(0, 10),
    };
}

async function build() {
    console.log("Building AutistMask extension...");

    const receiptPath = receiptTarget();
    if (!receiptPath) {
        console.warn(
            `WARNING: ${RECEIPT_ENV} is unset, so this build writes no ` +
                `receipt and script/verify-build cannot verify what it ` +
                `emitted. Build through make build / make build-debug.`,
        );
    }

    const buildInfo = getBuildInfo();
    console.log("Build info:", buildInfo);

    const debugBuild = isDebugBuild();
    console.log(
        debugBuild
            ? "Build mode: DEBUG (INSECURE - hardcoded test mnemonic, do not ship)"
            : "Build mode: release (DEBUG off)",
    );

    const define = {
        __BUILD_DEBUG__: JSON.stringify(debugBuild),
        __BUILD_VERSION__: JSON.stringify(buildInfo.version),
        __BUILD_LICENSE__: JSON.stringify(buildInfo.license),
        __BUILD_AUTHOR__: JSON.stringify(buildInfo.author),
        __BUILD_COMMIT__: JSON.stringify(buildInfo.commitHash),
        __BUILD_COMMIT_FULL__: JSON.stringify(buildInfo.commitHashFull),
        __BUILD_DATE__: JSON.stringify(buildInfo.buildDate),
    };

    // Emitted bundles that contain constants.js, accumulated across every
    // esbuild run below and recorded in the receipt for script/verify-build.
    const auditedBundles = [];

    // What the forbidden-input checks accumulate across those same runs.
    const forbiddenRecord = newForbiddenRecord();

    // compile tailwind CSS
    console.log("Compiling Tailwind CSS...");
    const tailwindInput = path.join(SRC, "popup", "styles", "main.css");
    const tailwindOutput = path.join(DIST, "styles.css");

    // Start from an empty dist/, so what is there afterwards is what this
    // build put there and nothing else. Leftovers from an earlier build are
    // not covered by this build's receipt, and script/verify-build rejects
    // any file it did not emit rather than ignoring it.
    fs.rmSync(DIST, { recursive: true, force: true });
    ensureDir(DIST);

    // The locally installed binary, not `npx` — npx silently fetches from the
    // registry when the binary is absent, which is an unpinned network fetch
    // in the middle of a build.
    const tailwindBin = path.join(
        __dirname,
        "node_modules",
        ".bin",
        "tailwindcss",
    );
    execSync(
        `"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`,
        { stdio: "inherit" },
    );
    recordEmitted(tailwindOutput);

    // Every bundle goes through here, so metafile collection cannot be
    // forgotten when a new entry point is added.
    async function bundle(entryPoint, outfile) {
        const result = await esbuild.build({
            entryPoints: [entryPoint],
            bundle: true,
            format: "iife",
            outfile,
            platform: "browser",
            target: ["chrome110", "firefox110"],
            minify: true,
            metafile: true,
            define,
        });
        // Before the output is recorded as emitted: a bundle that violates a
        // prohibition must abort the build, not be written into a receipt.
        recordBundledInputs(result.metafile, forbiddenRecord);
        assertNoForbiddenInputs(
            entryPoint,
            outfile,
            result.metafile,
            forbiddenRecord,
        );
        recordEmitted(outfile);
        auditedBundles.push(...outputsContainingAuditedModule(result.metafile));
    }

    for (const distDir of [DIST_CHROME, DIST_FIREFOX]) {
        ensureDir(path.join(distDir, "src", "popup"));
        ensureDir(path.join(distDir, "src", "background"));
        ensureDir(path.join(distDir, "src", "content"));

        // bundle popup JS with esbuild (inlines ethers, libsodium, etc.)
        await bundle(
            path.join(SRC, "popup", "index.js"),
            path.join(distDir, "src", "popup", "index.js"),
        );

        // bundle background script
        await bundle(
            path.join(SRC, "background", "index.js"),
            path.join(distDir, "src", "background", "index.js"),
        );

        // bundle content script
        await bundle(
            path.join(SRC, "content", "index.js"),
            path.join(distDir, "src", "content", "index.js"),
        );

        // bundle inpage script (injected into page context, separate file)
        await bundle(
            path.join(SRC, "content", "inpage.js"),
            path.join(distDir, "src", "content", "inpage.js"),
        );

        // copy popup HTML
        copyEmitted(
            path.join(SRC, "popup", "index.html"),
            path.join(distDir, "src", "popup", "index.html"),
        );

        // place compiled CSS next to popup HTML
        copyEmitted(
            tailwindOutput,
            path.join(distDir, "src", "popup", "styles.css"),
        );
    }

    // copy manifests
    copyEmitted(
        path.join(__dirname, "manifest", "chrome.json"),
        path.join(DIST_CHROME, "manifest.json"),
    );
    copyEmitted(
        path.join(__dirname, "manifest", "firefox.json"),
        path.join(DIST_FIREFOX, "manifest.json"),
    );

    assertForbiddenTableCovered(forbiddenRecord);

    // Written last so a build that died partway through leaves no receipt at
    // all, which script/verify-build treats as a hard failure rather than as
    // "nothing to check".
    if (receiptPath) {
        writeReceipt(receiptPath, auditedBundles);
    }

    console.log("Build complete: dist/chrome/ and dist/firefox/");
}

// Run only as a program. Required as a module — which is how
// tests/buildForbiddenInputs.test.js reaches the checks below — this file
// builds nothing and writes nothing.
if (require.main === module) {
    build().catch((err) => {
        console.error(
            `Build failed: ${err && err.message ? err.message : err}`,
        );
        process.exit(1);
    });
}

// Exported for tests/buildForbiddenInputs.test.js only. The prohibition these
// three functions enforce is the guarantee behind
// https://git.eeqj.de/sneak/AutistMask/issues/324, and `make check` does not
// run `make build` — so they are unit tested against synthetic metafiles
// rather than being exercised only by CI, where "it ran" is not "it works".
module.exports = {
    importChain,
    newForbiddenRecord,
    recordBundledInputs,
    assertNoForbiddenInputs,
    assertForbiddenTableCovered,
};
