// build.js's FORBIDDEN_INPUTS assertion — the mechanical guarantee that the
// MV3 background bundle cannot contain src/shared/state.js
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
//
// Why this file exists: `make check` does not run `make build`. CI executes
// the assertion (Dockerfile runs `make build`), but executing is not testing —
// invert its condition, or make the table lookup always come back undefined,
// and every check in this repo stays green while the singleton walks back into
// the worker. Five defects, one destroyed wallet, and the whole argument for
// the scoped loud-read guard rest on this assertion, so it is pinned here.
//
// The subject is build.js's exported helpers plus the table's own
// well-formedness check, driven against SYNTHETIC metafiles in esbuild's
// shape. Nothing here shells out to a build or writes dist/: the assertion's
// job is to read a metafile correctly, and a metafile is data. That the real
// shapes reach it is the build's own business and is measured in the PR that
// introduced it.
//
// Every vacuous pass this guarantee has been found to have is pinned below,
// because each one was a way for `make check`, `make lint` and `make build` to
// be green over a background bundle containing the singleton: a stale key, a
// stale module, an entry that lists no modules, an entry recorded as checked
// before its bundle was in hand, and a background entry point nobody added to
// the table.
//
// Paths are absolute on the way in, because the helpers normalize whatever
// esbuild gave them to repo-relative and this file should not depend on the
// working directory jest was started from.

const path = require("path");

const {
    importChain,
    newForbiddenRecord,
    recordBundledInputs,
    assertNoForbiddenInputs,
    assertForbiddenTableCovered,
} = require("../build");
const {
    BACKGROUND_ENTRY_PREFIX,
    FORBIDDEN_INPUTS,
    assertTableWellFormed,
} = require("../script/lib/forbiddenBundleInputs");

const ROOT = path.resolve(__dirname, "..");
const abs = (p) => path.join(ROOT, p);

const ENTRY = "src/background/index.js";
const OUT = "dist/chrome/src/background/index.js";
const STATE = "src/shared/state.js";
const HOP = "src/shared/chainSwitchFields.js";
const SECOND = "src/background/worker2.js";
const SECOND_OUT = "dist/chrome/src/background/worker2.js";
const POPUP = "src/popup/index.js";
const POPUP_OUT = "dist/chrome/src/popup/index.js";

// The real table's shape: entry point -> modules its bundle may not contain.
const TABLE = { [ENTRY]: [STATE] };

// A metafile as esbuild emits one: `outputs[out].inputs` is the flat list of
// every input that contributed to that output, and `inputs[file].imports` is
// the edge list, which is what the chain walk follows.
function metafile({ outputs = {}, imports = {} } = {}) {
    return {
        outputs: Object.fromEntries(
            Object.entries(outputs).map(([out, inputs]) => [
                abs(out),
                {
                    inputs: Object.fromEntries(
                        inputs.map((input) => [
                            abs(input),
                            { bytesInOutput: 1 },
                        ]),
                    ),
                },
            ]),
        ),
        inputs: Object.fromEntries(
            Object.entries(imports).map(([file, targets]) => [
                abs(file),
                { imports: targets.map((target) => ({ path: abs(target) })) },
            ]),
        ),
    };
}

function check(mf, table = TABLE, record = newForbiddenRecord()) {
    recordBundledInputs(mf, record);
    assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, table);
    return record;
}

