// Turn a verified dist/ into the two distributable archives.
//
// Invoked by script/package, which runs `make build` first so that dist/ has
// already been checked against the build's own receipt (see the Build Receipts
// section of README.md). This program does not build anything and does not
// write into dist/: it reads the emitted tree and writes release/.
//
//   release/autistmask-chrome-<version>.zip   loaded via chrome://extensions
//   release/autistmask-firefox-<version>.xpi  an UNSIGNED add-on, see README
//   release/SHA256SUMS
//
// Self-containment is checked rather than assumed, because the layout invites
// exactly one mistake: build.js emits dist/styles.css at the dist/ ROOT,
// outside both browser directories, and copies it into each of them as
// src/popup/styles.css. A naive `zip -r dist/chrome` is therefore correct only
// by accident, and would stop being correct the moment a reference pointed up
// and out. So every path the manifest and the popup HTML reference is resolved
// and required to be inside the archive, a reference that escapes the browser
// directory is a hard failure, and anything sitting at the dist/ root is
// listed as deliberately not shipped rather than silently dropped.
//
// The archive is then read back and compared byte for byte against the
// directory it was built from. An archive nobody opened is a claim, not an
// artifact.

"use strict";

const crypto = require("crypto");
const fs = require("fs");
const path = require("path");

const { readZip, writeZip } = require("./zip");
const { resolveVersion } = require("./version");

const ROOT = path.resolve(__dirname, "..", "..");
const DIST = path.join(ROOT, "dist");
const RELEASE = path.join(ROOT, "release");

const TARGETS = [
    { dir: "chrome", ext: "zip" },
    // .xpi rather than .zip: it is the same container, but Firefox's install
    // flow keys off the extension.
    { dir: "firefox", ext: "xpi" },
];

// Strings in a manifest that name a file the extension loads. Matched by
// shape, not by a list of manifest keys, so a key added in a later manifest
// version is covered the day it appears rather than the day someone remembers
// to extend a list here. Nothing else in either manifest looks like this: the
// CSP strings, "<all_urls>", the version and the base64 key all fail it.
const MANIFEST_PATH_RE =
    /^[A-Za-z0-9._][A-Za-z0-9._/-]*\.(?:js|css|html|json|png|svg|woff2?)$/;

// Local references out of an HTML document. Enough for what this repo emits —
// one stylesheet link and one script tag — and anything it does not understand
// is reported rather than passed over, see htmlReferences().
const HTML_REF_RE = /(?:src|href)\s*=\s*["']([^"']+)["']/gi;

function fail(message) {
    throw new Error(message);
}

function sha256(buf) {
    return crypto.createHash("sha256").update(buf).digest("hex");
}

// Every regular file under dir, as archive-root-relative forward-slashed
// paths. A symlink is refused rather than followed: build.js emits regular
// files only, so a link under dist/ is not something the build produced, and
// dereferencing one would put bytes from outside dist/ into the artifact.
function listFiles(dir, prefix = "") {
    const out = [];
    for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
        const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
        if (entry.isSymbolicLink()) {
            fail(
                `${dir}/${entry.name} is a symlink. The build emits regular ` +
                    `files only, so this is not something it produced and it ` +
                    `will not be archived.`,
            );
        } else if (entry.isDirectory()) {
            out.push(...listFiles(path.join(dir, entry.name), rel));
        } else if (entry.isFile()) {
            out.push(rel);
        } else {
            fail(
                `${dir}/${entry.name} is neither a regular file nor a ` +
                    `directory, so it is not something the build emitted`,
            );
        }
    }
    return out.sort();
}

// Collect every string anywhere in the manifest that looks like a file it
// loads, plus every string that tries to reach outside the extension root.
// The second half is the point: "../styles.css" never matches
// MANIFEST_PATH_RE, so without an explicit check an escaping reference would
// read as "not a path" and the missing file would be found only by a user
// whose popup rendered unstyled.
function manifestReferences(value, found = new Set()) {
    if (typeof value === "string") {
        if (value.split("/").includes("..")) {
            fail(
                `the manifest references ${JSON.stringify(value)}, which ` +
                    `points outside the extension root. Everything the ` +
                    `browser loads has to be inside the archive; nothing ` +
                    `above it is shipped.`,
            );
        }
        if (MANIFEST_PATH_RE.test(value)) found.add(value);
    } else if (Array.isArray(value)) {
        for (const v of value) manifestReferences(v, found);
    } else if (value && typeof value === "object") {
        for (const v of Object.values(value)) manifestReferences(v, found);
    }
    return found;
}

// Local references out of one HTML member, resolved against that member's own
// directory and returned archive-relative. Absolute URLs, data: URIs and
// in-page anchors are not files and are skipped; a relative reference that
// climbs out of the archive root is a failure for the same reason as above.
function htmlReferences(member, text) {
    const base = path.posix.dirname(member);
    const out = new Set();
    for (const match of text.matchAll(HTML_REF_RE)) {
        const ref = match[1].trim();
        if (ref === "" || ref.startsWith("#") || ref.startsWith("//")) continue;
        if (/^[a-z][a-z0-9+.-]*:/i.test(ref)) continue;
        if (ref.startsWith("/")) {
            fail(
                `${member} references ${JSON.stringify(ref)} from the ` +
                    `extension root. Nothing here emits root-absolute ` +
                    `references and this packager does not resolve them.`,
            );
        }
        const resolved = path.posix.normalize(path.posix.join(base, ref));
        if (resolved.startsWith("..")) {
            fail(
                `${member} references ${JSON.stringify(ref)}, which resolves ` +
                    `outside the extension root. build.js copies the ` +
                    `compiled stylesheet into each browser directory for ` +
                    `exactly this reason: dist/styles.css lives at the dist/ ` +
                    `root and is not part of either archive.`,
            );
        }
        out.add(resolved);
    }
    return out;
}

