// Domain-based phishing detection using a vendored blocklist with delta updates.
//
// A community-maintained phishing domain blocklist is vendored in
// phishingBlocklist.json and bundled at build time. At runtime, we fetch
// the live list periodically and keep only the delta (new entries not in
// the vendored list) in memory. This keeps runtime memory usage small.
//
// The domain-checker checks the in-memory delta first (fresh/recent scam
// sites), then falls back to the vendored list.
//
// If the delta and its fetch timestamp fit in 256 KiB they are persisted to
// extension storage, so they survive termination of the MV3 service worker.
// Extension storage, not localStorage: localStorage does not exist in a
// service worker, so the previous persistence never ran on Chrome at all.
// The stored timestamps are what keep a restarted worker from re-fetching on
// every wake while still noticing an overdue update. Those guards apply to the
// startup path only; the 24-hour alarm tick bypasses them, or it would veto
// its own refresh — see updatePhishingList().

const vendoredConfig = require("./phishingBlocklist.json");

const BLOCKLIST_URL =
    "https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";

const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours

// Floor on how often an unscheduled path may hit the network. The worker is
// revived every ~30 seconds while the browser is busy, and every revival runs
// the startup path; without a persisted record of the last attempt, any state
// that leaves lastFetchTime unset — a fetch that failed, or a delta too large
// to store — would download the full list on every single wake.
const MIN_FETCH_ATTEMPT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour

const DELTA_STORAGE_KEY = "phishing-delta";
const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB

// Vendored set — built once from the bundled JSON.
const vendoredBlacklist = new Set(
    (vendoredConfig.blacklist || []).map((d) => d.toLowerCase()),
);

// Delta set — only entries from live list that are NOT in vendored.
let deltaBlacklist = new Set();
let lastFetchTime = 0;
let lastAttemptTime = 0;
let fetchPromise = null;
let loadPromise = null;

// Resolved on use rather than captured at module load, so a test can install
// a stub after requiring the module and so the popup — which has no reason to
// touch the delta — does not fail to load where the API is absent.
function storageApi() {
    if (typeof browser !== "undefined" && browser.storage) {
        return browser.storage.local;
    }
    if (typeof chrome !== "undefined" && chrome.storage) {
        return chrome.storage.local;
    }
    return null;
}

/**
 * Sanitise a timestamp read back from storage.
 *
 * A value in the future is permanent poison: every guard here measures elapsed
 * time as `Date.now() - stamp` and tests only the lower bound, so a stamp a
 * year ahead suppresses updates for a year with no path that ever clears it.
 * Clock skew and a restored profile backup both produce one. Since these
 * timestamps only ever gate work, discarding an impossible one is safe: it
 * costs at most a single extra fetch and restores a sane value immediately.
 *
 * @param {unknown} value
 * @returns {number} the timestamp, or 0 if it is unusable.
 */
function sanitizeTimestamp(value) {
    if (typeof value !== "number" || !Number.isFinite(value)) return 0;
    if (value <= 0 || value > Date.now()) return 0;
    return value;
}

/**
 * Load the persisted delta and its timestamps from extension storage.
 * Runs once per worker lifetime; every entry point funnels through
 * ensureDeltaLoaded() so a wake from termination restores state exactly once.
 *
 * @returns {Promise<void>}
 */
async function loadDeltaFromStorage() {
    const storage = storageApi();
    if (!storage) return;
    try {
        const result = await storage.get(DELTA_STORAGE_KEY);
        const data = result && result[DELTA_STORAGE_KEY];
        if (!data) return;
        if (Array.isArray(data.blacklist)) {
            deltaBlacklist = new Set(
                data.blacklist.map((d) => d.toLowerCase()),
            );
        }
        lastFetchTime = sanitizeTimestamp(data.lastFetchTime);
        lastAttemptTime = sanitizeTimestamp(data.lastAttemptTime);
    } catch {
        // Storage unavailable or corrupt — start empty and re-fetch.
    }
}

function ensureDeltaLoaded() {
    if (!loadPromise) loadPromise = loadDeltaFromStorage();
    return loadPromise;
}

/**
 * Persist the delta and its timestamps if they fit within MAX_DELTA_BYTES.
 *
 * The 256 KiB cap covers the delta and its freshness claim: when the delta is
 * too large to keep, lastFetchTime goes with it, so the next start re-fetches
 * rather than trusting a freshness claim for a delta it no longer holds.
 * lastAttemptTime is written either way — it records that the network was
 * contacted, which stays true whatever became of the response, and it is what
 * stops a permanently oversized list from downloading on every worker wake.
 *
 * @returns {Promise<void>}
 */
async function saveDeltaToStorage() {
    const storage = storageApi();
    if (!storage) return;
    try {
        const data = {
            blacklist: Array.from(deltaBlacklist),
            lastFetchTime,
            lastAttemptTime,
        };
        const json = JSON.stringify(data);
        if (json.length < MAX_DELTA_BYTES) {
            await storage.set({ [DELTA_STORAGE_KEY]: data });
        } else if (lastAttemptTime > 0) {
            await storage.set({ [DELTA_STORAGE_KEY]: { lastAttemptTime } });
        } else {
            await storage.remove(DELTA_STORAGE_KEY);
        }
    } catch {
        // Storage unavailable — skip silently
    }
}

/**
 * Load a pre-parsed config and compute the delta against the vendored list.
 * Used for both live fetches and testing.
 *
 * @param {{ blacklist?: string[] }} config
 * @returns {Promise<void>} resolves once the delta has been persisted.
 */
