v3 · 37 files

ProjectElly/background/background.js 84.9 KB Raw
import { isSearchEngineResultsPage } from '../lib/searchEngineHelpers.js';
import { DEFAULT_BLOCKED_SITES } from '../lib/defaultBlocklist.js';
import { tryPostQuietStatsReport, ELLY_HOST_TALLY_KEY, MAX_HOST_KEYS } from '../lib/ellyQuietStats.js';
import { isUrlInList as urlListMatches } from '../lib/ellyUrlMatch.js';
import {
    ACCESS_PIN_CONFIGURED_KEY,
    ACCESS_PIN_HASH_STORAGE_KEY,
    ACCESS_PIN_STORAGE_KEY,
    LEGACY_DEFAULT_PARENT_PIN,
    hashAccessPin,
    hashAccessPinLegacySha256,
    hashParentPin,
    isAcceptableNewAccessPin,
    isAcceptableNewParentPin,
    isAccessPinEffectivelyConfigured,
    isForbiddenAccessPin,
    isForbiddenParentPin,
    isLegacySha256PinHash,
    isPbkdf2PinHash,
    isValidAccessPin,
    isValidParentPin,
    normalizePinDigits,
    secureStringEqual,
    stripSecretsFromStorageSnapshot,
    importContainsPlaintextParentPin
} from '../lib/ellyAccessPin.js';

// PIN Protection System
let isPINVerified = false;
const PIN_STORAGE_KEY = ACCESS_PIN_STORAGE_KEY;
const PIN_HASH_KEY = ACCESS_PIN_HASH_STORAGE_KEY;
const APP_SHORTNAME = 'Elly';
let pinIsSet = false;

/** Access session idle timeout (15 minutes). */
const ACCESS_SESSION_IDLE_MS = 15 * 60 * 1000;
/** @type {number} */
let lastAccessActivityMs = 0;

/**
 * Short-lived Parent mutation auth — SW memory only (never trust storage.local).
 * Cleared plantable key `ellyBlocklistMutateUntil` on boot. Max 5 minutes.
 */
const BLOCKLIST_AUTH_KEY = 'ellyBlocklistMutateUntil';
const BLOCKLIST_AUTH_MS = 5 * 60 * 1000;
/** @type {number} */
let blocklistMutateUntilMs = 0;

/** Access PIN verify rate limit: 5 failures / 5 minutes. */
const ACCESS_PIN_FAIL_WINDOW_MS = 5 * 60 * 1000;
const ACCESS_PIN_FAIL_MAX = 5;
/** @type {number[]} */
let accessPinFailTimestamps = [];

/** Prevent re-entry while reverting unauthorized blocklist shrinks. */
let blocklistRevertInFlight = false;

/** Set true only around intentional Access PIN / configured-flag writes from this SW. */
let accessPinWriteAuthorized = false;
/** Set true only around intentional Parent PIN hash writes from this SW. */
let parentPinWriteAuthorized = false;
/** Set true only around intentional security toggle writes (extensionEnabled / strict / pinLock OFF). */
let securityToggleWriteAuthorized = false;
/** One-shot: focus session restore may shrink blockedSites without Parent mutation auth. */
let focusRestoreAuthorized = false;
/** Optional SW write-auth for schedules / limitedSites (import paths may use mutation auth instead). */
let schedulesWriteAuthorized = false;
let limitedSitesWriteAuthorized = false;

/** Last known good Access PIN hash / configured flag (for unauthorized-change revert). */
let lastKnownAccessPinHash = '';
let lastKnownAccessPinConfigured = false;
/** Last known Parent override PIN hash (memory; for unauthorized-change revert). Never plaintext. */
let lastKnownOverridePinHash = '';

/** One-time seed: block extension-management surfaces after first Access/Parent PIN setup. */
const EXT_MGMT_BLOCKLIST_SEEDED_KEY = 'ellyExtMgmtBlocklistSeededV1';
/** Patterns added once to blockedSites (exact entries; matching uses includes / wildcard regex). */
const EXT_MGMT_BLOCK_PATTERNS = [
    'chrome://extensions',
    'opera://extensions',
    'addons.opera.com',
    '*.addons.opera.com'
];

/**
 * Authorized parent overrides live only in SW memory (never trust storage.local alone).
 * @type {Map<string, number>} hostKey -> expiry ms
 */
const parentOverrideHosts = new Map();
/**
 * @type {Map<string, number>} tabId string -> expiry ms
 */
const parentOverrideTabs = new Map();

/** Allowlisted sync keys for locked-down updateSettings. */
const UPDATE_SETTINGS_ALLOWLIST = new Set([
    'autoCloseTabs',
    'showNotifications',
    'allowSearchEngineResults',
    'theme',
    'pomodoroMinutes',
    'breakMinutes',
    'syncFrequency'
]);

/**
 * Clears client-plantable override / mutation keys from local storage.
 * @returns {Promise<void>}
 */
async function clearPlantableLocalAuthKeys() {
    await chrome.storage.local.remove(['overrideHosts', 'overrideTabs', BLOCKLIST_AUTH_KEY]);
    parentOverrideHosts.clear();
    parentOverrideTabs.clear();
}

/**
 * Writes Access PIN hash (+ removes plaintext) with write-auth flag set.
 * @param {string} hash
 * @returns {Promise<void>}
 */
async function writeAccessPinHashAuthorized(hash) {
    accessPinWriteAuthorized = true;
    try {
        await chrome.storage.sync.set({ [PIN_HASH_KEY]: hash });
        await chrome.storage.sync.remove([PIN_STORAGE_KEY]);
        lastKnownAccessPinHash = hash;
    } finally {
        accessPinWriteAuthorized = false;
    }
}

/**
 * Writes Access PIN configured local flag with write-auth flag set.
 * @param {boolean} value
 * @returns {Promise<void>}
 */
async function writeAccessPinConfiguredAuthorized(value) {
    accessPinWriteAuthorized = true;
    try {
        await chrome.storage.local.set({ [ACCESS_PIN_CONFIGURED_KEY]: !!value });
        lastKnownAccessPinConfigured = !!value;
    } finally {
        accessPinWriteAuthorized = false;
    }
}

/**
 * Migrates / refreshes Access PIN configured flag without auto-setting a factory PIN.
 * Migrates plaintext `nanny_pin` → PBKDF2 hash; upgrades legacy SHA-256 hashes on verify.
 * @returns {Promise<boolean>} effectively configured
 */
async function refreshAccessPinConfiguredState() {
    const sync = await chrome.storage.sync.get([PIN_STORAGE_KEY, PIN_HASH_KEY]);
    const local = await chrome.storage.local.get([ACCESS_PIN_CONFIGURED_KEY]);
    let storedHash = typeof sync[PIN_HASH_KEY] === 'string' ? sync[PIN_HASH_KEY] : '';
    const stored = normalizePinDigits(sync[PIN_STORAGE_KEY]);

    // Migrate acceptable plaintext → PBKDF2 hash once.
    if (!storedHash && isAcceptableNewAccessPin(stored)) {
        storedHash = await hashAccessPin(stored);
        await writeAccessPinHashAuthorized(storedHash);
    } else if (stored && (isForbiddenAccessPin(stored) || !isValidAccessPin(stored))) {
        accessPinWriteAuthorized = true;
        try {
            await chrome.storage.sync.remove([PIN_STORAGE_KEY]);
        } finally {
            accessPinWriteAuthorized = false;
        }
    } else if (stored && storedHash) {
        // Hash already present — drop leftover plaintext.
        accessPinWriteAuthorized = true;
        try {
            await chrome.storage.sync.remove([PIN_STORAGE_KEY]);
        } finally {
            accessPinWriteAuthorized = false;
        }
    }

    lastKnownAccessPinHash = storedHash || '';

    if (!storedHash && (isForbiddenAccessPin(stored) || !isValidAccessPin(stored))) {
        await writeAccessPinConfiguredAuthorized(false);
        pinIsSet = false;
        return false;
    }

    let flag = !!local[ACCESS_PIN_CONFIGURED_KEY];
    // Existing non-legacy PIN / hash from older installs: treat as configured.
    if (!flag && (storedHash || isAcceptableNewAccessPin(stored))) {
        await writeAccessPinConfiguredAuthorized(true);
        flag = true;
    }

    lastKnownAccessPinConfigured = flag;
    const configured = isAccessPinEffectivelyConfigured(stored, flag, storedHash);
    pinIsSet = configured;
    if (!configured) {
        isPINVerified = false;
    }
    return configured;
}

/**
 * Migrates plaintext Parent PIN → hash; clears invalid-length / forbidden PINs.
 * @returns {Promise<void>}
 */
async function refreshLastKnownParentPin() {
    const data = await chrome.storage.sync.get(['settings']);
    const prev = { ...(data.settings || {}) };
    const storedPlain = normalizePinDigits(prev.overridePIN);
    let hash = typeof prev.overridePinHash === 'string' ? prev.overridePinHash : '';

    // Upgrade: short / forbidden legacy plaintext → clear and force re-setup.
    if (storedPlain && (!isValidParentPin(storedPlain) || isForbiddenParentPin(storedPlain))) {
        parentPinWriteAuthorized = true;
        try {
            const next = { ...prev };
            delete next.overridePIN;
            delete next.overridePinHash;
            await chrome.storage.sync.set({ settings: next });
        } finally {
            parentPinWriteAuthorized = false;
        }
        lastKnownOverridePinHash = '';
        return;
    }

    // Migrate acceptable plaintext → hash.
    if (storedPlain && isValidParentPin(storedPlain) && !isForbiddenParentPin(storedPlain)) {
        if (!isPbkdf2PinHash(hash)) {
            hash = await hashParentPin(storedPlain);
        }
        parentPinWriteAuthorized = true;
        try {
            const next = { ...prev, overridePinHash: hash };
            delete next.overridePIN;
            await chrome.storage.sync.set({ settings: next });
        } finally {
            parentPinWriteAuthorized = false;
        }
        lastKnownOverridePinHash = hash;
        return;
    }

    // Hash present but invalid format / leftover plaintext empty — keep hash if PBKDF2.
    if (isPbkdf2PinHash(hash)) {
        if (storedPlain) {
            parentPinWriteAuthorized = true;
            try {
                const next = { ...prev };
                delete next.overridePIN;
                await chrome.storage.sync.set({ settings: next });
            } finally {
                parentPinWriteAuthorized = false;
            }
        }
        lastKnownOverridePinHash = hash;
        return;
    }

    // Unusable hash leftovers — clear.
    if (hash || prev.overridePIN != null) {
        parentPinWriteAuthorized = true;
        try {
            const next = { ...prev };
            delete next.overridePIN;
            delete next.overridePinHash;
            await chrome.storage.sync.set({ settings: next });
        } finally {
            parentPinWriteAuthorized = false;
        }
    }
    lastKnownOverridePinHash = '';
}

