// The lint rule that keeps src/shared/state.js out of the background bundle
// (script/lib/eslint/noStateSingletonInBackground.js).
//
// Five defects, one of which destroyed a wallet, came from background code
// reaching that singleton, and each point fix created the next site
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
//
// What this file does NOT do is establish that the singleton cannot reach the
// background bundle. That is build.js's FORBIDDEN_INPUTS assertion, which reads
// esbuild's metafile and so cannot be evaded by a syntax a matcher does not
// know; it is pinned by tests/buildForbiddenInputs.test.js. The rule under test
// here is fast local feedback in front of that, and these cases pin the shapes
// it is known to catch, so a regression in the matcher is a failing test rather
// than a quietly narrower rule.
//
// Every shape below was measured against a real `make build`: each one puts
// state.js in the shipped background bundles, and each one was invisible to
// some earlier revision of the matcher — the quoted-only regex missed the
// backtick, the dynamic import and the `from` clause; its successor missed a
// comment inside the call and a directory resolved through package.json `main`.
//
// Two shapes the rule does NOT report are pinned below as non-reports, in
// "the divergences from the build's answer": a computed specifier such as
// `require("../shared/" + "state")`, which esbuild constant-folds, and a
// symlink to the module, whose real path esbuild reports. Both put state.js in
// the shipped background bundle and both are `make build` exit 2 with
// `make lint` exit 0 (measured). Pinning them as non-reports is what makes the
// rule's stated bounds a measured description rather than a claim: if either
// starts being reported, or the matcher is widened until one is, a test says
// so. Their catch is the build's, and is pinned in
// tests/buildForbiddenInputs.test.js against the metafile that catches it.

const fs = require("fs");
const os = require("os");
const path = require("path");
const { Linter } = require("eslint");

const plugin = require("../script/lib/eslint/noStateSingletonInBackground");

const RULE = "background/no-state-singleton-in-background";

// The three files a fixture tree always has. `src/background/index.js` is
// supplied per case; the other two stand in for the real modules.
const SHARED_STATE = "const state = {};\nmodule.exports = { state };\n";
const SHARED_HOP =
    "// A shared module the background legitimately imports.\n" +
    "module.exports = { applyChainSwitchFields() {} };\n";

let roots = [];

function fixture(files) {
    const root = fs.realpathSync(
        fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-state-rule-")),
    );
    roots.push(root);
    const tree = {
        "src/shared/state.js": SHARED_STATE,
        "src/shared/chainSwitchFields.js": SHARED_HOP,
        ...files,
    };
    for (const [rel, source] of Object.entries(tree)) {
        const abs = path.join(root, rel);
        fs.mkdirSync(path.dirname(abs), { recursive: true });
        fs.writeFileSync(abs, source);
    }
    return root;
}

// Run the rule exactly as eslint.config.js runs it, over a real tree: the walk
// reads its sources from disk, so a virtual RuleTester would not exercise it.
// `sourceType` is the fixture's own, not the rule's business: the walk is
// textual and never parses the files it follows. The two ESM cases below pass
// "module" only so espree can parse the fixture at all — in this repo those
// shapes are also a parse error under the commonjs config, but the rule must
// not be left depending on that.
function lintBackground(root, { sourceType = "commonjs" } = {}) {
    const file = path.join(root, "src/background/index.js");
    const linter = new Linter({ cwd: root });
    return linter.verify(
        fs.readFileSync(file, "utf8"),
        {
            plugins: { background: plugin },
            languageOptions: { ecmaVersion: 2024, sourceType },
            rules: { [RULE]: "error" },
        },
        file,
    );
}

function chainOf(messages) {
    expect(messages).toHaveLength(1);
    expect(messages[0].ruleId).toBe(RULE);
    // "...singleton: <chain>. The MV3 worker..." — the chain is what the
    // message exists to hand the reader, so assert on it rather than on the
    // fact that something was reported.
    return messages[0].message.split("singleton: ")[1].split(". The MV3")[0];
}

afterEach(() => {
    for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
    roots = [];
});