describe("assertNoForbiddenInputs()", () => {
    test("a forbidden module in the bundle fails, naming the import chain", () => {
        const mf = metafile({
            outputs: { [OUT]: [ENTRY, HOP, STATE] },
            imports: {
                [ENTRY]: [HOP],
                [HOP]: [STATE],
            },
        });

        expect(() => check(mf)).toThrow(
            `${OUT} bundles ${STATE}, which ${ENTRY} must not reach: ` +
                `${ENTRY} -> ${HOP} -> ${STATE}.`,
        );
    });

    test("a bundle without the forbidden module passes, and is recorded as checked", () => {
        const mf = metafile({
            outputs: { [OUT]: [ENTRY, HOP, "src/background/state.js"] },
            imports: { [ENTRY]: [HOP, "src/background/state.js"] },
        });

        const record = check(mf);
        expect([...record.entriesChecked]).toEqual([ENTRY]);
        expect(record.bundledInputs.has(STATE)).toBe(false);
    });

    test("the failure still names the bundle when no import chain can be shown", () => {
        // esbuild resolves `import("../shared/" + variable)` as a glob: the
        // module is an input of the output, but no single edge leads to it.
        // The message must degrade to no chain rather than crash.
        const mf = metafile({
            outputs: { [OUT]: [ENTRY, STATE] },
            imports: { [ENTRY]: [] },
        });

        expect(() => check(mf)).toThrow(
            `${OUT} bundles ${STATE}, which ${ENTRY} must not reach.`,
        );
    });

    // The lookup this covers is the fragile step in the whole assertion:
    // repoRelative() resolves against process.cwd() and esbuild's output keys
    // are cwd-relative, so a change to where the build runs from, or to
    // outfile vs outdir, makes it miss. It must be loud, and the entry must
    // NOT already be marked checked when it does — otherwise a later edit
    // turning this throw into an early return leaves both halves of the
    // guarantee satisfied by a bundle nothing looked at.
    test("an output esbuild did not report fails, and records nothing as checked", () => {
        const mf = metafile({
            outputs: { "dist/chrome/src/background/renamed.js": [ENTRY] },
            imports: { [ENTRY]: [] },
        });
        const record = newForbiddenRecord();
        recordBundledInputs(mf, record);

        expect(() =>
            assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, TABLE),
        ).toThrow(`esbuild reported no metafile output for ${OUT}`);
        expect([...record.entriesChecked]).toEqual([]);

        // So even if that throw became `return`, the coverage half catches it.
        record.bundledInputs.add(STATE);
        expect(() => assertForbiddenTableCovered(record, TABLE)).toThrow(
            `${ENTRY} is listed in FORBIDDEN_INPUTS but was not bundled`,
        );
    });
});

// Finding from the fifth review of this change: adding a second worker entry
// point is exactly the accident this guarantee exists for, and the person
// adding one has no reason to know a table elsewhere needs a line. So the
// default for the background directory is protected, not unprotected.
describe("background entry points are protected by default", () => {
    test("a bundled background entry point with no line in the table fails", () => {
        const mf = metafile({
            outputs: { [SECOND_OUT]: [SECOND, STATE] },
            imports: { [SECOND]: [STATE] },
        });
        const record = newForbiddenRecord();
        recordBundledInputs(mf, record);

        expect(() =>
            assertNoForbiddenInputs(
                abs(SECOND),
                abs(SECOND_OUT),
                mf,
                record,
                TABLE,
            ),
        ).toThrow(
            `${SECOND} is a background entry point with no line in ` +
                `FORBIDDEN_INPUTS`,
        );
    });

    test("an entry point outside the background directory needs no line", () => {
        // The popup legitimately bundles the singleton; that is its model.
        const mf = metafile({
            outputs: { [POPUP_OUT]: [POPUP, STATE] },
            imports: { [POPUP]: [STATE] },
        });
        const record = newForbiddenRecord();
        recordBundledInputs(mf, record);

        expect(() =>
            assertNoForbiddenInputs(
                abs(POPUP),
                abs(POPUP_OUT),
                mf,
                record,
                TABLE,
            ),
        ).not.toThrow();
        expect([...record.entriesChecked]).toEqual([]);
    });

    test("the prefix is the one the lint rule is scoped to", () => {
        expect(BACKGROUND_ENTRY_PREFIX).toBe("src/background/");
        expect(SECOND.startsWith(BACKGROUND_ENTRY_PREFIX)).toBe(true);
        expect(POPUP.startsWith(BACKGROUND_ENTRY_PREFIX)).toBe(false);
    });
});

describe("recordBundledInputs()", () => {
    // The sole data source for the module half of the anti-rot check, and
    // every other case here hand-seeds what it produces. Driven for real,
    // across two outputs, and then handed straight to the check that reads it.
    test("records every input of every output, satisfying the module half", () => {
        const mf = metafile({
            outputs: {
                [OUT]: [ENTRY, HOP],
                [POPUP_OUT]: [POPUP, STATE],
            },
            imports: { [ENTRY]: [HOP], [POPUP]: [STATE] },
        });
        const record = newForbiddenRecord();
        recordBundledInputs(mf, record);

        expect([...record.bundledInputs].sort()).toEqual(
            [ENTRY, HOP, POPUP, STATE].sort(),
        );

        // Nothing hand-seeded: the covered check passes on what the recorder
        // actually collected, so a recorder that collects nothing fails here.
        assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, TABLE);
        expect(() => assertForbiddenTableCovered(record, TABLE)).not.toThrow();
    });
});