/**
 * @returns {Promise<boolean>}
 */
async function isParentPinConfigured() {
    await refreshLastKnownParentPin();
    return isPbkdf2PinHash(lastKnownOverridePinHash);
}

/**
 * @returns {void}
 */
function touchAccessSession() {
    lastAccessActivityMs = Date.now();
}

/**
 * Clears Access session if idle past timeout.
 * @returns {boolean} still verified
 */
function ensureAccessSessionFresh() {
    if (!isPINVerified) {
        return false;
    }
    if (!lastAccessActivityMs || (Date.now() - lastAccessActivityMs) > ACCESS_SESSION_IDLE_MS) {
        isPINVerified = false;
        lastAccessActivityMs = 0;
        return false;
    }
    return true;
}

/**
 * @returns {void}
 */
function lockAccessSession() {
    isPINVerified = false;
    lastAccessActivityMs = 0;
}

/**
 * True when message sender is an extension page (options/popup/pin/welcome/block).
 * @param {chrome.runtime.MessageSender | undefined} sender
 * @returns {boolean}
 */
function isExtensionPageSender(sender) {
    if (!sender || typeof sender.url !== 'string' || !sender.url) {
        return false;
    }
    const origin = chrome.runtime.getURL('');
    return sender.url.startsWith(origin);
}

/**
 * Override from block.html (extension page / web_accessible) only — not arbitrary content scripts.
 * @param {chrome.runtime.MessageSender | undefined} sender
 * @returns {boolean}
 */
function isOverrideBlockSenderAllowed(sender) {
    if (!sender || typeof sender.url !== 'string') {
        return false;
    }
    const u = sender.url;
    const origin = chrome.runtime.getURL('');
    if (u.startsWith(origin)) {
        return true;
    }
    const extId = chrome.runtime.id;
    if (extId && u.includes(extId) && u.includes('block.html')) {
        return true;
    }
    return false;
}

/**
 * Fresh-install first-claim: welcome / pin / options only.
 * @param {chrome.runtime.MessageSender | undefined} sender
 * @returns {boolean}
 */
function isFirstClaimSetupSender(sender) {
    if (!isExtensionPageSender(sender)) {
        return false;
    }
    const u = sender.url || '';
    return u.includes('/welcome/') || u.includes('/pin/') || u.includes('/options/');
}

/**
 * Reject privileged actions from non-extension senders.
 * @param {chrome.runtime.MessageSender | undefined} sender
 * @param {(r: object) => void} sendResponse
 * @returns {boolean} true if rejected
 */
function rejectIfNotExtensionPage(sender, sendResponse) {
    if (isExtensionPageSender(sender)) {
        return false;
    }
    sendResponse({ success: false, error: 'Privileged action requires an extension page' });
    return true;
}

/**
 * Access PIN verify rate-limit bookkeeping.
 * @returns {{ limited: boolean, retryAfterMs?: number }}
 */
function getAccessPinRateLimitState() {
    const now = Date.now();
    accessPinFailTimestamps = accessPinFailTimestamps.filter((t) => now - t < ACCESS_PIN_FAIL_WINDOW_MS);
    if (accessPinFailTimestamps.length >= ACCESS_PIN_FAIL_MAX) {
        const oldest = accessPinFailTimestamps[0];
        return { limited: true, retryAfterMs: Math.max(0, ACCESS_PIN_FAIL_WINDOW_MS - (now - oldest)) };
    }
    return { limited: false };
}

/**
 * @returns {void}
 */
function recordAccessPinFailure() {
    accessPinFailTimestamps.push(Date.now());
}

/**
 * @returns {void}
 */
function clearAccessPinFailures() {
    accessPinFailTimestamps = [];
}

/**
 * @returns {Promise<boolean>}
 */
async function isAccessPinConfigured() {
    return refreshAccessPinConfiguredState();
}

/**
 * Opens the Access PIN window (setup or unlock).
 * @param {'setup' | 'unlock'} mode
 * @returns {Promise<void>}
 */
async function openAccessPinWindow(mode) {
    const q = mode === 'setup' ? '?mode=setup' : '';
    await chrome.windows.create({
        url: chrome.runtime.getURL(`pin/pin.html${q}`),
        type: 'popup',
        width: 560,
        height: 900,
        focused: true
    });
}

/**
 * Opens the main toolbar popup as a dedicated window (reliable after PIN, no default_popup).
 * @returns {Promise<void>}
 */
async function openMainPopupWindow() {
    await chrome.windows.create({
        url: chrome.runtime.getURL('popup/popup.html'),
        type: 'popup',
        width: 560,
        height: 640,
        focused: true
    });
}

/**
 * Toolbar icon click: setup → unlock → main popup.
 */
chrome.action.onClicked.addListener(() => {
    (async () => {
        try {
            const configured = await isAccessPinConfigured();
            if (!configured) {
                await openAccessPinWindow('setup');
                return;
            }
            ensureAccessSessionFresh();
            if (!isPINVerified) {
                await openAccessPinWindow('unlock');
                return;
            }
            touchAccessSession();
            await openMainPopupWindow();
        } catch (e) {
            console.error('Elly action.onClicked:', e);
        }
    })();
});

/**
 * Shared fresh-install defaults (no factory PINs).
 * @returns {object}
 */
function buildDefaultSyncPayload() {
    return {
        blockedSites: [...DEFAULT_BLOCKED_SITES],
        limitedSites: {},
        schedules: [],
        settings: {
            strictMode: true,
            blocklistPinLock: true,
            autoCloseTabs: true,
            showNotifications: true,
            theme: 'light',
            allowSearchEngineResults: true
        },
        statistics: {},
        whitelist: [],
        focusSessions: []
    };
}

// Install / update: defaults, welcome tab, search-results preference — never auto-set Access PIN
chrome.runtime.onInstalled.addListener((details) => {
    (async () => {
        try {
            await clearPlantableLocalAuthKeys();
            isPINVerified = false;

            const data = await chrome.storage.sync.get(null);
            const local = await chrome.storage.local.get(['ellySearchDefaultApplied219', 'ellyExpandedBlocklistV1']);

            if (Object.keys(data).length === 0) {
                await chrome.storage.sync.set(buildDefaultSyncPayload());
                await chrome.storage.local.set({
                    ellySearchDefaultApplied219: true,
                    ellyExpandedBlocklistV1: true
                });
                await writeAccessPinConfiguredAuthorized(false);
            } else {
                const merged = { ...(data.settings || {}) };
                if (merged.allowSearchEngineResults === undefined) {
                    merged.allowSearchEngineResults = true;
                }
                // Strip unusable legacy parent PIN from storage (migrate/clear on refresh)
                if (isForbiddenParentPin(merged.overridePIN) || merged.overridePIN === LEGACY_DEFAULT_PARENT_PIN) {
                    delete merged.overridePIN;
                }
                if (merged.overridePIN && !isValidParentPin(merged.overridePIN)) {
                    delete merged.overridePIN;
                    delete merged.overridePinHash;
                }
                if (!local.ellySearchDefaultApplied219) {
                    merged.allowSearchEngineResults = true;
                    await chrome.storage.local.set({ ellySearchDefaultApplied219: true });
                }
                await chrome.storage.sync.set({ settings: merged });

                if (!local.ellyExpandedBlocklistV1) {
                    const sync = await chrome.storage.sync.get(['blockedSites']);
                    const existing = new Set(sync.blockedSites || []);
                    const mergedSites = [...(sync.blockedSites || [])];
                    for (const entry of DEFAULT_BLOCKED_SITES) {
                        if (!existing.has(entry)) {
                            existing.add(entry);
                            mergedSites.push(entry);
                        }
                    }
                    await chrome.storage.sync.set({ blockedSites: mergedSites });
                    await chrome.storage.local.set({ ellyExpandedBlocklistV1: true });
                }
            }

            await refreshAccessPinConfiguredState();
            await refreshLastKnownParentPin();
            await maybeSeedExtMgmtAfterPinReady();

            if (details.reason === 'install') {
                chrome.tabs.create({ url: chrome.runtime.getURL('welcome/welcome.html') });
            }

            initializeAlarms();
        } catch (e) {
            console.error('Elly onInstalled:', e);
            initializeAlarms();
        }
    })();
});

chrome.runtime.onStartup.addListener(() => {
    isPINVerified = false;
    blocklistMutateUntilMs = 0;
    (async () => {
        try {
            await clearPlantableLocalAuthKeys();
            await refreshAccessPinConfiguredState();
            await refreshLastKnownParentPin();
            await maybeSeedExtMgmtAfterPinReady();
            await loadPersistedSiteUsage();
        } catch (e) {
            console.error('Elly onStartup:', e);
        }
    })();
});

// Track active timers and site usage
let activeTimers = {};
/** @type {Record<string, Record<string, number>>} */
let siteUsage = {};

/** Max domains kept in persisted siteUsage per day. */
const SITE_USAGE_MAX_DOMAINS = 150;
/** Persist at most once per this many ms from the usage ticker. */
const SITE_USAGE_PERSIST_INTERVAL_MS = 15000;
let siteUsageLastPersistMs = 0;