describe("the specifier syntaxes the matcher is known to catch", () => {
    test("a quoted require", () => {
        const root = fixture({
            "src/background/index.js":
                'const { state } = require("../shared/state");\n' +
                "module.exports = { state };\n",
        });
        expect(chainOf(lintBackground(root))).toBe(
            "src/background/index.js -> src/shared/state.js",
        );
    });

    test("a backtick require", () => {
        const root = fixture({
            "src/background/index.js":
                "const { state } = require(`../shared/state`);\n" +
                "module.exports = { state };\n",
        });
        expect(chainOf(lintBackground(root))).toBe(
            "src/background/index.js -> src/shared/state.js",
        );
    });

    test("a dynamic import inside an async function", () => {
        const root = fixture({
            "src/background/index.js":
                "async function readState() {\n" +
                '    const m = await import("../shared/state");\n' +
                "    return m.state;\n" +
                "}\n" +
                "module.exports = { readState };\n",
        });
        expect(chainOf(lintBackground(root))).toBe(
            "src/background/index.js -> src/shared/state.js",
        );
    });

    test("a static import from-clause", () => {
        const root = fixture({
            "src/background/index.js":
                'import { state } from "../shared/state";\n' +
                "export { state };\n",
        });
        expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe(
            "src/background/index.js -> src/shared/state.js",
        );
    });

    // `import(/* webpackChunkName: "x" */ "./x")` is the standard bundler
    // annotation idiom, and prettier leaves both of these exactly as written,
    // so nothing else in the repo would object to them either.
    test("a comment between the paren and the specifier", () => {
        const root = fixture({
            "src/background/index.js":
                'globalThis.__probe = require(/* probe */ "../shared/state").state;\n',
        });
        expect(chainOf(lintBackground(root))).toBe(
            "src/background/index.js -> src/shared/state.js",
        );
    });

    test("a comment between the specifier and the closing paren", () => {
        const root = fixture({
            "src/background/index.js":
                'globalThis.__probe = require("../shared/state" /* probe */).state;\n',
        });
        expect(chainOf(lintBackground(root))).toBe(
            "src/background/index.js -> src/shared/state.js",
        );
    });

    test("a bare side-effect import", () => {
        const root = fixture({
            "src/background/index.js": 'import "../shared/state";\n',
        });
        expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe(
            "src/background/index.js -> src/shared/state.js",
        );
    });
});

describe("reachability, not just the direct specifier", () => {
    // The shape a no-restricted-imports could never see: no background file
    // names state.js, and the singleton is in the bundle anyway. In a backtick
    // require, so this fails on the specifier widening as well as on the walk.
    test("a two-hop re-export through a shared module", () => {
        const root = fixture({
            "src/background/index.js":
                'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
                "module.exports = { applyChainSwitchFields };\n",
            "src/shared/chainSwitchFields.js":
                SHARED_HOP +
                "module.exports.state = require(`./state`).state;\n",
        });
        expect(chainOf(lintBackground(root))).toBe(
            "src/background/index.js -> src/shared/chainSwitchFields.js" +
                " -> src/shared/state.js",
        );
    });

    // Resolution, not syntax: the specifier names a directory, and the file it
    // resolves to is chosen by that directory's package.json `main`. A walk
    // that only tries `<dir>/index.js` stops on a specifier it matched.
    test("a directory resolved through its package.json main", () => {
        const root = fixture({
            "src/background/index.js":
                'globalThis.__probe = require("../shared/probepkg").state;\n',
            "src/shared/probepkg/package.json": '{"main": "./bridge.js"}\n',
            "src/shared/probepkg/bridge.js":
                'const { state } = require("../state");\n' +
                "module.exports = { state };\n",
        });
        expect(chainOf(lintBackground(root))).toBe(
            "src/background/index.js -> src/shared/probepkg/bridge.js" +
                " -> src/shared/state.js",
        );
    });
});

// These two are holes in the rule, and they are pinned as holes on purpose:
// the build catches both, the rule is fast feedback in front of it, and a
// written-down bound that nothing measures is how the previous three rounds of
// this change ended up with claims that were false.
describe("the divergences from the build's answer", () => {
    test("a computed specifier is not reported (esbuild folds it; the build fails)", () => {
        const root = fixture({
            "src/background/index.js":
                'globalThis.__probe = require("../shared/" + "state").state;\n',
        });
        expect(lintBackground(root)).toEqual([]);
    });

    test("a symlink to the module is not reported (esbuild reports the real path)", () => {
        const root = fixture({
            "src/background/index.js":
                'const { state } = require("../shared/stateLink");\n' +
                "module.exports = { state };\n",
        });
        fs.symlinkSync(
            path.join(root, "src/shared/state.js"),
            path.join(root, "src/shared/stateLink.js"),
        );
        expect(lintBackground(root)).toEqual([]);
    });
});

describe("what the rule must not report", () => {
    test("a background file that reaches only its own state layer", () => {
        const root = fixture({
            "src/background/index.js":
                'const { getState } = require("./state");\n' +
                'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
                "module.exports = { getState, applyChainSwitchFields };\n",
            "src/background/state.js":
                "async function getState() {}\nmodule.exports = { getState };\n",
        });
        expect(lintBackground(root)).toEqual([]);
    });

    // The tree as it actually stands. This is the assertion that would catch a
    // widened matcher that resolves something it should not: it runs the rule
    // over the real background entrypoint, from the real repo root.
    test("the repository's own background entrypoint", () => {
        const root = path.resolve(__dirname, "..");
        expect(lintBackground(root)).toEqual([]);
    });
});
