// ESLint rule: the background bundle may not reach the shared state singleton.
//
// src/shared/state.js holds a module-level `state` object, loaded once by
// loadState() and mutated in place from then on. That is the popup's model. In
// the MV3 service worker there is no "once": the worker is terminated when
// idle and revived by the next message, nothing loads state at module scope,
// and an unpopulated read used to be served DEFAULT_STATE without complaint —
// five defects, one cause
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The background has its
// own per-call storage layer in src/background/state.js instead.
//
// THIS RULE IS NOT THE GUARANTEE, and must not be described as one. The
// guarantee is in build.js: FORBIDDEN_INPUTS / assertNoForbiddenInputs() fails
// the build when esbuild's own metafile reports src/shared/state.js as an input
// of a background bundle. That consults the resolution esbuild actually
// performed, so no specifier syntax and no resolution rule can slip past it,
// and Dockerfile:42 runs `make build` in CI.
//
// What this rule is: fast local feedback, in the editor and in `make lint`,
// before a full bundle. It reads sources from disk and matches import
// specifiers TEXTUALLY, so it is a best-effort approximation of module
// resolution — a hand-rolled matcher will diverge from a real bundler, and two
// earlier revisions of this file proved it by shipping holes (a template
// literal, a dynamic `import()`, a comment inside the call, a directory
// resolved through `package.json` `main`). Those are all covered now, and the
// next divergence is caught by the build rather than by widening this again.
//
// It checks REACHABILITY, not just the direct require: the singleton is one
// `require()` away from any shared module the background pulls in, and a
// re-export would put it back in the bundle without any background file naming
// it. So each background file is the root of a walk over the CommonJS require
// graph, and the error names the whole chain that brought the singleton in.
//
// Matching textually over-approximates — a specifier inside a comment or a
// string counts — which is the safe direction here: the failure mode is a
// spurious error naming an exact file and line, not a silent hole.
//
// Two shapes this rule does NOT report, both of which the build does fail on
// (each measured with `make lint` and `make build` on the branch that added
// this note):
//
//   - a computed specifier, `require("../shared/" + "state")` — esbuild
//     constant-folds it, so it is in the bundle and `make build` is exit 2
//     naming src/shared/state.js, while `make lint` is exit 0. Same for
//     `import("../shared/" + variable)`, which esbuild resolves as a glob.
//   - a symlink to the module — esbuild reports the real path and fails the
//     build; this rule resolves the link's own path and sees a different file.
//
// Both are pinned as non-reports in tests/backgroundStateLintRule.test.js, so
// this list is a measured description of the rule rather than a claim about
// it. They are known divergences, not things that cannot happen. A
// matcher will keep diverging from a bundler; that is why the guarantee is the
// build's and this rule is not widened again to chase them.

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

const { FORBIDDEN_INPUTS } = require("../forbiddenBundleInputs");

// The modules to keep out, repo-relative, taken from the same table build.js
// asserts against so that the two layers cannot name different paths. A second
// literal copy here is how a rename disarms one of them while the other still
// looks enforced.
const FORBIDDEN = [...new Set(Object.values(FORBIDDEN_INPUTS).flat())];

// Whatever may sit between a keyword, a paren and a specifier: whitespace and
// comments. `import(/* webpackChunkName: "x" */ "./x")` is a standard bundler
// idiom, and an inline `/* eslint-… */` is just as ordinary, so a matcher that
// allows only \s there is not strict, it is broken. Each alternative starts
// with a distinct character, so this cannot backtrack quadratically.
const GAP = "(?:\\s|/\\*[^]*?\\*/|//[^\\n]*)";
const SPECIFIER = "[\"'`]([^\"'`]+)[\"'`]";

// Both alternatives capture the specifier: call form first
// (`require(...)`/`import(...)`), then clause form (`from "x"`, and the bare
// side-effect `import "x"`). Nothing after the specifier is matched, so a
// trailing comment or a trailing comma cannot break the match either.
const SPECIFIER_RE = new RegExp(
    `\\b(?:require|import)${GAP}*\\(${GAP}*${SPECIFIER}` +
        `|\\b(?:from|import)${GAP}+${SPECIFIER}`,
    "g",
);

// The `main` of a directory's package.json, as a specifier relative to that
// directory, or null. esbuild resolves a directory through it, so a walk that
// stops at `<dir>/index.js` reports a specifier it matched perfectly well as
// unresolvable.
function packageMain(dir) {
    try {
        const pkg = JSON.parse(
            fs.readFileSync(path.join(dir, "package.json"), "utf8"),
        );
        return typeof pkg.main === "string" && pkg.main ? pkg.main : null;
    } catch {
        return null;
    }
}

// Resolve a relative require to a file path, trying what node and esbuild would
// in the order they would: the path itself, then extensions, then the directory
// (its package.json `main`, then its index.js).
function resolveRelative(fromFile, spec) {
    if (!spec.startsWith(".")) return null; // a package, not our tree
    const base = path.resolve(path.dirname(fromFile), spec);
    const main = packageMain(base);
    for (const candidate of [
        base,
        base + ".js",
        base + ".json",
        ...(main
            ? [path.resolve(base, main), path.resolve(base, main) + ".js"]
            : []),
        path.join(base, "index.js"),
    ]) {
        try {
            if (fs.statSync(candidate).isFile()) return candidate;
        } catch {
            // Not this candidate.
        }
    }
    return null;
}

function requiresOf(file) {
    let source;
    try {
        source = fs.readFileSync(file, "utf8");
    } catch {
        return [];
    }
    const out = [];
    for (const match of source.matchAll(SPECIFIER_RE)) {
        const resolved = resolveRelative(file, match[1] ?? match[2]);
        if (resolved) out.push(resolved);
    }
    return out;
}

// Breadth-first from `entry`, returning the shortest chain of files that ends
// at one of the forbidden modules, or null when none is reachable.
function chainToForbidden(entry, forbidden) {
    const seen = new Set([entry]);
    const queue = [[entry]];
    while (queue.length > 0) {
        const chain = queue.shift();
        for (const next of requiresOf(chain[chain.length - 1])) {
            if (forbidden.has(next)) return chain.concat([next]);
            if (seen.has(next)) continue;
            seen.add(next);
            queue.push(chain.concat([next]));
        }
    }
    return null;
}

const rule = {
    meta: {
        type: "problem",
        docs: {
            description:
                "the background bundle must not be able to reach the" +
                " module-level state singleton in src/shared/state.js",
        },
        schema: [],
        messages: {
            reachable:
                "The background must not reach the shared state singleton:" +
                " {{chain}}. The MV3 worker never populates it, so reading it" +
                " serves DEFAULT_STATE. Use getState()/updateState() from" +
                " src/background/state.js instead.",
        },
    },

    create(context) {
        return {
            "Program:exit"(node) {
                const filename = context.filename;
                // ESLint lints from the repo root, which is also where the
                // forbidden paths are anchored.
                const forbidden = new Set(
                    FORBIDDEN.map((module) =>
                        path.resolve(context.cwd, module),
                    ),
                );
                const chain = chainToForbidden(
                    path.resolve(filename),
                    forbidden,
                );
                if (!chain) return;
                context.report({
                    node,
                    messageId: "reachable",
                    data: {
                        chain: chain
                            .map((file) => path.relative(context.cwd, file))
                            .join(" -> "),
                    },
                });
            },
        };
    },
};

module.exports = {
    rules: { "no-state-singleton-in-background": rule },
};