/**
 * @returns {string} chrome.storage.local key for today's usage
 */
function siteUsageStorageKey() {
    const d = new Date();
    const y = d.getFullYear();
    const m = String(d.getMonth() + 1).padStart(2, '0');
    const day = String(d.getDate()).padStart(2, '0');
    return `ellySiteUsage${y}-${m}-${day}`;
}

/**
 * Loads today's siteUsage from local storage (capped).
 * @returns {Promise<void>}
 */
async function loadPersistedSiteUsage() {
    try {
        const key = siteUsageStorageKey();
        const data = await chrome.storage.local.get([key]);
        const raw = data[key];
        if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
            return;
        }
        /** @type {Record<string, Record<string, number>>} */
        const loaded = {};
        const keys = Object.keys(raw).slice(0, SITE_USAGE_MAX_DOMAINS);
        for (const domain of keys) {
            const days = raw[domain];
            if (days && typeof days === 'object' && !Array.isArray(days)) {
                loaded[domain] = { ...days };
            }
        }
        siteUsage = loaded;
    } catch (e) {
        console.error('Elly loadPersistedSiteUsage:', e);
    }
}

/**
 * Persists siteUsage lightly under the day key (capped).
 * @param {boolean} [force]
 * @returns {Promise<void>}
 */
async function persistSiteUsage(force = false) {
    const now = Date.now();
    if (!force && now - siteUsageLastPersistMs < SITE_USAGE_PERSIST_INTERVAL_MS) {
        return;
    }
    siteUsageLastPersistMs = now;
    try {
        const today = new Date().toDateString();
        /** @type {Record<string, Record<string, number>>} */
        const slim = {};
        const entries = Object.entries(siteUsage);
        // Prefer domains with today's usage, then cap.
        entries.sort((a, b) => (b[1]?.[today] || 0) - (a[1]?.[today] || 0));
        for (let i = 0; i < entries.length && Object.keys(slim).length < SITE_USAGE_MAX_DOMAINS; i++) {
            const [domain, days] = entries[i];
            if (!days || typeof days !== 'object') {
                continue;
            }
            const dayVal = days[today];
            if (typeof dayVal === 'number' && dayVal > 0) {
                slim[domain] = { [today]: dayVal };
            }
        }
        await chrome.storage.local.set({ [siteUsageStorageKey()]: slim });
    } catch (e) {
        console.error('Elly persistSiteUsage:', e);
    }
}

const QUIET_STATS_ALARM = 'ellyQuietStatsDaily';

/** Daily alarm for optional aggregate stats sync (JWT + server config). */
function ensureQuietStatsAlarm() {
    try {
        chrome.alarms.create(QUIET_STATS_ALARM, { periodInMinutes: 24 * 60 });
    } catch {
        /* ignore */
    }
}

// Initialize alarms for scheduled blocking
function initializeAlarms() {
    ensureQuietStatsAlarm();
    chrome.storage.sync.get(['schedules']).then((data) => {
        if (data.schedules) {
            data.schedules.forEach(schedule => {
                createScheduleAlarm(schedule);
            });
        }
    });
}

function createScheduleAlarm(schedule) {
    if (schedule.enabled) {
        chrome.alarms.create(`schedule_${schedule.id}_start`, {
            when: new Date(schedule.startTime).getTime(),
            periodInMinutes: 24 * 60 // Daily
        });

        chrome.alarms.create(`schedule_${schedule.id}_end`, {
            when: new Date(schedule.endTime).getTime(),
            periodInMinutes: 24 * 60 // Daily
        });
    }
}

// Handle alarms
chrome.alarms.onAlarm.addListener((alarm) => {
    if (alarm.name === QUIET_STATS_ALARM) {
        tryPostQuietStatsReport().catch(() => {});
        return;
    }
    if (alarm.name.includes('schedule')) {
        handleScheduleAlarm(alarm);
    } else if (alarm.name.includes('limit_warning')) {
        showTimeLimitWarning(alarm.name.split('_')[2]);
    } else if (alarm.name.includes('limit_reached')) {
        handleTimeLimitReached(alarm.name.split('_')[2]);
    } else if (alarm.name.startsWith('override_host_')) {
        const enc = alarm.name.slice('override_host_'.length);
        let hostKey = '';
        try {
            hostKey = decodeURIComponent(enc);
        } catch (e) {
            return;
        }
        if (!hostKey) {
            return;
        }
        (async () => {
            try {
                parentOverrideHosts.delete(hostKey);

                const tabs = await chrome.tabs.query({});
                for (const tab of tabs) {
                    if (!tab.id || !tab.url) continue;
                    if (tab.url.startsWith('chrome://') || tab.url.startsWith('chrome-extension://') ||
                        tab.url.startsWith('opera://') || tab.url.startsWith('edge://')) {
                        continue;
                    }
                    try {
                        if (extractDomain(tab.url).toLowerCase() === hostKey) {
                            await checkAndBlockSite(tab.url, tab.id);
                        }
                    } catch (e) {
                        console.error('Elly override expiry re-check:', e);
                    }
                }
            } catch (e) {
                console.error('Elly override_host alarm:', e);
            }
        })();
    } else if (alarm.name.startsWith('override_tab_')) {
        const tidStr = alarm.name.slice('override_tab_'.length);
        const tid = parseInt(tidStr, 10);
        if (!Number.isFinite(tid)) {
            return;
        }
        (async () => {
            try {
                parentOverrideTabs.delete(String(tid));

                const tab = await chrome.tabs.get(tid).catch(() => null);
                if (tab?.url && tab.id != null) {
                    await checkAndBlockSite(tab.url, tab.id);
                }
            } catch (e) {
                console.error('Elly override_tab alarm:', e);
            }
        })();
    }
});

chrome.tabs.onRemoved.addListener((tabId) => {
    parentOverrideTabs.delete(String(tabId));
});

async function handleScheduleAlarm(alarm) {
    const [_, scheduleId, type] = alarm.name.split('_');
    const data = await chrome.storage.sync.get(['schedules']);
    const schedule = data.schedules?.find(s => s.id === scheduleId);

    if (schedule) {
        const tabs = await chrome.tabs.query({});
        tabs.forEach(tab => {
            if (isUrlBlockedBySchedule(tab.url, schedule)) {
                if (type === 'start') {
                    blockTab(tab.id, tab.url);
                } else {
                    unblockTab(tab.id);
                }
            }
        });
    }
}

/**
 * Blocking model (there are no Declarative Net Request "rules"):
 * - Policies live in chrome.storage.sync: blockedSites, schedules, limitedSites, whitelist, settings.
 * - On navigation, checkAndBlockSite() evaluates those policies and may call blockTab().
 * - Parent PIN grants in-memory overrideTabs / overrideHosts for 15 minutes (SW Map only).
 * - Direct blockTab() calls must still honor overrides (see shouldSkipBlockDueToParentOverride).
 */
// Check if site should be blocked
chrome.webNavigation.onBeforeNavigate.addListener((details) => {
    if (details.url) {
        checkAndBlockSite(details.url, details.tabId);
    }
});

chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
    if (changeInfo.url && !shouldSkipInternalBrowserUrl(changeInfo.url)) {
        checkAndBlockSite(changeInfo.url, tabId);
    }
});

/**
 * chrome://extensions and opera://extensions only — other chrome/opera/edge internals stay skipped.
 * @param {string} url
 * @returns {boolean}
 */
function isExtensionManagementUrl(url) {
    if (!url || typeof url !== 'string') {
        return false;
    }
    const u = url.toLowerCase();
    return u.startsWith('chrome://extensions') || u.startsWith('opera://extensions');
}

/**
 * Skip chrome:// / opera:// / edge:// except extension-management pages we intentionally gate.
 * @param {string} url
 * @returns {boolean}
 */
function shouldSkipInternalBrowserUrl(url) {
    if (!url || typeof url !== 'string') {
        return true;
    }
    if (url.startsWith('chrome://') || url.startsWith('opera://') || url.startsWith('edge://')) {
        return !isExtensionManagementUrl(url);
    }
    return false;
}

/**
 * Seed ext-mgmt blocklist once when a real PIN already exists (upgrade path)
 * or after first-time PIN setup handlers call seed directly.
 * @returns {Promise<void>}
 */
async function maybeSeedExtMgmtAfterPinReady() {
    const accessReady = await refreshAccessPinConfiguredState();
    await refreshLastKnownParentPin();
    const parentReady = isPbkdf2PinHash(lastKnownOverridePinHash);
    if (accessReady || parentReady) {
        await seedExtensionManagementBlocklistIfNeeded();
    }
}

/**
 * Once only: add extension-management patterns to blockedSites after first PIN setup.
 * Grants mutation auth so the storage guard does not revert the seed write.
 * @returns {Promise<void>}
 */
async function seedExtensionManagementBlocklistIfNeeded() {
    try {
        const local = await chrome.storage.local.get([EXT_MGMT_BLOCKLIST_SEEDED_KEY]);
        if (local[EXT_MGMT_BLOCKLIST_SEEDED_KEY]) {
            return;
        }

        grantBlocklistMutationAuth();
        const sync = await chrome.storage.sync.get(['blockedSites']);
        const sites = [...(sync.blockedSites || [])];
        let changed = false;
        for (const pattern of EXT_MGMT_BLOCK_PATTERNS) {
            if (!sites.includes(pattern)) {
                sites.push(pattern);
                changed = true;
            }
        }
        if (changed) {
            await chrome.storage.sync.set({ blockedSites: sites });
        }
        await chrome.storage.local.set({ [EXT_MGMT_BLOCKLIST_SEEDED_KEY]: true });
    } catch (err) {
        console.error('Elly: extension-mgmt blocklist seed failed', err);
    }
}