// Everything the browser is told to load, and the assertion that all of it is
// in the archive.
function checkSelfContained(target, members, read) {
    if (!members.includes("manifest.json")) {
        fail(`dist/${target} has no manifest.json at its root`);
    }

    const manifest = JSON.parse(read("manifest.json").toString("utf8"));
    const referenced = new Set(manifestReferences(manifest));

    for (const member of members) {
        if (!member.endsWith(".html")) continue;
        for (const ref of htmlReferences(
            member,
            read(member).toString("utf8"),
        )) {
            referenced.add(ref);
        }
    }

    const missing = [...referenced].filter((r) => !members.includes(r));
    if (missing.length > 0) {
        fail(
            `the ${target} archive would not be self-contained: it is told ` +
                `to load ${missing.join(", ")}, which ${
                    missing.length === 1 ? "is" : "are"
                } not in it`,
        );
    }
    return { manifest, referenced };
}

function main() {
    const version = resolveVersion(ROOT);

    if (!fs.existsSync(DIST)) {
        fail(
            "there is no dist/ to package. script/package runs make build " +
                "first; run it rather than this program.",
        );
    }

    fs.rmSync(RELEASE, { recursive: true, force: true });
    fs.mkdirSync(RELEASE, { recursive: true });

    // Files the build emits at the dist/ root, outside both browser
    // directories. Printed rather than ignored: dist/styles.css is the
    // Tailwind output that build.js then copies into each browser directory,
    // so leaving it out is correct — but "correct and stated" and "dropped by
    // a glob" are different things, and only one of them survives the next
    // change to the build.
    const rootOnly = fs
        .readdirSync(DIST, { withFileTypes: true })
        .filter((e) => !e.isDirectory())
        .map((e) => e.name)
        .sort();
    if (rootOnly.length > 0) {
        console.log(
            `Not shipped (dist/ root, outside every browser directory, and ` +
                `referenced by nothing inside one): ${rootOnly.join(", ")}`,
        );
    }

    const sums = [];
    for (const { dir, ext } of TARGETS) {
        const targetDir = path.join(DIST, dir);
        if (!fs.existsSync(targetDir)) {
            fail(`dist/${dir} does not exist; run make build`);
        }

        const members = listFiles(targetDir);
        const readFromDir = (member) =>
            fs.readFileSync(path.join(targetDir, member));

        const { manifest } = checkSelfContained(dir, members, readFromDir);
        if (manifest.version !== version) {
            fail(
                `dist/${dir}/manifest.json says version ${manifest.version} ` +
                    `but this tree is ${version}. dist/ is stale: run make ` +
                    `build.`,
            );
        }

        const archive = writeZip(
            members.map((name) => ({ name, data: readFromDir(name) })),
        );
        const name = `autistmask-${dir}-${version}.${ext}`;
        const outPath = path.join(RELEASE, name);
        fs.writeFileSync(outPath, archive);

        // Read the artifact back off disk, not the buffer that was just
        // written: what ships is the file.
        const written = fs.readFileSync(outPath);
        const entries = readZip(written);
        const inArchive = entries.map((e) => e.name).sort();
        if (inArchive.join("\n") !== members.join("\n")) {
            fail(
                `${name} does not hold the same members as dist/${dir}: ` +
                    `archive has ${inArchive.length}, directory has ` +
                    `${members.length}`,
            );
        }
        for (const entry of entries) {
            const onDisk = readFromDir(entry.name);
            if (sha256(entry.data) !== sha256(onDisk)) {
                fail(`${name} member ${entry.name} differs from dist/${dir}`);
            }
        }
        // Re-run the self-containment check against the ARCHIVE's own
        // contents. The directory passing it is not the claim being made.
        const byName = new Map(entries.map((e) => [e.name, e.data]));
        checkSelfContained(dir, inArchive, (m) => byName.get(m));

        const digest = sha256(written);
        sums.push(`${digest}  ${name}`);
        console.log(
            `${name}: ${entries.length} member(s), ${written.length} bytes, ` +
                `sha256 ${digest}`,
        );
    }

    fs.writeFileSync(
        path.join(RELEASE, "SHA256SUMS"),
        sums.map((l) => `${l}\n`).join(""),
    );
    console.log(`Wrote release/ for version ${version}`);
}

// Only when run as a program. The reference-resolving helpers are what decide
// whether an archive is self-contained, so tests/packaging.test.js exercises
// them directly and must be able to require this file without packaging
// anything.
if (require.main === module) {
    try {
        main();
    } catch (err) {
        console.error(`package: ${err && err.message ? err.message : err}`);
        process.exit(1);
    }
}

module.exports = { checkSelfContained, htmlReferences, manifestReferences };
