// Domain-based phishing detection against a blocklist vendored at build time.
//
// The list is produced by script/vendor-blocklist from a hash-pinned upstream
// commit, committed as phishingBlocklist.json, and bundled. There is no runtime
// fetch: the extension asks nobody anything to answer this question, so no third
// party learns which sites a user connects to, and no third party decides what
// this wallet warns about. The cost is staleness — the shipped list is exactly
// as fresh as the last vendoring run that was released — and the refresh path is
// re-running that script and shipping the diff.
//
// The artifact holds digests, not domains: sha256 truncated to 64 bits, one
// entry per 16 hex characters, concatenated in sorted order into a single
// string (see domainHash.js). Three things follow from that shape, and all
// three are the reason for it:
//
//   - the extension ships no plaintext list of anyone's domain names, which is
//     what makes a blocklist assembled elsewhere shippable here at all.
//   - a lookup is a binary search over that string. Nothing is built at module
//     load, which matters because the MV3 service worker is torn down when idle
//     and re-evaluates this file on every wake.
//   - the file is 1.7 MB rather than 8.7 MB.
//
// Nothing here is async: callers answer an approval prompt with the result.

const vendored = require("./phishingBlocklist.json");
const { HASH_ALGORITHM, HASH_HEX_CHARS, hashDomain } = require("./domainHash");

// The artifact is generated, so a shape it does not have is a build fault, not
// a runtime condition. It is checked anyway, and loudly, because every way of
// getting it wrong — a stale format, a truncated file, a different digest —
// produces a blocklist that matches nothing at all while looking perfectly
// healthy. A phishing check that silently answers "no" to everything is the one
// failure this module must not have.
function checkArtifact(a) {
    const bad = (why) =>
        new Error(
            "phishingBlocklist.json " +
                why +
                ". It is generated by script/vendor-blocklist; re-run that " +
                "rather than editing it.",
        );

    if (!a || typeof a !== "object") throw bad("is not an object");
    if (a.algorithm !== HASH_ALGORITHM) {
        throw bad(
            "declares algorithm " +
                JSON.stringify(a.algorithm) +
                ", but this build hashes with " +
                HASH_ALGORITHM,
        );
    }
    if (a.hashHexChars !== HASH_HEX_CHARS) {
        throw bad(
            "declares " +
                JSON.stringify(a.hashHexChars) +
                " hex characters per entry, but this build produces " +
                HASH_HEX_CHARS,
        );
    }
    if (typeof a.hashes !== "string") throw bad("has no hashes string");
    if (!Number.isInteger(a.count) || a.count < 1) {
        throw bad("declares no usable entry count");
    }
    if (a.hashes.length !== a.count * HASH_HEX_CHARS) {
        throw bad(
            "holds " +
                a.hashes.length +
                " hex characters, which is not the " +
                a.count * HASH_HEX_CHARS +
                " its count of " +
                a.count +
                " entries requires",
        );
    }
}

checkArtifact(vendored);

const HASHES = vendored.hashes;
const COUNT = vendored.count;

/**
 * Is this digest one of the vendored entries?
 *
 * Binary search over fixed-width records. The digests are lowercase hex of one
 * width, so lexicographic order is numeric order and the artifact is written
 * sorted; tests assert that ordering against the committed file, because an
 * unsorted artifact would fail lookups silently rather than loudly.
 *
 * @param {string} hash
 * @returns {boolean}
 */
function hashListed(hash) {
    let lo = 0;
    let hi = COUNT - 1;
    while (lo <= hi) {
        const mid = (lo + hi) >> 1;
        const at = HASHES.slice(
            mid * HASH_HEX_CHARS,
            (mid + 1) * HASH_HEX_CHARS,
        );
        if (at === hash) return true;
        if (at < hash) lo = mid + 1;
        else hi = mid - 1;
    }
    return false;
}

/**
 * Generate hostname variants for subdomain matching.
 * "sub.evil.com" yields ["sub.evil.com", "evil.com"].
 *
 * @param {string} hostname
 * @returns {string[]}
 */
function hostnameVariants(hostname) {
    const h = hostname.toLowerCase();
    const variants = [h];
    const parts = h.split(".");
    // Parent domains: a.b.c.d -> b.c.d, c.d
    for (let i = 1; i < parts.length - 1; i++) {
        variants.push(parts.slice(i).join("."));
    }
    return variants;
}

/**
 * Check if a hostname is on the phishing blocklist.
 *
 * @param {string} hostname - The hostname to check.
 * @returns {boolean}
 */
function isPhishingDomain(hostname) {
    if (!hostname) return false;
    for (const variant of hostnameVariants(hostname)) {
        if (hashListed(hashDomain(variant))) return true;
    }
    return false;
}

/**
 * Return the blocklist size for diagnostics.
 *
 * @returns {number}
 */
function getBlocklistSize() {
    return COUNT;
}

module.exports = {
    isPhishingDomain,
    getBlocklistSize,
    hostnameVariants,
    // Exposed for testing only: the ends of the search range are where an
    // off-by-one hides, and reaching them through isPhishingDomain() would mean
    // knowing which domain hashes to the first or last entry.
    _hashListed: hashListed,
};