async function checkAndBlockSite(url, tabId) {
    if (!url || shouldSkipInternalBrowserUrl(url)) {
        return;
    }

    if (isExtensionSchemeUrl(url)) {
        return;
    }

    if (typeof tabId !== 'number' || !Number.isFinite(tabId)) {
        return;
    }

    try {
        const tabExp = parentOverrideTabs.get(String(tabId));
        if (typeof tabExp === 'number') {
            if (Date.now() < tabExp) {
                return;
            }
            parentOverrideTabs.delete(String(tabId));
        }

        const { settings: extSettings } = await chrome.storage.sync.get(['settings']);
        if (extSettings?.extensionEnabled === false) {
            return;
        }

        const hostsObj = Object.fromEntries(parentOverrideHosts.entries());
        const matchKey = findMatchingOverrideKey(url, hostsObj);
        const exp = matchKey != null ? parentOverrideHosts.get(matchKey) : undefined;
        if (typeof exp === 'number') {
            if (Date.now() < exp) {
                return;
            }
            parentOverrideHosts.delete(matchKey);
        }

        const data = await chrome.storage.sync.get(['blockedSites', 'limitedSites', 'schedules', 'whitelist', 'settings']);

        // Check whitelist first
        if (data.whitelist && isUrlInList(url, data.whitelist)) {
            return;
        }

        if (data.settings?.allowSearchEngineResults !== false && isSearchEngineResultsPage(url)) {
            return;
        }

        // Check blocked sites
        if (data.blockedSites && isUrlBlocked(url, data.blockedSites)) {
            await blockTab(tabId, url);
            return;
        }

        // Check schedules
        if (data.schedules && data.schedules.length > 0) {
            const now = new Date();
            const currentDay = now.getDay();
            const currentTime = now.getHours() * 60 + now.getMinutes();

            for (const schedule of data.schedules) {
                if (!schedule.enabled) continue;

                const scheduleDays = schedule.days || [0, 1, 2, 3, 4, 5, 6];
                if (!scheduleDays.includes(currentDay)) continue;

                const startMinutes = timeToMinutes(schedule.startTime);
                const endMinutes = timeToMinutes(schedule.endTime);

                if (currentTime >= startMinutes && currentTime <= endMinutes) {
                    if (schedule.type === 'block' && !isUrlInList(url, schedule.allowedSites || [])) {
                        await blockTab(tabId, url);
                        return;
                    } else if (schedule.type === 'allow' && isUrlInList(url, schedule.blockedSites || [])) {
                        await blockTab(tabId, url);
                        return;
                    }
                }
            }
        }

        // Check time limits
        const limit = getLimitedSiteEntryForUrl(url, data.limitedSites);
        if (limit && typeof limit.limitMinutes === 'number') {
            const domain = extractDomain(url);
            const today = new Date().toDateString();

            if (!siteUsage[domain]) siteUsage[domain] = {};
            if (!siteUsage[domain][today]) siteUsage[domain][today] = 0;

            if (siteUsage[domain][today] >= limit.limitMinutes * 60) {
                await blockTab(tabId, url);
                return;
            }

            const remaining = (limit.limitMinutes * 60) - siteUsage[domain][today];
            if (remaining <= 300 && remaining > 0) {
                chrome.alarms.create(`limit_warning_${domain}`, {
                    delayInMinutes: 1
                });
            }
        }

    } catch (error) {
        console.error('Error checking site:', error);
    }
}

function isUrlBlocked(url, blockedList) {
    return urlListMatches(url, blockedList);
}

function isUrlInList(url, list) {
    return urlListMatches(url, list);
}

function extractDomain(url) {
    try {
        const urlObj = new URL(url);
        return urlObj.hostname.replace('www.', '');
    } catch {
        return url;
    }
}

/**
 * Matches limitedSites keys whether stored as bare domain, full hostname, or www.
 * @param {string} url
 * @param {Record<string, { limitMinutes: number }> | undefined} limitedSites
 * @returns {{ limitMinutes: number } | null}
 */
/**
 * Finds the overrideHosts storage key that applies to this URL (bare domain keys apply to subdomains).
 * @param {string} url
 * @param {Record<string, number>} hosts
 * @returns {string | null}
 */
function findMatchingOverrideKey(url, hosts) {
    if (!hosts || typeof hosts !== 'object') {
        return null;
    }
    try {
        const host = new URL(url).hostname.toLowerCase();
        const bare = extractDomain(url).toLowerCase();
        if (typeof hosts[bare] === 'number') {
            return bare;
        }
        if (typeof hosts[host] === 'number') {
            return host;
        }
        for (const k of Object.keys(hosts)) {
            if (typeof hosts[k] !== 'number') {
                continue;
            }
            const key = String(k).toLowerCase();
            if (host === key || bare === key) {
                return k;
            }
            if (host.endsWith('.' + key)) {
                return k;
            }
        }
    } catch (e) {
        /* ignore */
    }
    return null;
}

/**
 * Extension internal pages must never be evaluated against web blocklists (Opera may use opera-extension://).
 * @param {string} url
 * @returns {boolean}
 */
function isExtensionSchemeUrl(url) {
    if (!url || typeof url !== 'string') {
        return false;
    }
    const u = url.toLowerCase();
    return (
        u.startsWith('chrome-extension://') ||
        u.startsWith('opera-extension://') ||
        u.startsWith('moz-extension://')
    );
}

/**
 * Same logic as the override checks inside checkAndBlockSite. Schedule alarms and timers call blockTab()
 * directly; without this, parent override is ignored and the tab keeps getting forced to the block page.
 * @param {number} tabId
 * @param {string} url
 * @returns {Promise<boolean>}
 */
async function shouldSkipBlockDueToParentOverride(tabId, url) {
    if (typeof tabId !== 'number' || !Number.isFinite(tabId) || !url) {
        return false;
    }
    const tabExp = parentOverrideTabs.get(String(tabId));
    if (typeof tabExp === 'number' && Date.now() < tabExp) {
        return true;
    }
    const hostsObj = Object.fromEntries(parentOverrideHosts.entries());
    const matchKey = findMatchingOverrideKey(url, hostsObj);
    const exp = matchKey != null ? parentOverrideHosts.get(matchKey) : undefined;
    return typeof exp === 'number' && Date.now() < exp;
}

function getLimitedSiteEntryForUrl(url, limitedSites) {
    if (!limitedSites || typeof limitedSites !== 'object') {
        return null;
    }
    try {
        const domain = extractDomain(url);
        let host = '';
        try {
            host = new URL(url).hostname.toLowerCase();
        } catch (e) {
            host = '';
        }
        const bare = host.replace(/^www\./i, '');
        if (limitedSites[domain]) {
            return limitedSites[domain];
        }
        if (host && limitedSites[host]) {
            return limitedSites[host];
        }
        if (bare && limitedSites[bare]) {
            return limitedSites[bare];
        }
        for (const key of Object.keys(limitedSites)) {
            const k = String(key).toLowerCase();
            if (k === domain || k === host || k === bare) {
                return limitedSites[key];
            }
        }
    } catch (e) {
        /* ignore */
    }
    return null;
}

function timeToMinutes(timeStr) {
    const [hours, minutes] = timeStr.split(':').map(Number);
    return hours * 60 + minutes;
}

/**
 * True if the tab is already showing Elly's block page (any extension id).
 * @param {string} url
 * @returns {boolean}
 */
function tabUrlIsEllyBlockPage(url) {
    try {
        const u = new URL(url);
        const p = u.protocol.toLowerCase();
        if (p !== 'chrome-extension:' && p !== 'opera-extension:' && p !== 'moz-extension:') {
            return false;
        }
        return u.pathname.toLowerCase().endsWith('block.html');
    } catch {
        return false;
    }
}

async function blockTab(tabId, originalUrl) {
    try {
        if (await shouldSkipBlockDueToParentOverride(tabId, originalUrl)) {
            return;
        }

        const blockPage = new URL(chrome.runtime.getURL('content/block.html'));
        blockPage.searchParams.set('tabId', String(tabId));
        const targetHref = blockPage.href;

        const tab = await chrome.tabs.get(tabId);
        if (tab?.url) {
            if (tabUrlIsEllyBlockPage(tab.url)) {
                const data = await chrome.storage.local.get(['originalUrls']);
                const originalUrls = { ...(data.originalUrls || {}) };
                if (originalUrl) {
                    originalUrls[tabId] = originalUrl;
                    await chrome.storage.local.set({ originalUrls });
                }
                return;
            }
            let sameDestination = false;
            try {
                sameDestination = new URL(tab.url).href === new URL(targetHref).href;
            } catch (e) {
                sameDestination = tab.url === targetHref;
            }
            if (sameDestination) {
                return;
            }
        }

        const data = await chrome.storage.local.get(['originalUrls']);
        const originalUrls = data.originalUrls || {};
        originalUrls[tabId] = originalUrl;
        await chrome.storage.local.set({ originalUrls });

        await chrome.tabs.update(tabId, {
            url: targetHref
        });

        const blockHost = hostFromBlockedUrl(originalUrl);
        updateStatistics('blocks', 1, blockHost);
    } catch (error) {
        console.error('Error blocking tab:', error);
    }
}

/**
 * Hostname for per-site block tallies (chrome:// / invalid URLs yield empty).
 * @param {string | undefined} url
 * @returns {string}
 */
function hostFromBlockedUrl(url) {
    if (!url || typeof url !== 'string') {
        return '';
    }
    try {
        const u = new URL(url);
        const p = u.protocol.toLowerCase();
        if (p === 'chrome-extension:' || p === 'opera-extension:' || p === 'moz-extension:' || p === 'chrome:' || p === 'opera:' || p === 'edge:') {
            return '';
        }
        return (u.hostname || '').toLowerCase();
    } catch {
        return '';
    }
}

async function unblockTab(tabId) {
    try {
        // Restore original URL from storage
        const data = await chrome.storage.local.get(['originalUrls']);
        if (data.originalUrls && data.originalUrls[tabId]) {
            await chrome.tabs.update(tabId, { url: data.originalUrls[tabId] });
            delete data.originalUrls[tabId];
            await chrome.storage.local.set(data);
        }
    } catch (error) {
        console.error('Error unblocking tab:', error);
    }
}