function loadConfig(config) {
    const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase());

    // Delta = entries in the live list that are NOT in the vendored list
    deltaBlacklist = new Set(
        liveBlacklist.filter((d) => !vendoredBlacklist.has(d)),
    );

    lastFetchTime = Date.now();
    return saveDeltaToStorage();
}

/**
 * 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.
 * Checks delta first (fresh/recent scam sites), then vendored list.
 *
 * Synchronous by design — callers answer an approval prompt with it. On a
 * worker that has just woken, the persisted delta may still be loading; the
 * vendored list, which is bundled and always present, carries the check until
 * it lands.
 *
 * @param {string} hostname - The hostname to check.
 * @returns {boolean}
 */
function isPhishingDomain(hostname) {
    if (!hostname) return false;
    const variants = hostnameVariants(hostname);

    // Check delta blacklist first (fresh/recent scam sites), then vendored
    for (const v of variants) {
        if (deltaBlacklist.has(v) || vendoredBlacklist.has(v)) return true;
    }
    return false;
}

/**
 * Fetch the latest blocklist and compute delta against vendored data.
 * De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS,
 * counted from the persisted timestamp so the cache outlives the worker.
 *
 * `force` is what makes the 24-hour alarm actually refresh every 24 hours.
 * The alarm fires one period after the previous alarm, but lastFetchTime is
 * stamped when that fetch *completed*, so an unforced tick lands one fetch
 * latency inside its own TTL, skips, and turns the real cadence into 48 hours.
 * Shortening the TTL instead would not fix it: the worker wakes every ~30
 * seconds and the startup path re-checks the TTL each time, so a shortened TTL
 * simply becomes the real cadence. The TTL is there to stop redundant fetches
 * on wake, and the scheduled tick is not redundant, so it bypasses it.
 *
 * @param {{force?: boolean}} [opts] force: fetch unless one is already in
 * flight, ignoring both the freshness and the retry guard. For the scheduled
 * alarm tick only.
 * @returns {Promise<void>}
 */
async function updatePhishingList({ force = false } = {}) {
    // A worker that has just been revived knows nothing until the persisted
    // record is back in memory; without this the freshness check below would
    // always see 0 and re-fetch on every wake.
    await ensureDeltaLoaded();

    if (!force) {
        const now = Date.now();
        // Skip if recently fetched.
        if (lastFetchTime > 0 && now - lastFetchTime < CACHE_TTL_MS) return;
        // Skip if the network was contacted recently and the result was not
        // usable — a failed fetch or an oversized delta leaves lastFetchTime
        // unset, and without this every wake would retry.
        if (
            lastAttemptTime > 0 &&
            now - lastAttemptTime < MIN_FETCH_ATTEMPT_INTERVAL_MS
        ) {
            return;
        }
    }

    // De-duplicate concurrent calls
    if (fetchPromise) return fetchPromise;

    fetchPromise = (async () => {
        lastAttemptTime = Date.now();
        try {
            const resp = await fetch(BLOCKLIST_URL);
            if (!resp.ok) throw new Error("HTTP " + resp.status);
            const config = await resp.json();
            await loadConfig(config);
        } catch {
            // Silently fail — vendored list still provides coverage. Persist
            // the attempt so a persistently failing fetch is retried on the
            // schedule rather than on every wake.
            await saveDeltaToStorage();
        } finally {
            fetchPromise = null;
        }
    })();

    return fetchPromise;
}

/**
 * Restore persisted state and fetch if the list is overdue.
 *
 * Called from the background script every time it starts — a fresh install,
 * a browser start, and every revival of a terminated service worker all land
 * here. The recurring 24-hour schedule itself is an alarm (see
 * shared/alarms.js), not a timer, because timers die with the worker.
 *
 * @returns {Promise<void>}
 */
async function initPhishingList() {
    await ensureDeltaLoaded();
    return updatePhishingList();
}

/**
 * The 24-hour alarm tick. Separate from initPhishingList() because this is the
 * scheduled refresh and must not be vetoed by the guards that exist to keep
 * the unscheduled startup path off the network.
 *
 * @returns {Promise<void>}
 */
async function refreshPhishingListOnSchedule() {
    return updatePhishingList({ force: true });
}

/**
 * Return the total blocklist size (vendored + delta) for diagnostics.
 *
 * @returns {number}
 */
function getBlocklistSize() {
    return vendoredBlacklist.size + deltaBlacklist.size;
}

/**
 * Return the delta blocklist size for diagnostics.
 *
 * @returns {number}
 */
function getDeltaSize() {
    return deltaBlacklist.size;
}

/**
 * Reset internal state (for testing).
 */
function _reset() {
    deltaBlacklist = new Set();
    lastFetchTime = 0;
    lastAttemptTime = 0;
    fetchPromise = null;
    loadPromise = null;
}

module.exports = {
    isPhishingDomain,
    updatePhishingList,
    refreshPhishingListOnSchedule,
    initPhishingList,
    loadDeltaFromStorage,
    loadConfig,
    CACHE_TTL_MS,
    MIN_FETCH_ATTEMPT_INTERVAL_MS,
    DELTA_STORAGE_KEY,
    MAX_DELTA_BYTES,
    getBlocklistSize,
    getDeltaSize,
    hostnameVariants,
    _reset,
    // Exposed for testing only
    _getVendoredBlacklistSize: () => vendoredBlacklist.size,
    _getDeltaBlacklist: () => deltaBlacklist,
};