describe("importChain()", () => {
    test("terminates on a cyclic input graph, and still finds the module", () => {
        const mf = metafile({
            imports: {
                [ENTRY]: [HOP],
                [HOP]: ["src/shared/log.js"],
                // The cycle: log <-> hop, with the target one hop past it.
                "src/shared/log.js": [HOP, STATE],
            },
        });

        expect(importChain(mf, abs(ENTRY), STATE)).toEqual([
            ENTRY,
            HOP,
            "src/shared/log.js",
            STATE,
        ]);
    });

    test("terminates and returns null when a cycle cannot reach the module", () => {
        const mf = metafile({
            imports: {
                [ENTRY]: [HOP],
                [HOP]: ["src/shared/log.js"],
                "src/shared/log.js": [HOP, ENTRY],
            },
        });

        expect(importChain(mf, abs(ENTRY), STATE)).toBeNull();
    });
});

describe("assertForbiddenTableCovered()", () => {
    test("the shipped table is satisfied by a build that checked it", () => {
        const record = newForbiddenRecord();
        record.entriesChecked.add(ENTRY);
        // The popup bundle is what legitimately contains the singleton.
        record.bundledInputs.add(STATE);

        expect(() => assertForbiddenTableCovered(record, TABLE)).not.toThrow();
    });

    // The build this drives bundles the popup and no background entry point,
    // because a stale key AND a bundled background entry point is now the
    // stronger failure above — the entry point that is there but unlisted is
    // reported by name, before the end of the build. This case is the rot that
    // is left after that one: a key naming something this build never bundled.
    test("a key no bundled entry point matched fails", () => {
        const mf = metafile({
            outputs: { [POPUP_OUT]: [POPUP] },
            imports: { [POPUP]: [] },
        });
        const stale = { "src/background/renamed.js": [STATE] };
        const record = newForbiddenRecord();
        recordBundledInputs(mf, record);
        assertNoForbiddenInputs(abs(POPUP), abs(POPUP_OUT), mf, record, stale);
        record.bundledInputs.add(STATE);

        expect(() => assertForbiddenTableCovered(record, stale)).toThrow(
            "src/background/renamed.js is listed in FORBIDDEN_INPUTS but was" +
                " not bundled, so nothing checked it",
        );
    });

    test("a forbidden module this build bundled nowhere fails", () => {
        // The other half of the same rot: renaming or moving the singleton
        // leaves a table that names a path nothing resolves to any more, and
        // every bundle then passes it vacuously.
        const mf = metafile({
            outputs: { [OUT]: [ENTRY] },
            imports: { [ENTRY]: [] },
        });
        const stale = { [ENTRY]: ["src/shared/stateRenamed.js"] };
        const record = check(mf, stale);
        record.bundledInputs.add(STATE);

        expect(() => assertForbiddenTableCovered(record, stale)).toThrow(
            "src/shared/stateRenamed.js is listed in FORBIDDEN_INPUTS for" +
                ` ${ENTRY}, but this build bundled it nowhere`,
        );
    });

    // The third rot, and the worst of the three: an entry with an empty list
    // is checked, is bundled, names no module that could be missing, and
    // prohibits nothing — in BOTH layers at once, since the rule's forbidden
    // set is Object.values(table).flat().
    test("an entry that lists no modules fails", () => {
        const mf = metafile({
            outputs: { [OUT]: [ENTRY, STATE] },
            imports: { [ENTRY]: [STATE] },
        });
        const empty = { [ENTRY]: [] };
        const record = newForbiddenRecord();
        recordBundledInputs(mf, record);
        assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, empty);

        expect(() => assertForbiddenTableCovered(record, empty)).toThrow(
            `FORBIDDEN_INPUTS["${ENTRY}"] lists no modules`,
        );
    });
});

describe("assertTableWellFormed()", () => {
    // Runs at require time on the shipped table, in script/lib/
    // forbiddenBundleInputs.js, so an empty list fails the lint run as well as
    // the build — the build's own re-check cannot help a layer that never
    // reaches the build.
    test("the shipped table is well formed", () => {
        expect(() => assertTableWellFormed(FORBIDDEN_INPUTS)).not.toThrow();
        expect(Object.entries(FORBIDDEN_INPUTS).length).toBeGreaterThan(0);
    });

    test("an entry that lists no modules fails, naming the entry", () => {
        expect(() => assertTableWellFormed({ [ENTRY]: [] })).toThrow(
            `FORBIDDEN_INPUTS["${ENTRY}"] lists no modules`,
        );
    });

    test("a table with no entries at all fails", () => {
        expect(() => assertTableWellFormed({})).toThrow(
            "FORBIDDEN_INPUTS is empty",
        );
    });
});