function showTimeLimitWarning(domain) {
    chrome.storage.sync.get(['settings']).then((data) => {
        if (data.settings?.showNotifications !== false) {
            chrome.notifications.create({
                type: 'basic',
                iconUrl: 'icons/icon48.png',
                title: `${APP_SHORTNAME} - Time Limit Warning`,
                message: `You have less than 5 minutes remaining on ${domain}`,
                priority: 2
            });
        }
    });
}

async function handleTimeLimitReached(domain) {
    const tabs = await chrome.tabs.query({});
    tabs.forEach(tab => {
        if (tab.url && tab.url.includes(domain)) {
            blockTab(tab.id, tab.url);
        }
    });

    const data = await chrome.storage.sync.get(['settings']);
    if (data.settings?.autoCloseTabs !== false) {
        setTimeout(() => {
            chrome.tabs.query({}).then((tabs) => {
                tabs.forEach(tab => {
                    if (tab.url && tab.url.includes(domain)) {
                        chrome.tabs.remove(tab.id);
                    }
                });
            });
        }, 5000); // Close tabs after 5 seconds
    }
}

/**
 * @param {'blocks' | 'timeSaved'} type
 * @param {number} value
 * @param {string} [blockHost] Registrable domain for block tallies when type === 'blocks'
 */
function updateStatistics(type, value = 1, blockHost = '') {
    const today = new Date().toDateString();
    chrome.storage.sync.get(['statistics', ELLY_HOST_TALLY_KEY]).then((data) => {
        const statistics = data.statistics || {};
        if (!statistics[today]) {
            statistics[today] = { blocks: 0, timeSaved: 0 };
        }

        if (type === 'blocks') {
            statistics[today].blocks += value;
        } else if (type === 'timeSaved') {
            statistics[today].timeSaved += value;
        }

        const patch = { statistics };

        if (type === 'blocks' && blockHost) {
            const tally = { ...(data[ELLY_HOST_TALLY_KEY] || {}) };
            tally[blockHost] = (tally[blockHost] || 0) + value;
            const keys = Object.keys(tally);
            if (keys.length > MAX_HOST_KEYS) {
                keys.sort((a, b) => (tally[b] || 0) - (tally[a] || 0));
                const pruned = {};
                for (let i = 0; i < MAX_HOST_KEYS; i++) {
                    pruned[keys[i]] = tally[keys[i]];
                }
                patch[ELLY_HOST_TALLY_KEY] = pruned;
            } else {
                patch[ELLY_HOST_TALLY_KEY] = tally;
            }
        }

        chrome.storage.sync.set(patch);
    });
}

// Track time spent on sites
setInterval(() => {
    chrome.tabs.query({ active: true, currentWindow: true }).then((tabs) => {
        if (tabs[0] && tabs[0].url) {
            if (tabUrlIsEllyBlockPage(tabs[0].url)) {
                return;
            }

            const domain = extractDomain(tabs[0].url);
            const today = new Date().toDateString();

            if (!siteUsage[domain]) siteUsage[domain] = {};
            if (!siteUsage[domain][today]) siteUsage[domain][today] = 0;

            siteUsage[domain][today]++;

            persistSiteUsage().catch(() => {});

            chrome.storage.sync.get(['limitedSites']).then((data) => {
                const limit = getLimitedSiteEntryForUrl(tabs[0].url, data.limitedSites);
                if (limit && typeof limit.limitMinutes === 'number') {
                    if (siteUsage[domain][today] >= limit.limitMinutes * 60) {
                        blockTab(tabs[0].id, tabs[0].url);
                    }
                }
            });
        }
    });
}, 1000); // Update every second

// Message listener
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    const action = request?.action;

    /** Privileged actions that require extension-page sender (not website content scripts). */
    const PRIVILEGED_EXT_PAGE = new Set([
        'setNewPIN',
        'resetPIN',
        'setParentPin',
        'resetParentPin',
        'startFocusSession',
        'addToBlocklist',
        'removeFromBlocklist',
        'authorizeBlocklistMutation',
        'updateSettings',
        'verifyAccessPin',
        'verifyParentPinForBlocklist',
        'updateSecuritySettings',
        'setExtensionEnabled',
        'importSettings',
        'lockAccessSession'
    ]);

    if (PRIVILEGED_EXT_PAGE.has(action)) {
        if (rejectIfNotExtensionPage(sender, sendResponse)) {
            return false;
        }
    }

    if (action === 'overrideBlock') {
        if (!isOverrideBlockSenderAllowed(sender)) {
            sendResponse({ success: false, error: 'Override only allowed from the Elly block page' });
            return false;
        }
    }

    switch (action) {
        case 'getSiteUsage':
            sendResponse({ usage: siteUsage });
            break;

        case 'overrideBlock':
            handleOverrideBlock(request.pin, request.tabId, sendResponse);
            return true;

        case 'updateBlocklist':
            sendResponse({ success: true });
            break;

        case 'authorizeBlocklistMutation':
            handleAuthorizeBlocklistMutation(request.pin, sendResponse);
            return true;

        case 'hasBlocklistMutationAuth':
            sendResponse({ active: hasBlocklistMutationAuth() });
            break;

        case 'revokeBlocklistMutationAuth':
            revokeBlocklistMutationAuth();
            sendResponse({ success: true });
            break;

        case 'verifyParentPinForBlocklist':
            handleVerifyParentPinForBlocklist(request.pin, sendResponse);
            return true;

        case 'removeFromBlocklist':
            handleRemoveFromBlocklist(request.sites, request.pin, sendResponse);
            return true;

        case 'addToBlocklist':
            touchAccessSession();
            addToBlocklist(request.url);
            sendResponse({ success: true });
            break;

        case 'getSettings':
            chrome.storage.sync.get(null).then(async (data) => {
                const parentConfigured = await isParentPinConfigured();
                const safe = stripSecretsFromStorageSnapshot(data);
                sendResponse({ ...safe, parentPinConfigured: parentConfigured });
            });
            return true;

        case 'updateSettings': {
            ensureAccessSessionFresh();
            if (!isPINVerified) {
                sendResponse({ success: false, error: 'Access PIN verification required' });
                break;
            }
            touchAccessSession();
            const incoming = request.settings;
            if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
                sendResponse({ success: false, error: 'Invalid settings payload' });
                break;
            }
            (async () => {
                try {
                    const data = await chrome.storage.sync.get(['settings']);
                    const prev = { ...(data.settings || {}) };
                    /** @type {Record<string, unknown>} */
                    const patch = {};
                    for (const key of Object.keys(incoming)) {
                        if (UPDATE_SETTINGS_ALLOWLIST.has(key)) {
                            patch[key] = incoming[key];
                        }
                    }
                    if (Object.keys(patch).length === 0) {
                        sendResponse({ success: false, error: 'No allowlisted settings keys' });
                        return;
                    }
                    await chrome.storage.sync.set({ settings: { ...prev, ...patch } });
                    sendResponse({ success: true });
                } catch (err) {
                    sendResponse({ success: false, error: err?.message || 'updateSettings failed' });
                }
            })();
            return true;
        }

        case 'updateSecuritySettings':
            handleUpdateSecuritySettings(request.settings, request.pin, sendResponse);
            return true;

        case 'setExtensionEnabled':
            handleSetExtensionEnabled(request.enabled, request.pin, sendResponse);
            return true;

        case 'importSettings':
            handleImportSettings(request.data, sendResponse);
            return true;

        case 'verifyAccessPin':
            handleVerifyAccessPin(request.pin, sendResponse);
            return true;

        case 'lockAccessSession':
            lockAccessSession();
            sendResponse({ success: true });
            break;

        case 'pinVerified':
            sendResponse({ success: false, error: 'Use verifyAccessPin with PIN digits' });
            break;

        case 'checkPINStatus':
            ensureAccessSessionFresh();
            sendResponse({ verified: isPINVerified });
            break;

        case 'startFocusSession':
            ensureAccessSessionFresh();
            if (!isPINVerified) {
                sendResponse({ success: false, error: 'Access PIN verification required' });
                break;
            }
            touchAccessSession();
            handleFocusSession(request.duration);
            sendResponse({ success: true });
            break;

        case 'resetPIN':
            handleResetPIN(request.oldPin, request.newPin, sendResponse);
            return true;

        case 'setNewPIN':
            handleSetNewPIN(request.newPin, sender, sendResponse);
            return true;

        case 'setParentPin':
        case 'resetParentPin':
            handleSetParentPin(request.currentPin, request.newPin, sender, sendResponse);
            return true;

        case 'clearPIN':
            handleClearPIN(sendResponse);
            return true;

        case 'getAppStatus':
            (async () => {
                try {
                    const configured = await isAccessPinConfigured();
                    ensureAccessSessionFresh();
                    const parentConfigured = await isParentPinConfigured();
                    sendResponse({
                        pinSet: configured,
                        configured,
                        verified: isPINVerified && configured,
                        parentPinConfigured: parentConfigured,
                        version: chrome.runtime.getManifest()?.version || '2.4.1'
                    });
                } catch (err) {
                    sendResponse({
                        pinSet: false,
                        configured: false,
                        verified: false,
                        parentPinConfigured: false,
                        version: '2.4.1',
                        error: err?.message
                    });
                }
            })();
            return true;

        case 'heartbeat':
            sendResponse({ alive: true });
            break;

        default:
            sendResponse({ error: 'Unknown action' });
    }

    return false;
});

// Helper functions for PIN management

/**
 * Verifies the 8-digit Access PIN and marks the session verified on success.
 * Does NOT grant Parent blocklist mutation auth (Access session only).
 * @param {unknown} pin
 * @param {(r: object) => void} callback
 * @returns {Promise<void>}
 */
