// Every element id the popup views look up must exist in the markup they
// look it up in.
//
// The failure this catches: `$("settings-hide-dsut")` is valid JavaScript
// referring to a defined function, so neither jest (node environment, no
// DOM) nor a linter has anything to object to. At runtime `$()` returns
// null and the next property access throws, which in `init()` aborts the
// rest of that view's wiring and takes the whole screen down. Settings is
// the densest concentration of these lookups in the codebase.
//
// This is the cheap general half of the guard: it runs in `make check`
// with no browser and covers every id in every view, not the ones some
// test happens to click. The expensive specific half is the Settings
// section of the end-to-end suite (tests/e2e/run.js), which proves the
// screen actually comes up and its controls work.
//
// Scope and limits, stated rather than implied:
//   - Only literal string arguments are resolvable statically. A call
//     like `$(containerId)` is invisible here; those are covered by the
//     e2e run instead.
//   - `document.getElementById()` is checked too, minus the ids listed in
//     RUNTIME_CREATED_IDS, which name nodes the code creates itself and
//     which are legitimately absent from the static markup.

"use strict";

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

const POPUP_DIR = path.join(__dirname, "..", "src", "popup");
const POPUP_HTML_PATH = path.join(POPUP_DIR, "index.html");

// Nodes built at runtime rather than authored in index.html. Each one must
// be created unconditionally by the code before it is ever looked up.
const RUNTIME_CREATED_IDS = new Set([
    // Created by updateDebugBanner() in src/popup/views/helpers.js.
    "debug-banner",
]);

// Every id lookup the popup performs with a literal argument, as
// {id, file, line, source} records.
//
// showView("x") is included because it resolves to the element id
// "view-x": a view name with no matching section is the same defect one
// indirection further out.
const PATTERNS = [
    { re: /\$\(\s*"([^"\n]+)"\s*\)/g, id: (m) => m[1], source: "$()" },
    {
        re: /document\.getElementById\(\s*"([^"\n]+)"\s*\)/g,
        id: (m) => m[1],
        source: "getElementById()",
    },
    {
        re: /\b(?:showError|hideError)\(\s*"([^"\n]+)"/g,
        id: (m) => m[1],
        source: "showError()/hideError()",
    },
    {
        re: /\bshowView\(\s*"([^"\n]+)"\s*\)/g,
        id: (m) => "view-" + m[1],
        source: "showView()",
    },
];

function jsFilesUnder(dir) {
    const out = [];
    for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
        const full = path.join(dir, entry.name);
        if (entry.isDirectory()) {
            out.push(...jsFilesUnder(full));
        } else if (entry.name.endsWith(".js")) {
            out.push(full);
        }
    }
    return out.sort();
}

function lineOf(text, index) {
    return text.slice(0, index).split("\n").length;
}

function collectReferences() {
    const refs = [];
    for (const file of jsFilesUnder(POPUP_DIR)) {
        const text = fs.readFileSync(file, "utf8");
        const rel = path.relative(path.join(__dirname, ".."), file);
        for (const { re, id, source } of PATTERNS) {
            re.lastIndex = 0;
            let m;
            while ((m = re.exec(text)) !== null) {
                refs.push({
                    id: id(m),
                    file: rel,
                    line: lineOf(text, m.index),
                    source,
                });
            }
        }
    }
    return refs;
}

function collectHtmlIds(html) {
    const ids = [];
    const re = /\bid="([^"]+)"/g;
    let m;
    while ((m = re.exec(html)) !== null) ids.push(m[1]);
    return ids;
}

const HTML = fs.readFileSync(POPUP_HTML_PATH, "utf8");
const HTML_IDS = collectHtmlIds(HTML);
const HTML_ID_SET = new Set(HTML_IDS);
const REFERENCES = collectReferences();

describe("every element id the popup looks up exists in its markup", () => {
    // A guard that found nothing to check would pass forever. If a
    // refactor renames the directory, changes the helper, or moves the
    // markup, this fails instead of quietly covering zero call sites.
    // The floors are far below the counts measured when this was written
    // (434 lookups across 20 of the 24 files under src/popup/, against 274
    // ids in the markup), so ordinary churn does not trip them.
    test("the scan actually found the code and the markup", () => {
        const files = new Set(REFERENCES.map((r) => r.file));
        expect(files.size).toBeGreaterThanOrEqual(15);
        expect(REFERENCES.length).toBeGreaterThanOrEqual(300);
        expect(HTML_IDS.length).toBeGreaterThanOrEqual(200);

        // The densest screen, named explicitly: a scan that stopped
        // covering src/popup/views/settings.js is the exact regression
        // this file was written for.
        expect(
            files.has(path.join("src", "popup", "views", "settings.js")),
        ).toBe(true);
        expect(
            REFERENCES.some((r) => r.id === "settings-hide-spoofed-symbols"),
        ).toBe(true);
        expect(REFERENCES.some((r) => r.id === "view-settings")).toBe(true);
    });

    test("no lookup names an id that src/popup/index.html does not define", () => {
        const missing = REFERENCES.filter(
            (r) => !HTML_ID_SET.has(r.id) && !RUNTIME_CREATED_IDS.has(r.id),
        ).map(
            (r) =>
                r.file +
                ":" +
                r.line +
                " " +
                r.source +
                ' looks up id "' +
                r.id +
                '", which is not in src/popup/index.html',
        );

        expect(missing).toEqual([]);
    });

    test("every id excused as runtime-created is still looked up somewhere", () => {
        // Otherwise the exception list becomes a place stale names
        // accumulate, and the next real miss can be waved through by
        // adding one more.
        for (const id of RUNTIME_CREATED_IDS) {
            expect(REFERENCES.some((r) => r.id === id)).toBe(true);
            expect(HTML_ID_SET.has(id)).toBe(false);
        }
    });

    test("index.html defines no id twice", () => {
        // getElementById returns the first match, so a duplicate id means
        // one of the two elements can never be reached by the code that
        // thinks it owns it.
        const seen = new Set();
        const duplicated = [];
        for (const id of HTML_IDS) {
            if (seen.has(id)) duplicated.push(id);
            seen.add(id);
        }

        expect(duplicated).toEqual([]);
    });
});
