const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
const esbuild = require("esbuild");

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, and the
// manifest naming every emitted bundle that ends up containing it. The
// manifest 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";
const BUNDLE_MANIFEST = path.join(DIST, "constants-bundles.txt");

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 searches every file and symlink under dist/ for a
// marker, without filtering by extension, and hard-fails if it cannot walk the
// whole tree, so a bundle emitted under some other extension fails there as
// unlisted 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));
}

// 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";
}

function getBuildInfo() {
    const pkg = JSON.parse(
        fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
    );
    let commitHash = "unknown";
    try {
        commitHash = execSync("git rev-parse --short HEAD", {
            encoding: "utf8",
        }).trim();
    } catch (_) {
        // not a git repo or git not available
    }
    let commitHashFull = "unknown";
    try {
        commitHashFull = execSync("git rev-parse HEAD", {
            encoding: "utf8",
        }).trim();
    } catch (_) {
        // not a git repo or git not available
    }
    return {
        version: pkg.version,
        license: pkg.license,
        author: pkg.author,
        commitHash,
        commitHashFull,
        buildDate: new Date().toISOString().slice(0, 10),
    };
}

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

    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 written out for script/verify-build.
    const auditedBundles = [];

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

    // Drop any manifest from a previous build before emitting anything, so a
    // build that never gets around to writing one cannot be verified against
    // a stale list.
    fs.rmSync(BUNDLE_MANIFEST, { force: true });
    // 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" },
    );

    // 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,
        });
        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
        fs.copyFileSync(
            path.join(SRC, "popup", "index.html"),
            path.join(distDir, "src", "popup", "index.html"),
        );

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

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

    // Written last so a build that died partway through leaves no manifest
    // at all, which script/verify-build treats as a hard failure rather than
    // as "nothing to check".
    const manifest = [...new Set(auditedBundles)].sort();
    fs.writeFileSync(BUNDLE_MANIFEST, manifest.map((p) => `${p}\n`).join(""));
    console.log(
        `Bundles containing ${AUDITED_MODULE}: ${manifest.length} ` +
            `(listed in ${repoRelative(BUNDLE_MANIFEST)})`,
    );

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

build();