async function handleVerifyAccessPin(pin, callback) {
    try {
        const rate = getAccessPinRateLimitState();
        if (rate.limited) {
            const secs = Math.ceil((rate.retryAfterMs || 0) / 1000);
            callback({
                success: false,
                error: `Too many failed attempts. Try again in ${secs}s.`,
                cooldown: true,
                retryAfterMs: rate.retryAfterMs
            });
            return;
        }

        const entered = normalizePinDigits(pin);
        if (!isValidAccessPin(entered) || isForbiddenAccessPin(entered)) {
            recordAccessPinFailure();
            callback({ success: false, error: 'Invalid Access PIN' });
            return;
        }

        const configured = await isAccessPinConfigured();
        if (!configured) {
            callback({ success: false, error: 'Access PIN is not configured', configured: false });
            return;
        }

        if (!(await accessPinMatches(entered))) {
            isPINVerified = false;
            recordAccessPinFailure();
            callback({ success: false, error: 'Incorrect Access PIN' });
            return;
        }

        clearAccessPinFailures();
        isPINVerified = true;
        pinIsSet = true;
        touchAccessSession();
        callback({ success: true, verified: true, configured: true });
    } catch (error) {
        console.error('Error verifying Access PIN:', error);
        callback({ success: false, error: error?.message || 'Verification failed' });
    }
}

/**
 * Compares entered Access PIN against stored hash (migrates legacy formats on success).
 * @param {string} entered
 * @returns {Promise<boolean>}
 */
async function accessPinMatches(entered) {
    const data = await chrome.storage.sync.get([PIN_HASH_KEY, PIN_STORAGE_KEY]);
    const storedHash = typeof data[PIN_HASH_KEY] === 'string' ? data[PIN_HASH_KEY] : '';

    if (isPbkdf2PinHash(storedHash)) {
        const candidate = await hashAccessPin(entered);
        return secureStringEqual(storedHash, candidate);
    }

    if (isLegacySha256PinHash(storedHash)) {
        const legacy = await hashAccessPinLegacySha256(entered);
        if (secureStringEqual(storedHash.toLowerCase(), legacy.toLowerCase())) {
            const upgraded = await hashAccessPin(entered);
            await writeAccessPinHashAuthorized(upgraded);
            return true;
        }
        return false;
    }

    const plaintext = normalizePinDigits(data[PIN_STORAGE_KEY]);
    if (isAcceptableNewAccessPin(plaintext) && plaintext === entered) {
        const upgraded = await hashAccessPin(entered);
        await writeAccessPinHashAuthorized(upgraded);
        return true;
    }
    return false;
}

async function handleResetPIN(oldPin, newPin, callback) {
    try {
        const configured = await refreshAccessPinConfiguredState();
        if (!configured) {
            callback({ success: false, error: 'Access PIN not configured — use setNewPIN first' });
            return;
        }

        const oldNorm = normalizePinDigits(oldPin);
        const newNorm = normalizePinDigits(newPin);

        if (!(await accessPinMatches(oldNorm))) {
            callback({ success: false, error: 'Current PIN incorrect' });
            return;
        }

        if (!isAcceptableNewAccessPin(newNorm)) {
            callback({
                success: false,
                error: isForbiddenAccessPin(newNorm)
                    ? 'That PIN is not allowed. Choose a different 8-digit PIN.'
                    : 'New PIN must be 8 digits'
            });
            return;
        }

        const newHash = await hashAccessPin(newNorm);
        await writeAccessPinHashAuthorized(newHash);
        await writeAccessPinConfiguredAuthorized(true);
        pinIsSet = true;
        isPINVerified = true;
        touchAccessSession();
        callback({ success: true });
    } catch (error) {
        console.error('Error resetting PIN:', error);
        callback({ success: false, error: error.message });
    }
}

/**
 * @param {unknown} newPin
 * @param {chrome.runtime.MessageSender | undefined} sender
 * @param {(r: object) => void} callback
 */
async function handleSetNewPIN(newPin, sender, callback) {
    try {
        if (!isExtensionPageSender(sender)) {
            callback({ success: false, error: 'Access PIN setup only allowed from Elly pages' });
            return;
        }
        // Content-script style: tab present without extension URL already rejected by allowlist.
        if (sender?.tab && !isExtensionPageSender(sender)) {
            callback({ success: false, error: 'Rejected' });
            return;
        }

        const configured = await refreshAccessPinConfiguredState();
        if (configured) {
            callback({ success: false, error: 'Access PIN already set — use resetPIN with your current PIN' });
            return;
        }

        const newNorm = normalizePinDigits(newPin);
        if (!isAcceptableNewAccessPin(newNorm)) {
            callback({
                success: false,
                error: isForbiddenAccessPin(newNorm)
                    ? 'That PIN is not allowed. Choose a different 8-digit PIN.'
                    : 'PIN must be 8 digits'
            });
            return;
        }

        const newHash = await hashAccessPin(newNorm);
        await writeAccessPinHashAuthorized(newHash);
        await writeAccessPinConfiguredAuthorized(true);
        pinIsSet = true;
        isPINVerified = true;
        touchAccessSession();
        await seedExtensionManagementBlocklistIfNeeded();
        callback({ success: true });
    } catch (error) {
        console.error('Error setting PIN:', error);
        callback({ success: false, error: error.message });
    }
}

/**
 * Sets or resets Parent override PIN hash. Requires currentPin when a usable Parent PIN exists.
 * First-set when missing: requires Access unlocked unless Access also unconfigured (welcome flow).
 * @param {unknown} currentPin
 * @param {unknown} newPin
 * @param {chrome.runtime.MessageSender | undefined} sender
 * @param {(r: object) => void} callback
 * @returns {Promise<void>}
 */
async function handleSetParentPin(currentPin, newPin, sender, callback) {
    try {
        if (!isExtensionPageSender(sender)) {
            callback({ success: false, error: 'Parent PIN setup only allowed from Elly pages' });
            return;
        }

        const newNorm = normalizePinDigits(newPin);
        if (!isAcceptableNewParentPin(newNorm)) {
            callback({
                success: false,
                error: isForbiddenParentPin(newNorm)
                    ? 'That Parent PIN is not allowed. Choose a different PIN.'
                    : 'Parent PIN must be 8–12 digits'
            });
            return;
        }

        await refreshLastKnownParentPin();
        const hasUsable = isPbkdf2PinHash(lastKnownOverridePinHash);
        const accessConfigured = await refreshAccessPinConfiguredState();
        ensureAccessSessionFresh();

        if (!hasUsable) {
            // First-set: Access unlocked, OR fresh install (Access also unconfigured) from welcome/pin/options.
            if (accessConfigured) {
                if (!isPINVerified) {
                    callback({ success: false, error: 'Unlock with your Access PIN before setting Parent PIN' });
                    return;
                }
            } else if (!isFirstClaimSetupSender(sender)) {
                callback({ success: false, error: 'Parent PIN first-set only from welcome, PIN, or options pages' });
                return;
            }
        } else {
            const cur = normalizePinDigits(currentPin);
            if (!cur || !(await parentPinMatches(cur))) {
                callback({ success: false, error: 'Current Parent PIN incorrect' });
                return;
            }
        }

        const newHash = await hashParentPin(newNorm);
        parentPinWriteAuthorized = true;
        try {
            const data = await chrome.storage.sync.get(['settings']);
            const prev = { ...(data.settings || {}) };
            const next = { ...prev, overridePinHash: newHash };
            delete next.overridePIN;
            await chrome.storage.sync.set({ settings: next });
            lastKnownOverridePinHash = newHash;
        } finally {
            parentPinWriteAuthorized = false;
        }

        // Changing Parent PIN grants short mutation auth (not override-only path).
        grantBlocklistMutationAuth();
        touchAccessSession();
        if (!hasUsable) {
            await seedExtensionManagementBlocklistIfNeeded();
        }
        callback({ success: true });
    } catch (error) {
        console.error('Error setting Parent PIN:', error);
        callback({ success: false, error: error?.message || 'setParentPin failed' });
    }
}

async function handleClearPIN(callback) {
    callback({
        success: false,
        error: 'Access PIN cannot be cleared. Use resetPIN to change it.'
    });
}

async function handleFocusSession(duration) {
    const data = await chrome.storage.sync.get(['blockedSites', 'focusSessions']);
    const originalBlocklist = data.blockedSites || [];

    const focusBlocklist = [...originalBlocklist,
        '*.facebook.com', '*.twitter.com', '*.instagram.com',
        '*.youtube.com', '*.netflix.com', '*.reddit.com'
    ];

    const sessions = data.focusSessions || [];
    sessions.push({
        start: new Date().toISOString(),
        duration: parseInt(duration),
        active: true
    });

    await chrome.storage.sync.set({
        blockedSites: focusBlocklist,
        focusSessions: sessions,
        focusSessionActive: true
    });

    setTimeout(async () => {
        const currentData = await chrome.storage.sync.get(['focusSessions']);
        const updatedSessions = currentData.focusSessions || [];
        const currentSession = updatedSessions.find(s => s.active);

        if (currentSession) {
            currentSession.active = false;
            currentSession.end = new Date().toISOString();
            focusRestoreAuthorized = true;
            try {
                await chrome.storage.sync.set({
                    blockedSites: originalBlocklist,
                    focusSessions: updatedSessions,
                    focusSessionActive: false
                });
            } finally {
                focusRestoreAuthorized = false;
            }

            chrome.notifications.create({
                type: 'basic',
                iconUrl: 'icons/icon48.png',
                title: 'Focus Session Ended',
                message: `Your ${duration}-minute focus session has ended!`,
                priority: 2
            });
        }
    }, duration * 60000);
}

/**
 * Temporary site override only — does NOT grant blocklist mutation auth.
 * @param {unknown} pin
 * @param {unknown} tabId
 * @param {(r: object) => void} callback
 */
async function handleOverrideBlock(pin, tabId, callback) {
    try {
        const pinStr = normalizePinDigits(pin);
        const data = await chrome.storage.sync.get(['settings']);

        if (data.settings?.strictMode) {
            callback({
                success: false,
                error: 'Strict Mode is on — temporary overrides are disabled. Remove the site from the Blocklist with your Parent PIN instead.'
            });
            return;
        }

        if (isForbiddenParentPin(pinStr) || !(await parentPinMatches(pinStr))) {
            callback({ success: false, error: 'Incorrect parent PIN' });
            return;
        }

        const tid = typeof tabId === 'number' ? tabId : parseInt(String(tabId), 10);
        if (!Number.isFinite(tid)) {
            callback({ success: false, error: 'Invalid tab' });
            return;
        }

        const urlData = await chrome.storage.local.get(['originalUrls']);
        const originalUrls = urlData.originalUrls || {};
        const originalUrl = originalUrls[tid] ?? originalUrls[String(tid)];
        if (!originalUrl || typeof originalUrl !== 'string') {
            callback({ success: false, error: 'Blocked URL not found' });
            return;
        }

        let hostKey = '';
        try {
            hostKey = extractDomain(originalUrl).toLowerCase();
        } catch (e) {
            callback({ success: false, error: 'Invalid URL' });
            return;
        }
        if (!hostKey) {
            callback({ success: false, error: 'Invalid host' });
            return;
        }

        const until = Date.now() + 15 * 60 * 1000;
        parentOverrideHosts.set(hostKey, until);
        parentOverrideTabs.set(String(tid), until);
        // Intentionally do NOT grantBlocklistMutationAuth — override ≠ mutation auth.

        const alarmName = `override_host_${encodeURIComponent(hostKey)}`;
        chrome.alarms.create(alarmName, { delayInMinutes: 15 });
        chrome.alarms.create(`override_tab_${tid}`, { delayInMinutes: 15 });

        callback({ success: true });
    } catch (error) {
        console.error('Error handling override:', error);
        callback({ success: false, error: error.message });
    }
}

/**
 * Grants short-lived Parent mutation auth in SW memory only (max 5 min).
 * @returns {void}
 */
function grantBlocklistMutationAuth() {
    blocklistMutateUntilMs = Date.now() + BLOCKLIST_AUTH_MS;
}

/**
 * Clears the short-lived mutation auth window (used by the header Lock button).
 * @returns {void}
 */
function revokeBlocklistMutationAuth() {
    blocklistMutateUntilMs = 0;
}

/**
 * @returns {boolean}
 */
function hasBlocklistMutationAuth() {
    return Number.isFinite(blocklistMutateUntilMs) && Date.now() < blocklistMutateUntilMs;
}

/**
 * @returns {Promise<boolean>}
 */
async function isBlocklistPinLockActive() {
    const data = await chrome.storage.sync.get(['settings']);
    return !!(data.settings?.blocklistPinLock || data.settings?.strictMode);
}

/**
 * Parent PIN compare — hash only; requires isValidParentPin (8–12).
 * @param {unknown} pin
 * @returns {Promise<boolean>}
 */
async function parentPinMatches(pin) {
    const entered = normalizePinDigits(pin);
    if (!isValidParentPin(entered) || isForbiddenParentPin(entered)) {
        return false;
    }
    await refreshLastKnownParentPin();
    if (!isPbkdf2PinHash(lastKnownOverridePinHash)) {
        return false;
    }
    const candidate = await hashParentPin(entered);
    return secureStringEqual(lastKnownOverridePinHash, candidate);
}

/**
 * @param {unknown} pin
 * @param {(r: object) => void} callback
 */
async function handleAuthorizeBlocklistMutation(pin, callback) {
    try {
        ensureAccessSessionFresh();
        if (!(await isBlocklistPinLockActive())) {
            // Lock OFF: still require Access session — never grant with zero auth.
            if (!isPINVerified) {
                callback({ success: false, error: 'Access PIN verification required' });
                return;
            }
            grantBlocklistMutationAuth();
            touchAccessSession();
            callback({ success: true });
            return;
        }
        if (hasBlocklistMutationAuth()) {
            grantBlocklistMutationAuth();
            touchAccessSession();
            callback({ success: true });
            return;
        }
        if (!(await parentPinMatches(pin))) {
            callback({ success: false, error: 'Incorrect parent PIN' });
            return;
        }
        grantBlocklistMutationAuth();
        touchAccessSession();
        callback({ success: true });
    } catch (error) {
        callback({ success: false, error: error?.message || 'Auth failed' });
    }
}

/**
 * @param {unknown} pin
 * @param {(r: object) => void} callback
 */
async function handleVerifyParentPinForBlocklist(pin, callback) {
    try {
        if (!(await parentPinMatches(pin))) {
            callback({ success: false, error: 'Incorrect parent PIN' });
            return;
        }
        grantBlocklistMutationAuth();
        touchAccessSession();
        callback({ success: true });
    } catch (error) {
        callback({ success: false, error: error?.message || 'Verification failed' });
    }
}

/**
 * @param {unknown} sites
 * @param {unknown} pin
 * @param {(r: object) => void} callback
 */
async function handleRemoveFromBlocklist(sites, pin, callback) {
    try {
        const toRemove = Array.isArray(sites)
            ? sites.map((s) => String(s)).filter(Boolean)
            : (sites ? [String(sites)] : []);
        if (toRemove.length === 0) {
            callback({ success: false, error: 'No sites to remove' });
            return;
        }

        ensureAccessSessionFresh();
        if (await isBlocklistPinLockActive()) {
            const authed = hasBlocklistMutationAuth() || (await parentPinMatches(pin));
            if (!authed) {
                callback({ success: false, error: 'Parent PIN required to remove blocked sites' });
                return;
            }
            grantBlocklistMutationAuth();
        } else {
            if (!isPINVerified) {
                callback({ success: false, error: 'Access PIN verification required' });
                return;
            }
            grantBlocklistMutationAuth();
        }

        touchAccessSession();
        const data = await chrome.storage.sync.get(['blockedSites']);
        const blockedSites = (data.blockedSites || []).filter((s) => !toRemove.includes(s));
        await chrome.storage.sync.set({ blockedSites });
        callback({ success: true, blockedSites });
    } catch (error) {
        callback({ success: false, error: error?.message || 'Remove failed' });
    }
}

/**
 * Writes security toggles with dedicated write-auth (not mutation auth alone).
 * @param {Record<string, unknown> | undefined} patch
 * @param {unknown} pin
 * @param {(r: object) => void} callback
 */
async function handleUpdateSecuritySettings(patch, pin, callback) {
    try {
        if (!patch || typeof patch !== 'object') {
            callback({ success: false, error: 'Invalid settings' });
            return;
        }
        const turningOffSensitive =
            patch.extensionEnabled === false
            || patch.strictMode === false
            || patch.blocklistPinLock === false;

        if (turningOffSensitive) {
            if (!(await parentPinMatches(pin))) {
                callback({ success: false, error: 'Incorrect parent PIN' });
                return;
            }
        }

        const data = await chrome.storage.sync.get(['settings']);
        const prev = { ...(data.settings || {}) };
        /** @type {Record<string, unknown>} */
        const next = { ...prev };
        if ('strictMode' in patch) {
            next.strictMode = !!patch.strictMode;
        }
        if ('blocklistPinLock' in patch) {
            next.blocklistPinLock = !!patch.blocklistPinLock;
        }
        if ('extensionEnabled' in patch) {
            next.extensionEnabled = patch.extensionEnabled !== false;
        }
        if (next.strictMode) {
            next.blocklistPinLock = true;
        }

        securityToggleWriteAuthorized = true;
        try {
            await chrome.storage.sync.set({ settings: next });
        } finally {
            securityToggleWriteAuthorized = false;
        }
        touchAccessSession();
        callback({ success: true, settings: stripSecretsFromStorageSnapshot({ settings: next }).settings });
    } catch (error) {
        callback({ success: false, error: error?.message || 'updateSecuritySettings failed' });
    }
}

/**
 * @param {unknown} enabled
 * @param {unknown} pin
 * @param {(r: object) => void} callback
 */
async function handleSetExtensionEnabled(enabled, pin, callback) {
    const on = enabled !== false;
    if (!on) {
        if (!(await parentPinMatches(pin))) {
            callback({ success: false, error: 'Incorrect parent PIN' });
            return;
        }
    }
    await handleUpdateSecuritySettings({ extensionEnabled: on }, pin, callback);
}

/**
 * Validates and applies an import snapshot (strips secrets; refuses plaintext Parent PIN).
 * @param {unknown} raw
 * @param {(r: object) => void} callback
 */
async function handleImportSettings(raw, callback) {
    try {
        if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
            callback({ success: false, error: 'Invalid import data' });
            return;
        }
        /** @type {Record<string, unknown>} */
        const data = /** @type {Record<string, unknown>} */ (raw);
        if (importContainsPlaintextParentPin(data)) {
            callback({ success: false, error: 'Import refused: file contains plaintext Parent PIN' });
            return;
        }
        const safe = stripSecretsFromStorageSnapshot(data);
        // Never import PIN hashes or JWT either.
        delete safe.nanny_pin;
        delete safe.nanny_pin_hash;
        if (safe.settings && typeof safe.settings === 'object') {
            const s = /** @type {Record<string, unknown>} */ (safe.settings);
            delete s.overridePIN;
            delete s.overridePinHash;
            delete s.parent_pin_hash;
        }

        grantBlocklistMutationAuth();
        schedulesWriteAuthorized = true;
        limitedSitesWriteAuthorized = true;
        try {
            await chrome.storage.sync.set(safe);
        } finally {
            schedulesWriteAuthorized = false;
            limitedSitesWriteAuthorized = false;
        }
        touchAccessSession();
        callback({ success: true });
    } catch (error) {
        callback({ success: false, error: error?.message || 'Import failed' });
    }
}

/**
 * True when schedules changed without write/mutation auth (any policy edit, not only shrinks).
 * @param {unknown} oldArr
 * @param {unknown} newArr
 * @returns {boolean}
 */
function isSchedulesUnauthorizedChange(oldArr, newArr) {
    try {
        return JSON.stringify(oldArr ?? []) !== JSON.stringify(newArr ?? []);
    } catch {
        return true;
    }
}

/**
 * True when limitedSites changed without write/mutation auth (any policy edit, not only shrinks).
 * @param {unknown} oldObj
 * @param {unknown} newObj
 * @returns {boolean}
 */
function isLimitedSitesUnauthorizedChange(oldObj, newObj) {
    try {
        return JSON.stringify(oldObj ?? {}) !== JSON.stringify(newObj ?? {});
    } catch {
        return true;
    }
}

/**
 * Defense-in-depth: revert unauthorized parental control weakenings.
 */
chrome.storage.onChanged.addListener((changes, areaName) => {
    (async () => {
        if (areaName === 'local') {
            const accessWriteOkLocal = accessPinWriteAuthorized;
            if (changes[BLOCKLIST_AUTH_KEY]) {
                await chrome.storage.local.remove([BLOCKLIST_AUTH_KEY]);
            }
            if (changes[ACCESS_PIN_CONFIGURED_KEY] && !accessWriteOkLocal) {
                const newVal = !!changes[ACCESS_PIN_CONFIGURED_KEY].newValue;
                if (newVal !== lastKnownAccessPinConfigured) {
                    accessPinWriteAuthorized = true;
                    try {
                        await chrome.storage.local.set({
                            [ACCESS_PIN_CONFIGURED_KEY]: lastKnownAccessPinConfigured
                        });
                        console.warn('Elly: reverted unauthorized Access PIN configured flag change');
                    } finally {
                        accessPinWriteAuthorized = false;
                    }
                }
            }
            return;
        }

        if (areaName !== 'sync') {
            return;
        }

        // Snapshot SW-only auth flags before any await (onChanged is async).
        const mutateAuthed = hasBlocklistMutationAuth();
        const focusRestoreOk = focusRestoreAuthorized;
        const accessWriteOk = accessPinWriteAuthorized;
        const parentWriteOk = parentPinWriteAuthorized;
        const securityToggleOk = securityToggleWriteAuthorized;
        const schedulesWriteOk = schedulesWriteAuthorized;
        const limitedWriteOk = limitedSitesWriteAuthorized;

        if ((changes[PIN_HASH_KEY] || changes[PIN_STORAGE_KEY]) && !accessWriteOk) {
            accessPinWriteAuthorized = true;
            try {
                /** @type {Record<string, unknown>} */
                const restore = {};
                if (lastKnownAccessPinHash) {
                    restore[PIN_HASH_KEY] = lastKnownAccessPinHash;
                }
                if (Object.keys(restore).length > 0) {
                    await chrome.storage.sync.set(restore);
                }
                await chrome.storage.sync.remove([PIN_STORAGE_KEY]);
                if (!lastKnownAccessPinHash) {
                    await chrome.storage.sync.remove([PIN_HASH_KEY]);
                }
                console.warn('Elly: reverted unauthorized Access PIN storage change');
            } finally {
                accessPinWriteAuthorized = false;
            }
        }

        if (changes.settings) {
            const oldSettings = changes.settings.oldValue || {};
            const newSettings = changes.settings.newValue || {};

            const oldHash = typeof oldSettings.overridePinHash === 'string' ? oldSettings.overridePinHash : '';
            const newHash = typeof newSettings.overridePinHash === 'string' ? newSettings.overridePinHash : '';
            const oldPlain = normalizePinDigits(oldSettings.overridePIN);
            const newPlain = normalizePinDigits(newSettings.overridePIN);
            const pinMaterialChanged = oldHash !== newHash || oldPlain !== newPlain || (!!newPlain);

            // Mutation auth alone must NOT allow Parent PIN changes.
            if (pinMaterialChanged && !parentWriteOk) {
                parentPinWriteAuthorized = true;
                try {
                    const restored = { ...newSettings };
                    delete restored.overridePIN;
                    if (lastKnownOverridePinHash) {
                        restored.overridePinHash = lastKnownOverridePinHash;
                    } else {
                        delete restored.overridePinHash;
                    }
                    await chrome.storage.sync.set({ settings: restored });
                    console.warn('Elly: reverted unauthorized Parent PIN change');
                } finally {
                    parentPinWriteAuthorized = false;
                }
            } else if (pinMaterialChanged && parentWriteOk) {
                lastKnownOverridePinHash = isPbkdf2PinHash(newHash) ? newHash : lastKnownOverridePinHash;
                if (newPlain) {
                    // Drop any plaintext that slipped in during authorized write.
                    parentPinWriteAuthorized = true;
                    try {
                        const cleaned = { ...newSettings };
                        delete cleaned.overridePIN;
                        await chrome.storage.sync.set({ settings: cleaned });
                    } finally {
                        parentPinWriteAuthorized = false;
                    }
                }
            }

            const extensionTurnedOff = oldSettings.extensionEnabled !== false
                && newSettings.extensionEnabled === false;
            if (extensionTurnedOff && !securityToggleOk) {
                await chrome.storage.sync.set({
                    settings: {
                        ...newSettings,
                        extensionEnabled: true
                    }
                });
                console.warn('Elly: reverted unauthorized extension disable');
            }
        }

        const lockActive = await isBlocklistPinLockActive();

        if (lockActive && changes.blockedSites && !blocklistRevertInFlight) {
            const oldSites = Array.isArray(changes.blockedSites.oldValue)
                ? changes.blockedSites.oldValue
                : [];
            const newSites = Array.isArray(changes.blockedSites.newValue)
                ? changes.blockedSites.newValue
                : [];
            const removed = oldSites.filter((s) => !newSites.includes(s));
            if (removed.length > 0 && !mutateAuthed && !focusRestoreOk) {
                blocklistRevertInFlight = true;
                try {
                    await chrome.storage.sync.set({ blockedSites: oldSites });
                    console.warn('Elly: reverted unauthorized blocklist removal');
                } finally {
                    blocklistRevertInFlight = false;
                }
            }
        }

        if (lockActive && changes.whitelist) {
            const oldList = Array.isArray(changes.whitelist.oldValue)
                ? changes.whitelist.oldValue
                : [];
            const newList = Array.isArray(changes.whitelist.newValue)
                ? changes.whitelist.newValue
                : [];
            const added = newList.filter((s) => !oldList.includes(s));
            if (added.length > 0 && !mutateAuthed) {
                await chrome.storage.sync.set({ whitelist: oldList });
                console.warn('Elly: reverted unauthorized whitelist add');
            }
        }

        if (lockActive && changes.schedules) {
            if (
                isSchedulesUnauthorizedChange(changes.schedules.oldValue, changes.schedules.newValue)
                && !mutateAuthed
                && !schedulesWriteOk
            ) {
                const oldList = Array.isArray(changes.schedules.oldValue)
                    ? changes.schedules.oldValue
                    : [];
                schedulesWriteAuthorized = true;
                try {
                    await chrome.storage.sync.set({ schedules: oldList });
                    console.warn('Elly: reverted unauthorized schedules change');
                } finally {
                    schedulesWriteAuthorized = false;
                }
            }
        }

        if (lockActive && changes.limitedSites) {
            if (
                isLimitedSitesUnauthorizedChange(changes.limitedSites.oldValue, changes.limitedSites.newValue)
                && !mutateAuthed
                && !limitedWriteOk
            ) {
                const oldObj = changes.limitedSites.oldValue && typeof changes.limitedSites.oldValue === 'object'
                    ? changes.limitedSites.oldValue
                    : {};
                limitedSitesWriteAuthorized = true;
                try {
                    await chrome.storage.sync.set({ limitedSites: oldObj });
                    console.warn('Elly: reverted unauthorized limitedSites change');
                } finally {
                    limitedSitesWriteAuthorized = false;
                }
            }
        }

        if (changes.settings) {
            const oldSettings = changes.settings.oldValue || {};
            const newSettings = changes.settings.newValue || {};
            const strictTurnedOff = !!oldSettings.strictMode && !newSettings.strictMode;
            const pinLockTurnedOff = (!!oldSettings.blocklistPinLock || !!oldSettings.strictMode)
                && !newSettings.blocklistPinLock
                && !newSettings.strictMode;
            // Mutation auth alone must NOT allow security toggles OFF.
            if ((strictTurnedOff || pinLockTurnedOff) && !securityToggleOk) {
                await chrome.storage.sync.set({
                    settings: {
                        ...newSettings,
                        strictMode: oldSettings.strictMode,
                        blocklistPinLock: oldSettings.blocklistPinLock || oldSettings.strictMode
                    }
                });
                console.warn('Elly: reverted unauthorized security toggle change');
            }
        }
    })().catch((err) => {
        console.error('Elly storage guard error:', err);
    });
});

function addToBlocklist(url) {
    chrome.storage.sync.get(['blockedSites']).then((data) => {
        const blockedSites = data.blockedSites || [];
        if (!blockedSites.includes(url)) {
            blockedSites.push(url);
            chrome.storage.sync.set({ blockedSites });
        }
    });
}

// Function to check if URL is blocked by schedule
function isUrlBlockedBySchedule(url, schedule) {
    if (!url) return false;

    if (schedule.type === 'block') {
        // Block all except allowed sites
        return !isUrlInList(url, schedule.allowedSites || []);
    } else {
        // Allow all except blocked sites
        return isUrlInList(url, schedule.blockedSites || []);
    }
}

// Cold-start: wipe plantable override/auth keys and refresh Access PIN flag
(async () => {
    try {
        await clearPlantableLocalAuthKeys();
        blocklistMutateUntilMs = 0;
        await refreshAccessPinConfiguredState();
        await refreshLastKnownParentPin();
        await loadPersistedSiteUsage();
    } catch (e) {
        console.error('Elly SW init:', e);
    }
})();