/*******************************************************************************

    uBlock Origin - a browser extension to block requests.
    Copyright (C) 2014-2015 The uBlock Origin authors
    Copyright (C) 2014-present Raymond Hill

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program.  If not, see {http://www.gnu.org/licenses/}.

    Home: https://github.com/gorhill/uBlock
*/

// For background page

/* globals browser */

'use strict';

/******************************************************************************/

import webext from './webext.js';
import { ubolog } from './console.js';

/******************************************************************************/

const manifest = browser.runtime.getManifest();

vAPI.cantWebsocket =
    browser.webRequest.ResourceType instanceof Object === false  ||
    browser.webRequest.ResourceType.WEBSOCKET !== 'websocket';

vAPI.canWASM = vAPI.webextFlavor.soup.has('chromium') === false;
if ( vAPI.canWASM === false ) {
    const csp = manifest.content_security_policy;
    vAPI.canWASM = csp !== undefined && csp.indexOf("'wasm-unsafe-eval'") !== -1;
}

vAPI.supportsUserStylesheets = vAPI.webextFlavor.soup.has('user_stylesheet');

/******************************************************************************/

vAPI.app = {
    name: manifest.name.replace(/ dev\w+ build/, ''),
    version: (( ) => {
        let version = manifest.version;
        const match = /(\d+\.\d+\.\d+)(?:\.(\d+))?/.exec(version);
        if ( match && match[2] ) {
            const v = parseInt(match[2], 10);
            version = match[1] + (v < 100 ? 'b' + v : 'rc' + (v - 100));
        }
        return version;
    })(),

    intFromVersion: function(s) {
        const parts = s.match(/(?:^|\.|b|rc)\d+/g);
        if ( parts === null ) { return 0; }
        let vint = 0;
        for ( let i = 0; i < 4; i++ ) {
            const pstr = parts[i] || '';
            let pint;
            if ( pstr === '' ) {
                pint = 0;
            } else if ( pstr.startsWith('.') || pstr.startsWith('b') ) {
                pint = parseInt(pstr.slice(1), 10);
            } else if ( pstr.startsWith('rc') ) {
                pint = parseInt(pstr.slice(2), 10) + 100;
            } else {
                pint = parseInt(pstr, 10);
            }
            vint = vint * 1000 + pint;
        }
        return vint;
    },

    restart: function() {
        browser.runtime.reload();
    },
};

/******************************************************************************/
/******************************************************************************/

vAPI.storage = {
    get(...args) {
        return webext.storage.local.get(...args).catch(reason => {
            console.log(reason);
        });
    },
    set(...args) {
        return webext.storage.local.set(...args).catch(reason => {
            console.log(reason);
        });
    },
    remove(...args) {
        return webext.storage.local.remove(...args).catch(reason => {
            console.log(reason);
        });
    },
    clear(...args) {
        return webext.storage.local.clear(...args).catch(reason => {
            console.log(reason);
        });
    },
    QUOTA_BYTES: browser.storage.local.QUOTA_BYTES,
};

// Not all platforms support getBytesInUse
if ( webext.storage.local.getBytesInUse instanceof Function ) {
    vAPI.storage.getBytesInUse = function(...args) {
        return webext.storage.local.getBytesInUse(...args).catch(reason => {
            console.log(reason);
        });
    };
}

/******************************************************************************/
/******************************************************************************/

// https://github.com/gorhill/uMatrix/issues/234
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/privacy/network

// https://github.com/gorhill/uBlock/issues/2048
//   Do not mess up with existing settings if not assigning them stricter
//   values.

vAPI.browserSettings = (( ) => {
    // Not all platforms support `browser.privacy`.
    const bp = webext.privacy;
    if ( bp instanceof Object === false ) { return; }

    return {
        // https://github.com/uBlockOrigin/uBlock-issues/issues/1723#issuecomment-919913361
        canLeakLocalIPAddresses:
            vAPI.webextFlavor.soup.has('firefox') &&
            vAPI.webextFlavor.soup.has('mobile'),

        set: function(details) {
            for ( const setting in details ) {
                if ( details.hasOwnProperty(setting) === false ) { continue; }
                switch ( setting ) {
                case 'prefetching':
                    const enabled = !!details[setting];
                    if ( enabled ) {
                        bp.network.networkPredictionEnabled.clear({
                            scope: 'regular',
                        });
                    } else {
                        bp.network.networkPredictionEnabled.set({
                            value: false,
                            scope: 'regular',
                        });
                    }
                    if ( vAPI.prefetching instanceof Function ) {
                        vAPI.prefetching(enabled);
                    }
                    break;

                case 'hyperlinkAuditing':
                    if ( !!details[setting] ) {
                        bp.websites.hyperlinkAuditingEnabled.clear({
                            scope: 'regular',
                        });
                    } else {
                        bp.websites.hyperlinkAuditingEnabled.set({
                            value: false,
                            scope: 'regular',
                        });
                    }
                    break;

                case 'webrtcIPAddress': {
                    // https://github.com/uBlockOrigin/uBlock-issues/issues/1928
                    // https://www.reddit.com/r/uBlockOrigin/comments/sl7p74/
                    //   Hypothetical: some browsers _think_ uBO is still using
                    //   the setting possibly based on cached state from the
                    //   past, and making an explicit API call that uBO is not
                    //   using the setting appears to solve those unexpected
                    //   reported occurrences of uBO interfering despite never
                    //   using the API.
                    const mustEnable = !details[setting];
                    if ( this.canLeakLocalIPAddresses === false ) {
                        if ( mustEnable && vAPI.webextFlavor.soup.has('chromium') ) {
                            bp.network.webRTCIPHandlingPolicy.clear({
                                scope: 'regular',
                            });
                        }
                        continue;
                    }
                    if ( mustEnable ) {
                        bp.network.webRTCIPHandlingPolicy.set({
                            value: 'default_public_interface_only',
                            scope: 'regular'
                        });
                    } else {
                        bp.network.webRTCIPHandlingPolicy.clear({
                            scope: 'regular',
                        });
                    }
                    break;
                }
                default:
                    break;
                }
            }
        }
    };
})();

/******************************************************************************/
/******************************************************************************/

vAPI.isBehindTheSceneTabId = function(tabId) {
    return tabId < 0;
};

vAPI.unsetTabId = 0;
vAPI.noTabId = -1;      // definitely not any existing tab

// To ensure we always use a good tab id
const toTabId = function(tabId) {
    return typeof tabId === 'number' && isNaN(tabId) === false
        ? tabId
        : 0;
};

// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webNavigation
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs

vAPI.Tabs = class {
    constructor() {
        browser.webNavigation.onCreatedNavigationTarget.addListener(details => {
            this.onCreatedNavigationTargetHandler(details);
        });
        browser.webNavigation.onCommitted.addListener(details => {
            const { frameId, tabId } = details;
            if ( frameId === 0 && tabId > 0 && details.transitionType === 'reload' ) {
                if ( vAPI.net && vAPI.net.hasUnprocessedRequest(tabId) ) {
                    vAPI.net.removeUnprocessedRequest(tabId);
                }
            }
            this.onCommittedHandler(details);
        });
        browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
            this.onUpdatedHandler(tabId, changeInfo, tab);
        });
        browser.tabs.onActivated.addListener(details => {
            this.onActivated(details);
        });
        // https://github.com/uBlockOrigin/uBlock-issues/issues/151
        // https://github.com/uBlockOrigin/uBlock-issues/issues/680#issuecomment-515215220
        if ( browser.windows instanceof Object ) {
            browser.windows.onFocusChanged.addListener(windowId => {
                this.onFocusChangedHandler(windowId);
            });
        }
        browser.tabs.onRemoved.addListener((tabId, details) => {
            if ( vAPI.net && vAPI.net.hasUnprocessedRequest(tabId) ) {
                vAPI.net.removeUnprocessedRequest(tabId);
            }
            this.onRemovedHandler(tabId, details);
        });
     }

    async executeScript() {
        let result;
        try {
            result = await webext.tabs.executeScript(...arguments);
        }
        catch(reason) {
        }
        return Array.isArray(result) ? result : [];
    }

    async get(tabId) {
        if ( tabId === null ) {
            return this.getCurrent();
        }
        if ( tabId <= 0 ) { return null; }
        let tab;
        try {
            tab = await webext.tabs.get(tabId);
        }
        catch(reason) {
        }
        return tab instanceof Object ? tab : null;
    }

    async getCurrent() {
        const tabs = await this.query({ active: true, currentWindow: true });
        return tabs.length !== 0 ? tabs[0] : null;
    }

    async insertCSS(tabId, details) {
        if ( vAPI.supportsUserStylesheets ) {
            details.cssOrigin = 'user';
        }
        try {
            await webext.tabs.insertCSS(...arguments);
        }
        catch(reason) {
        }
    }

    async query(queryInfo) {
        let tabs;
        try {
            tabs = await webext.tabs.query(queryInfo);
        }
        catch(reason) {
        }
        return Array.isArray(tabs) ? tabs : [];
    }

    async removeCSS(tabId, details) {
        if ( vAPI.supportsUserStylesheets ) {
            details.cssOrigin = 'user';
        }
        try {
            await webext.tabs.removeCSS(...arguments);
        }
        catch(reason) {
        }
    }

    // Properties of the details object:
    // - url: 'URL',    => the address that will be opened
    // - index: -1,     => undefined: end of the list, -1: following tab,
    //                     or after index
    // - active: false, => opens the tab... in background: true,
    //                     foreground: undefined
    // - popup: 'popup' => open in a new window

    async create(url, details) {
        if ( details.active === undefined ) {
            details.active = true;
        }

        const subWrapper = async ( ) => {
            const updateDetails = {
                url: url,
                active: !!details.active
            };

            // Opening a tab from incognito window won't focus the window
            // in which the tab was opened
            const focusWindow = tab => {
                if ( tab.active && vAPI.windows instanceof Object ) {
                    vAPI.windows.update(tab.windowId, { focused: true });
                }
            };

            if ( !details.tabId ) {
                if ( details.index !== undefined ) {
                    updateDetails.index = details.index;
                }
                browser.tabs.create(updateDetails, focusWindow);
                return;
            }

            // update doesn't accept index, must use move
            const tab = await vAPI.tabs.update(
                toTabId(details.tabId),
                updateDetails
            );
            // if the tab doesn't exist
            if ( tab === null ) {
                browser.tabs.create(updateDetails, focusWindow);
            } else if ( details.index !== undefined ) {
                browser.tabs.move(tab.id, { index: details.index });
            }
        };

        // Open in a standalone window
        //
        // https://github.com/uBlockOrigin/uBlock-issues/issues/168#issuecomment-413038191
        //   Not all platforms support vAPI.windows.
        //
        // For some reasons, some platforms do not honor the left,top
        // position when specified. I found that further calling
        // windows.update again with the same position _may_ help.
        if ( details.popup !== undefined && vAPI.windows instanceof Object ) {
            const createDetails = {
                url: details.url,
                type: details.popup,
            };
            if ( details.box instanceof Object ) {
                Object.assign(createDetails, details.box);
            }
            const win = await vAPI.windows.create(createDetails);
            if ( win === null ) { return; }
            if ( details.box instanceof Object === false ) { return; }
            if (
                win.left === details.box.left &&
                win.top === details.box.top
            ) {
                return;
            }
            vAPI.windows.update(win.id, {
                left: details.box.left,
                top: details.box.top
            });
            return;
        }

        if ( details.index !== -1 ) {
            subWrapper();
            return;
        }

        const tab = await vAPI.tabs.getCurrent();
        if ( tab !== null ) {
            details.index = tab.index + 1;
        } else {
            details.index = undefined;
        }
        subWrapper();
    }

    // Properties of the details object:
    // - url: 'URL',    => the address that will be opened
    // - tabId: 1,      => the tab is used if set, instead of creating a new one
    // - index: -1,     => undefined: end of the list, -1: following tab, or
    //                     after index
    // - active: false, => opens the tab in background - true and undefined:
    //                     foreground
    // - select: true,  => if a tab is already opened with that url, then select
    //                     it instead of opening a new one
    // - popup: true    => open in a new window

    async open(details) {
        let targetURL = details.url;
        if ( typeof targetURL !== 'string' || targetURL === '' ) {
            return null;
        }

        // extension pages
        if ( /^[\w-]{2,}:/.test(targetURL) !== true ) {
            targetURL = vAPI.getURL(targetURL);
        }

        if ( !details.select ) {
            this.create(targetURL, details);
            return;
        }

        // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/query#Parameters
        //   "Note that fragment identifiers are not matched."
        //   Fragment identifiers ARE matched -- we need to remove the fragment.
        const pos = targetURL.indexOf('#');
        const targetURLWithoutHash = pos === -1
            ? targetURL
            : targetURL.slice(0, pos);

        const tabs = await vAPI.tabs.query({ url: targetURLWithoutHash });
        if ( tabs.length === 0 ) {
            this.create(targetURL, details);
            return;
        }
        let tab = tabs[0];
        const updateDetails = { active: true };
        // https://github.com/uBlockOrigin/uBlock-issues/issues/592
        if ( tab.url.startsWith(targetURL) === false ) {
            updateDetails.url = targetURL;
        }
        tab = await vAPI.tabs.update(tab.id, updateDetails);
        if ( vAPI.windows instanceof Object === false ) { return; }
        vAPI.windows.update(tab.windowId, { focused: true });
    }

    async update() {
        let tab;
        try {
            tab = await webext.tabs.update(...arguments);
        }
        catch (reason) {
        }
        return tab instanceof Object ? tab : null;
    }

    // Replace the URL of a tab. Noop if the tab does not exist.
    replace(tabId, url) {
        tabId = toTabId(tabId);
        if ( tabId === 0 ) { return; }

        let targetURL = url;

        // extension pages
        if ( /^[\w-]{2,}:/.test(targetURL) !== true ) {
            targetURL = vAPI.getURL(targetURL);
        }

        vAPI.tabs.update(tabId, { url: targetURL });
    }

    async remove(tabId) {
        tabId = toTabId(tabId);
        if ( tabId === 0 ) { return; }
        try {
            await webext.tabs.remove(tabId);
        }
        catch (reason) {
        }
    }

    async reload(tabId, bypassCache = false) {
        tabId = toTabId(tabId);
        if ( tabId === 0 ) { return; }
        try {
            await webext.tabs.reload(
                tabId,
                { bypassCache: bypassCache === true }
            );
        }
        catch (reason) {
        }
    }

    async select(tabId) {
        tabId = toTabId(tabId);
        if ( tabId === 0 ) { return; }
        const tab = await vAPI.tabs.update(tabId, { active: true });
        if ( tab === null ) { return; }
        if ( vAPI.windows instanceof Object === false ) { return; }
        vAPI.windows.update(tab.windowId, { focused: true });
    }

    // https://forums.lanik.us/viewtopic.php?f=62&t=32826
    //   Chromium-based browsers: sanitize target URL. I've seen data: URI with
    //   newline characters in standard fields, possibly as a way of evading
    //   filters. As per spec, there should be no whitespaces in a data: URI's
    //   standard fields.

    sanitizeURL(url) {
        if ( url.startsWith('data:') === false ) { return url; }
        const pos = url.indexOf(',');
        if ( pos === -1 ) { return url; }
        const s = url.slice(0, pos);
        if ( s.search(/\s/) === -1 ) { return url; }
        return s.replace(/\s+/, '') + url.slice(pos);
    }

    onCreatedNavigationTargetHandler(details) {
        if ( typeof details.url !== 'string' ) {
            details.url = '';
        }
        if ( /^https?:\/\//.test(details.url) === false ) {
            details.frameId = 0;
            details.url = this.sanitizeURL(details.url);
            this.onNavigation(details);
        }
        this.onCreated(details);
    }

    onCommittedHandler(details) {
        details.url = this.sanitizeURL(details.url);
        this.onNavigation(details);
    }

    onUpdatedHandler(tabId, changeInfo, tab) {
        // Ignore uninteresting update events
        const { status = '', title = '', url = '' } = changeInfo;
        if ( status === '' && title === '' && url === '' ) { return; }
        // https://github.com/gorhill/uBlock/issues/3073
        //   Fall back to `tab.url` when `changeInfo.url` is not set.
        if ( url === '' ) {
            changeInfo.url = tab && tab.url;
        }
        if ( changeInfo.url ) {
            changeInfo.url = this.sanitizeURL(changeInfo.url);
        }
        this.onUpdated(tabId, changeInfo, tab);
    }

    onRemovedHandler(tabId, details) {
        this.onClosed(tabId, details);
    }

    onFocusChangedHandler(windowId) {
        if ( windowId === browser.windows.WINDOW_ID_NONE ) { return; }
        vAPI.tabs.query({ active: true, windowId }).then(tabs => {
            if ( tabs.length === 0 ) { return; }
            const tab = tabs[0];
            this.onActivated({ tabId: tab.id, windowId: tab.windowId });
        });
    }

    onActivated(/* details */) {
    }

    onClosed(/* tabId, details */) {
    }

    onCreated(/* details */) {
    }

    onNavigation(/* details */) {
    }

    onUpdated(/* tabId, changeInfo, tab */) {
    }
};

/******************************************************************************/
/******************************************************************************/

if ( webext.windows instanceof Object ) {
    vAPI.windows = {
        get: async function() {
            let win;
            try {
                win = await webext.windows.get(...arguments);
            }
            catch (reason) {
            }
            return win instanceof Object ? win : null;
        },
        create: async function() {
            let win;
            try {
                win = await webext.windows.create(...arguments);
            }
            catch (reason) {
            }
            return win instanceof Object ? win : null;
        },
        update: async function() {
            let win;
            try {
                win = await webext.windows.update(...arguments);
            }
            catch (reason) {
            }
            return win instanceof Object ? win : null;
        },
    };
}

/******************************************************************************/
/******************************************************************************/

if ( webext.browserAction instanceof Object ) {
    vAPI.browserAction = {
        setTitle: async function() {
            try {
                await webext.browserAction.setTitle(...arguments);
            }
            catch (reason) {
            }
        },
    };
    // Not supported on Firefox for Android
    if ( webext.browserAction.setIcon ) {
        vAPI.browserAction.setBadgeTextColor = async function() {
            try {
                await webext.browserAction.setBadgeTextColor(...arguments);
            }
            catch (reason) {
            }
        };
        vAPI.browserAction.setBadgeBackgroundColor = async function() {
            try {
                await webext.browserAction.setBadgeBackgroundColor(...arguments);
            }
            catch (reason) {
            }
        };
        vAPI.browserAction.setBadgeText = async function() {
            try {
                await webext.browserAction.setBadgeText(...arguments);
            }
            catch (reason) {
            }
        };
        vAPI.browserAction.setIcon = async function() {
            try {
                await webext.browserAction.setIcon(...arguments);
            }
            catch (reason) {
            }
        };
    }
}

/******************************************************************************/
/******************************************************************************/

// Must read: https://code.google.com/p/chromium/issues/detail?id=410868#c8

// https://github.com/chrisaljoudi/uBlock/issues/19
// https://github.com/chrisaljoudi/uBlock/issues/207
// Since we may be called asynchronously, the tab id may not exist
// anymore, so this ensures it does still exist.

// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/browserAction#Browser_compatibility
//   Firefox for Android does no support browser.browserAction.setIcon().
//   Performance: use ImageData for platforms supporting it.

// https://github.com/uBlockOrigin/uBlock-issues/issues/32
//   Ensure ImageData for toolbar icon is valid before use.

{
    const browserAction = vAPI.browserAction;
    const titleTemplate = `${browser.runtime.getManifest().browser_action.default_title} ({badge})`;
    const icons = [
        { path: {
            '16': 'img/icon_16-off.png',
            '32': 'img/icon_32-off.png',
            '64': 'img/icon_64-off.png',
        } },
        { path: {
            '16': 'img/icon_16.png',
            '32': 'img/icon_32.png',
            '64': 'img/icon_64.png',
        } },
    ];

    (( ) => {
        if ( browserAction.setIcon === undefined ) { return; }

        // The global badge text and background color.
        if ( browserAction.setBadgeBackgroundColor !== undefined ) {
            browserAction.setBadgeBackgroundColor({ color: '#666666' });
        }
        if ( browserAction.setBadgeTextColor !== undefined ) {
            browserAction.setBadgeTextColor({ color: '#FFFFFF' });
        }

        // As of 2018-05, benchmarks show that only Chromium benefits for sure
        // from using ImageData.
        //
        // Chromium creates a new ImageData instance every call to setIcon
        // with paths:
        // https://cs.chromium.org/chromium/src/extensions/renderer/resources/set_icon.js?l=56&rcl=99be185c25738437ecfa0dafba72a26114196631
        //
        // Firefox uses an internal cache for each setIcon's paths:
        // https://searchfox.org/mozilla-central/rev/5ff2d7683078c96e4b11b8a13674daded935aa44/browser/components/extensions/parent/ext-browserAction.js#631
        if ( vAPI.webextFlavor.soup.has('chromium') === false ) { return; }

        const imgs = [];
        for ( let i = 0; i < icons.length; i++ ) {
            for ( const key of Object.keys(icons[i].path) ) {
                if ( parseInt(key, 10) >= 64 ) { continue; }
                imgs.push({ i: i, p: key, cached: false });
            }
        }

        // https://github.com/uBlockOrigin/uBlock-issues/issues/296
        const safeGetImageData = function(ctx, w, h) {
            let data;
            try {
                data = ctx.getImageData(0, 0, w, h);
            } catch(ex) {
            }
            return data;
        };

        const onLoaded = function() {
            for ( const img of imgs ) {
                if ( img.r.complete === false ) { return; }
            }
            const ctx = document.createElement('canvas')
                .getContext('2d', { willReadFrequently: true });
            const iconData = [ null, null ];
            for ( const img of imgs ) {
                if ( img.cached ) { continue; }
                const w = img.r.naturalWidth, h = img.r.naturalHeight;
                ctx.width = w; ctx.height = h;
                ctx.clearRect(0, 0, w, h);
                ctx.drawImage(img.r, 0, 0);
                if ( iconData[img.i] === null ) { iconData[img.i] = {}; }
                const imgData = safeGetImageData(ctx, w, h);
                if (
                    imgData instanceof Object === false ||
                    imgData.data instanceof Uint8ClampedArray === false ||
                    imgData.data[0] !== 0 ||
                    imgData.data[1] !== 0 ||
                    imgData.data[2] !== 0 ||
                    imgData.data[3] !== 0
                ) {
                    return;
                }
                iconData[img.i][img.p] = imgData;
                img.cached = true;
            }
            for ( let i = 0; i < iconData.length; i++ ) {
                if ( iconData[i] ) {
                    icons[i] = { imageData: iconData[i] };
                }
            }
        };
        for ( const img of imgs ) {
            img.r = new Image();
            img.r.addEventListener('load', onLoaded, { once: true });
            img.r.src = icons[img.i].path[img.p];
        }
    })();

    // parts: bit 0 = icon
    //        bit 1 = badge text
    //        bit 2 = badge color
    //        bit 3 = hide badge

    vAPI.setIcon = async function(tabId, details) {
        tabId = toTabId(tabId);
        if ( tabId === 0 ) { return; }

        const tab = await vAPI.tabs.get(tabId);
        if ( tab === null ) { return; }

        const hasUnprocessedRequest = vAPI.net && vAPI.net.hasUnprocessedRequest(tabId);
        const { parts, state } = details;
        const { badge, color } = hasUnprocessedRequest
                ? { badge: '!', color: '#FC0' }
                : details;

        if ( browserAction.setIcon !== undefined ) {
            if ( parts === undefined || (parts & 0b0001) !== 0 ) {
                browserAction.setIcon(
                    Object.assign({ tabId: tab.id }, icons[state])
                );
            }
            if ( (parts & 0b0010) !== 0 ) {
                browserAction.setBadgeText({
                    tabId: tab.id,
                    text: (parts & 0b1000) === 0 ? badge : ''
                });
            }
            if ( (parts & 0b0100) !== 0 ) {
                browserAction.setBadgeBackgroundColor({ tabId: tab.id, color });
            }
        }

        // Insert the badge text in the title if:
        // - the platform does not support browserAction.setIcon(); OR
        // - the rendering of the badge is disabled
        if ( browserAction.setTitle !== undefined ) {
            const title = titleTemplate.replace('{badge}',
                state === 1 ? (badge !== '' ? badge : '0') : 'off'
            );
            browserAction.setTitle({ tabId: tab.id, title });
        }

        if ( vAPI.contextMenu instanceof Object ) {
            vAPI.contextMenu.onMustUpdate(tabId);
        }
    };

    vAPI.setDefaultIcon = function(flavor, text) {
        if ( browserAction.setIcon === undefined ) { return; }
        browserAction.setIcon({
            path: {
                '16': `img/icon_16${flavor}.png`,
                '32': `img/icon_32${flavor}.png`,
                '64': `img/icon_64${flavor}.png`,
            }
        });
        browserAction.setBadgeText({ text });
        browserAction.setBadgeBackgroundColor({
            color: text === '!' ? '#FC0' : '#666'
        });
    };
}

browser.browserAction.onClicked.addListener(function(tab) {
    vAPI.tabs.open({
        select: true,
        url: `popup-fenix.html?tabId=${tab.id}&intab=1`,
    });
});

/******************************************************************************/
/******************************************************************************/

// https://github.com/uBlockOrigin/uBlock-issues/issues/710
//   uBO uses only ports to communicate with its auxiliary pages and
//   content scripts. Whether a message can trigger a privileged operation is
//   decided based on whether the port from which a message is received is
//   privileged, which is a status evaluated once, at port connection time.
//
// https://github.com/uBlockOrigin/uBlock-issues/issues/1992
//   If present, use MessageSender.origin to determine whether the port is
//   from a privileged page, otherwise use MessageSender.url.
//   MessageSender.origin is more reliable as it is not spoofable by a
//   compromised renderer.

vAPI.messaging = {
    ports: new Map(),
    listeners: new Map(),
    defaultHandler: null,
    PRIVILEGED_ORIGIN: vAPI.getURL('').slice(0, -1),
    NOOPFUNC: function(){},
    UNHANDLED: 'vAPI.messaging.notHandled',

    listen: function(details) {
        this.listeners.set(details.name, {
            fn: details.listener,
            privileged: details.privileged === true
        });
    },

    onPortDisconnect: function(port) {
        this.ports.delete(port.name);
    },

    // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/Port
    //   port.sender is always present for onConnect() listeners.
    onPortConnect: function(port) {
        port.onDisconnect.addListener(port =>
            this.onPortDisconnect(port)
        );
        port.onMessage.addListener((request, port) =>
            this.onPortMessage(request, port)
        );
        const portDetails = { port };
        const sender = port.sender;
        const { origin, tab, url } = sender;
        portDetails.frameId = sender.frameId;
        portDetails.frameURL = url;
        portDetails.privileged = origin !== undefined
            ? origin === this.PRIVILEGED_ORIGIN
            : url.startsWith(this.PRIVILEGED_ORIGIN);
        if ( tab ) {
            portDetails.tabId = tab.id;
            portDetails.tabURL = tab.url;
        }
        this.ports.set(port.name, portDetails);
        // https://bugzilla.mozilla.org/show_bug.cgi?id=1652925#c24
        port.sender = undefined;
    },

    setup: function(defaultHandler) {
        if ( this.defaultHandler !== null ) { return; }

        if ( typeof defaultHandler !== 'function' ) {
            defaultHandler = function() {
                return this.UNHANDLED;
            };
        }
        this.defaultHandler = defaultHandler;

        browser.runtime.onConnect.addListener(
            port => this.onPortConnect(port)
        );

        // https://bugzilla.mozilla.org/show_bug.cgi?id=1392067
        //   Workaround: manually remove ports matching removed tab.
        if (
            vAPI.webextFlavor.soup.has('firefox') &&
            vAPI.webextFlavor.major < 61
        ) {
            browser.tabs.onRemoved.addListener(tabId => {
                for ( const { port, tabId: portTabId } of this.ports.values() ) {
                    if ( portTabId !== tabId ) { continue; }
                    this.onPortDisconnect(port);
                }
            });
        }
    },

    broadcast: function(message) {
        const messageWrapper = { broadcast: true, msg: message };
        for ( const { port } of this.ports.values() ) {
            try {
                port.postMessage(messageWrapper);
            } catch(ex) {
                this.onPortDisconnect(port);
            }
        }
        if ( this.defaultHandler ) {
            this.defaultHandler(message, null, ( ) => { });
        }
    },

    onFrameworkMessage: function(request, port, callback) {
        const portDetails = this.ports.get(port.name) || {};
        const tabId = portDetails.tabId;
        const msg = request.msg;
        switch ( msg.what ) {
        case 'connectionAccepted':
        case 'connectionRefused': {
            const toPort = this.ports.get(msg.fromToken);
            if ( toPort !== undefined ) {
                msg.tabId = tabId;
                toPort.port.postMessage(request);
            } else {
                msg.what = 'connectionBroken';
                port.postMessage(request);
            }
            break;
        }
        case 'connectionRequested':
            msg.tabId = tabId;
            for ( const { port: toPort } of this.ports.values() ) {
                if ( toPort === port ) { continue; }
                try {
                    toPort.postMessage(request);
                } catch (ex) {
                    this.onPortDisconnect(toPort);
                }
            }
            break;
        case 'connectionBroken':
        case 'connectionCheck':
        case 'connectionMessage': {
            const toPort = this.ports.get(
                port.name === msg.fromToken ? msg.toToken : msg.fromToken
            );
            if ( toPort !== undefined ) {
                msg.tabId = tabId;
                toPort.port.postMessage(request);
            } else {
                msg.what = 'connectionBroken';
                port.postMessage(request);
            }
            break;
        }
        case 'extendClient':
            vAPI.tabs.executeScript(tabId, {
                file: '/js/vapi-client-extra.js',
                frameId: portDetails.frameId,
            }).then(( ) => {
                callback();
            });
            break;
        case 'localStorage': {
            if ( portDetails.privileged !== true ) { break; }
            const args = msg.args || [];
            vAPI.localStorage[msg.fn](...args).then(result => {
                callback(result);
            });
            break;
        }
        case 'userCSS':
            if ( tabId === undefined ) { break; }
            const promises = [];
            if ( msg.add ) {
                const details = {
                    code: undefined,
                    frameId: portDetails.frameId,
                    matchAboutBlank: true,
                    runAt: 'document_start',
                };
                for ( const cssText of msg.add ) {
                    details.code = cssText;
                    promises.push(vAPI.tabs.insertCSS(tabId, details));
                }
            }
            if ( msg.remove ) {
                const details = {
                    code: undefined,
                    frameId: portDetails.frameId,
                    matchAboutBlank: true,
                };
                for ( const cssText of msg.remove ) {
                    details.code = cssText;
                    promises.push(vAPI.tabs.removeCSS(tabId, details));
                }
            }
            Promise.all(promises).then(( ) => {
                callback();
            });
            break;
        }
    },

    // Use a wrapper to avoid closure and to allow reuse.
    CallbackWrapper: class {
        constructor(messaging, port, msgId) {
            this.messaging = messaging;
            this.callback = this.proxy.bind(this); // bind once
            this.init(port, msgId);
        }
        init(port, msgId) {
            this.port = port;
            this.msgId = msgId;
            return this;
        }
        proxy(response) {
            // https://github.com/chrisaljoudi/uBlock/issues/383
            try {
                this.port.postMessage({
                    msgId: this.msgId,
                    msg: response !== undefined ? response : null,
                });
            } catch (ex) {
                this.messaging.onPortDisconnect(this.port);
            }
            // Store for reuse
            this.port = null;
            this.messaging.callbackWrapperJunkyard.push(this);
        }
    },

    callbackWrapperJunkyard: [],

    callbackWrapperFactory: function(port, msgId) {
        return this.callbackWrapperJunkyard.length !== 0
            ? this.callbackWrapperJunkyard.pop().init(port, msgId)
            : new this.CallbackWrapper(this, port, msgId);
    },

    onPortMessage: function(request, port) {
        // prepare response
        let callback = this.NOOPFUNC;
        if ( request.msgId !== undefined ) {
            callback = this.callbackWrapperFactory(port, request.msgId).callback;
        }

        // Content process to main process: framework handler.
        if ( request.channel === 'vapi' ) {
            this.onFrameworkMessage(request, port, callback);
            return;
        }

        // Auxiliary process to main process: specific handler
        const portDetails = this.ports.get(port.name);
        if ( portDetails === undefined ) { return; }

        const listenerDetails = this.listeners.get(request.channel);
        let r = this.UNHANDLED;
        if (
            (listenerDetails !== undefined) &&
            (listenerDetails.privileged === false || portDetails.privileged)
            
        ) {
            r = listenerDetails.fn(request.msg, portDetails, callback);
        }
        if ( r !== this.UNHANDLED ) { return; }

        // Auxiliary process to main process: default handler
        if ( portDetails.privileged ) {
            r = this.defaultHandler(request.msg, portDetails, callback);
            if ( r !== this.UNHANDLED ) { return; }
        }

        // Auxiliary process to main process: no handler
        ubolog(
            `vAPI.messaging.onPortMessage > unhandled request: ${JSON.stringify(request.msg)}`,
            request
        );

        // Need to callback anyways in case caller expected an answer, or
        // else there is a memory leak on caller's side
        callback();
    },
};

/******************************************************************************/
/******************************************************************************/

// https://github.com/gorhill/uBlock/issues/3474
// https://github.com/gorhill/uBlock/issues/2823
//   Foil ability of web pages to identify uBO through
//   its web accessible resources.
// https://github.com/gorhill/uBlock/issues/3497
//   Prevent web pages from interfering with uBO's element picker
// https://github.com/uBlockOrigin/uBlock-issues/issues/550
//   Support using a new secret for every network request.

{
    // Generate a 6-character alphanumeric string, thus one random value out
    // of 36^6 = over 2x10^9 values.
    const generateSecret = ( ) =>
        (Math.floor(Math.random() * 2176782336) + 2176782336).toString(36).slice(1);

    const root = vAPI.getURL('/');
    const reSecret = /\?secret=(\w+)/;
    const shortSecrets = [];
    let lastShortSecretTime = 0;

    // Long secrets are meant to be used multiple times, but for at most a few
    // minutes. The realm is one value out of 36^18 = over 10^28 values.
    const longSecrets = [ '', '' ];
    let lastLongSecretTimeSlice = 0;

    const guard = details => {
        const match = reSecret.exec(details.url);
        if ( match === null ) { return { cancel: true }; }
        const secret = match[1];
        if ( longSecrets.includes(secret) ) { return; }
        const pos = shortSecrets.indexOf(secret);
        if ( pos === -1 ) { return { cancel: true }; }
        shortSecrets.splice(pos, 1);
    };

    browser.webRequest.onBeforeRequest.addListener(
        guard,
        {
            urls: [ root + 'web_accessible_resources/*' ]
        },
        [ 'blocking' ]
    );

    vAPI.warSecret = {
        short: ( ) => {
            if ( shortSecrets.length !== 0 ) {
                if ( (Date.now() - lastShortSecretTime) > 5000 ) {
                    shortSecrets.splice(0);
                } else if ( shortSecrets.length > 256 ) {
                    shortSecrets.splice(0, shortSecrets.length - 192);
                }
            }
            lastShortSecretTime = Date.now();
            const secret = generateSecret();
            shortSecrets.push(secret);
            return secret;
        },
        long: ( ) => {
            const timeSlice = Date.now() >>> 19; // Changes every ~9 minutes
            if ( timeSlice !== lastLongSecretTimeSlice ) {
                longSecrets[1] = longSecrets[0];
                longSecrets[0] = `${generateSecret()}${generateSecret()}${generateSecret()}`;
                lastLongSecretTimeSlice = timeSlice;
            }
            return longSecrets[0];
        },
    };
}

/******************************************************************************/

vAPI.Net = class {
    constructor() {
        this.validTypes = new Set();
        {
            const wrrt = browser.webRequest.ResourceType;
            for ( const typeKey in wrrt ) {
                if ( wrrt.hasOwnProperty(typeKey) ) {
                    this.validTypes.add(wrrt[typeKey]);
                }
            }
        }
        this.suspendableListener = undefined;
        this.deferredSuspendableListener = undefined;
        this.listenerMap = new WeakMap();
        this.suspendDepth = 0;
        this.unprocessedTabs = new Map();

        browser.webRequest.onBeforeRequest.addListener(
            details => {
                this.normalizeDetails(details);
                if ( this.suspendDepth !== 0 && details.tabId >= 0 ) {
                    return this.suspendOneRequest(details);
                }
                return this.onBeforeSuspendableRequest(details);
            },
            this.denormalizeFilters({ urls: [ 'http://*/*', 'https://*/*' ] }),
            [ 'blocking' ]
        );

        vAPI.setDefaultIcon('-loading', '');
    }
    setOptions(/* options */) {
    }
    normalizeDetails(/* details */) {
    }
    denormalizeFilters(filters) {
        const urls = filters.urls || [ '<all_urls>' ];
        let types = filters.types;
        if ( Array.isArray(types) ) {
            types = this.denormalizeTypes(types);
        }
        if (
            (this.validTypes.has('websocket')) &&
            (types === undefined || types.indexOf('websocket') !== -1) &&
            (urls.indexOf('<all_urls>') === -1)
        ) {
            if ( urls.indexOf('ws://*/*') === -1 ) {
                urls.push('ws://*/*');
            }
            if ( urls.indexOf('wss://*/*') === -1 ) {
                urls.push('wss://*/*');
            }
        }
        return { types, urls };
    }
    denormalizeTypes(types) {
        return types;
    }
    canonicalNameFromHostname(/* hn */) {
    }
    addListener(which, clientListener, filters, options) {
        const actualFilters = this.denormalizeFilters(filters);
        const actualListener = this.makeNewListenerProxy(clientListener);
        browser.webRequest[which].addListener(
            actualListener,
            actualFilters,
            options
        );
    }
    onBeforeSuspendableRequest(details) {
        if ( this.suspendableListener !== undefined ) {
            return this.suspendableListener(details);
        }
        this.onUnprocessedRequest(details);
    }
    setSuspendableListener(listener) {
        for ( const [ tabId, requests ] of this.unprocessedTabs ) {
            let i = requests.length;
            while ( i-- ) {
                const r = listener(requests[i]);
                if ( r === undefined || r.cancel !== true ) {
                    requests.splice(i, 1);
                }
            }
            if ( requests.length !== 0 ) { continue; }
            this.unprocessedTabs.delete(tabId);
        }
        if ( this.unprocessedTabs.size !== 0 ) {
            this.deferredSuspendableListener = listener;
            listener = details => {
                const { tabId, type  } = details;
                if ( type === 'main_frame' && this.unprocessedTabs.has(tabId) ) {
                    if ( this.removeUnprocessedRequest(tabId) ) {
                        return this.suspendableListener(details);
                    }
                }
                return this.deferredSuspendableListener(details);
            };
        }
        this.suspendableListener = listener;
        vAPI.setDefaultIcon('', '');
    }
    removeListener(which, clientListener) {
        const actualListener = this.listenerMap.get(clientListener);
        if ( actualListener === undefined ) { return; }
        this.listenerMap.delete(clientListener);
        browser.webRequest[which].removeListener(actualListener);
    }
    makeNewListenerProxy(clientListener) {
        const actualListener = details => {
            this.normalizeDetails(details);
            return clientListener(details);
        };
        this.listenerMap.set(clientListener, actualListener);
        return actualListener;
    }
    handlerBehaviorChanged() {
        browser.webRequest.handlerBehaviorChanged();
    }
    onUnprocessedRequest(details) {
        const { tabId } = details;
        if ( tabId === -1 ) { return; }
        if ( this.unprocessedTabs.size === 0 ) {
            vAPI.setDefaultIcon('-loading', '!');
        }
        let requests = this.unprocessedTabs.get(tabId);
        if ( requests === undefined ) {
            this.unprocessedTabs.set(tabId, (requests = []));
        }
        requests.push(Object.assign({}, details));
    }
    hasUnprocessedRequest(tabId) {
        if ( this.unprocessedTabs.size === 0 ) { return false; }
        if ( tabId === undefined ) { return true; }
        return this.unprocessedTabs.has(tabId);
    }
    removeUnprocessedRequest(tabId) {
        if ( this.deferredSuspendableListener === undefined ) {
            this.unprocessedTabs.clear();
            return true;
        }
        if ( tabId !== undefined ) {
            this.unprocessedTabs.delete(tabId);
        } else {
            this.unprocessedTabs.clear();
        }
        if ( this.unprocessedTabs.size !== 0 ) { return false; }
        this.suspendableListener = this.deferredSuspendableListener;
        this.deferredSuspendableListener = undefined;
        return true;
    }
    suspendOneRequest() {
    }
    unsuspendAllRequests() {
    }
    suspend() {
        this.suspendDepth += 1;
    }
    unsuspend({ all = false, discard = false } = {}) {
        if ( this.suspendDepth === 0 ) { return; }
        if ( all ) {
            this.suspendDepth = 0;
        } else {
            this.suspendDepth -= 1;
        }
        if ( this.suspendDepth !== 0 ) { return; }
        this.unsuspendAllRequests(discard);
    }
    static canSuspend() {
        return false;
    }
};

/******************************************************************************/
/******************************************************************************/

// To be defined by platform-specific code.

vAPI.scriptletsInjector = (( ) => {
    self.uBO_scriptletsInjected = '';
}).toString();

/******************************************************************************/
/******************************************************************************/

// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/contextMenus#Browser_compatibility
//   Firefox for Android does no support browser.contextMenus.

vAPI.contextMenu = webext.menus && {
    _callback: null,
    _hash: '',
    onMustUpdate: function() {},
    setEntries: function(entries, callback) {
        entries = entries || [];
        const hash = entries.map(v => v.id).join();
        if ( hash === this._hash ) { return; }
        this._hash = hash;
        webext.menus.removeAll();
        for ( const entry of entries ) {
            webext.menus.create(JSON.parse(JSON.stringify(entry)));
        }
        const n = entries.length;
        callback = callback || null;
        if ( callback === this._callback ) { return; }
        if ( n !== 0 && callback !== null ) {
            webext.menus.onClicked.addListener(callback);
            this._callback = callback;
        } else if ( n === 0 && this._callback !== null ) {
            webext.menus.onClicked.removeListener(this._callback);
            this._callback = null;
        }
    }
};

/******************************************************************************/
/******************************************************************************/

vAPI.commands = browser.commands;

/******************************************************************************/
/******************************************************************************/

// https://github.com/gorhill/uBlock/issues/531
//   Storage area dedicated to admin settings. Read-only.

// https://github.com/gorhill/uBlock/commit/43a5ed735b95a575a9339b6e71a1fcb27a99663b#commitcomment-13965030
// Not all Chromium-based browsers support managed storage. Merely testing or
// exception handling in this case does NOT work: I don't know why. The
// extension on Opera ends up in a non-sensical state, whereas vAPI become
// undefined out of nowhere. So only solution left is to test explicitly for
// Opera.
// https://github.com/gorhill/uBlock/issues/900
// Also, UC Browser: http://www.upsieutoc.com/image/WXuH

// https://github.com/uBlockOrigin/uAssets/discussions/16939
//   Use a cached version of admin settings, such that there is no blocking
//   call on `storage.managed`. The side effect is that any changes to admin
//   settings will require an extra extension restart to take effect.

vAPI.adminStorage = (( ) => {
    if ( webext.storage.managed instanceof Object === false ) {
        return {
            get: function() {
                return Promise.resolve();
            },
        };
    }
    const cacheManagedStorage = async ( ) => {
        let store;
        try {
            store = await webext.storage.managed.get();
        } catch(ex) {
        }
        webext.storage.local.set({ cachedManagedStorage: store || {} });
    };

    return {
        get: async function(key) {
            let bin;
            try {
                bin = await webext.storage.local.get('cachedManagedStorage') || {};
                if ( Object.keys(bin).length === 0 ) {
                    bin = await webext.storage.managed.get() || {};
                } else {
                    bin = bin.cachedManagedStorage;
                }
            } catch(ex) {
                bin = {};
            }
            cacheManagedStorage();
            if ( key === undefined || key === null ) {
                return bin;
            }
            if ( typeof key === 'string' && bin instanceof Object ) {
                return bin[key];
            }
            const out = {};
            if ( Array.isArray(key) ) {
                for ( const k of key ) {
                    if ( bin[k] === undefined ) { continue; }
                    out[k] = bin[k];
                }
                return out;
            }
            for ( const [ k, v ] of Object.entries(key) ) {
                out[k] = bin[k] !== undefined ? bin[k] : v;
            }
            return out;
        }
    };
})();

/******************************************************************************/
/******************************************************************************/

// A localStorage-like object which should be accessible from the
// background page or auxiliary pages.
//
// https://github.com/uBlockOrigin/uBlock-issues/issues/899
//   Convert into asynchronous access API.
//
// Note: vAPI.localStorage should already be defined with the client-side
//       implementation at this point, but we override with the
//       background-side implementation.
vAPI.localStorage = {
    start: async function() {
        if ( this.cache instanceof Promise ) { return this.cache; }
        if ( this.cache instanceof Object ) { return this.cache; }
        this.cache = webext.storage.local.get('localStorage').then(bin => {
            this.cache = bin instanceof Object &&
                bin.localStorage instanceof Object
                    ? bin.localStorage
                    : {};
        });
        return this.cache;
    },
    clear: function() {
        this.cache = {};
        return webext.storage.local.set({ localStorage: this.cache });
    },
    getItem: function(key) {
        if ( this.cache instanceof Object === false ) {
            console.info(`localStorage.getItem('${key}') not ready`);
            return null;
        }
        const value = this.cache[key];
        return value !== undefined ? value : null;
    },
    getItemAsync: async function(key) {
        await this.start();
        const value = this.cache[key];
        return value !== undefined ? value : null;
    },
    removeItem: async function(key) {
        this.setItem(key);
    },
    setItem: async function(key, value = undefined) {
        await this.start();
        if ( value === this.cache[key] ) { return; }
        this.cache[key] = value;
        return webext.storage.local.set({ localStorage: this.cache });
    },
    cache: undefined,
};

vAPI.localStorage.start();

/******************************************************************************/
/******************************************************************************/

// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/sync

vAPI.cloud = (( ) => {
    // Not all platforms support `webext.storage.sync`.
    if ( webext.storage.sync instanceof Object === false ) { return; }

    // Currently, only Chromium supports the following constants -- these
    // values will be assumed for platforms which do not define them.
    // https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/storage/sync
    //   > You can store up to 100KB of data using this API
    const MAX_ITEMS =
        webext.storage.sync.MAX_ITEMS || 512;
    const QUOTA_BYTES =
        webext.storage.sync.QUOTA_BYTES || 102400;
    const QUOTA_BYTES_PER_ITEM =
        webext.storage.sync.QUOTA_BYTES_PER_ITEM || 8192;

    const chunkCountPerFetch = 16; // Must be a power of 2
    const maxChunkCountPerItem = Math.floor(MAX_ITEMS * 0.75) & ~(chunkCountPerFetch - 1);

    // https://github.com/gorhill/uBlock/issues/3006
    //   For Firefox, we will use a lower ratio to allow for more overhead for
    //   the infrastructure. Unfortunately this leads to less usable space for
    //   actual data, but all of this is provided for free by browser vendors,
    //   so we need to accept and deal with these limitations.
    const evalMaxChunkSize = function() {
        return Math.floor(
            QUOTA_BYTES_PER_ITEM *
            (vAPI.webextFlavor.soup.has('firefox') ? 0.6 : 0.75)
        );
    };

    let maxChunkSize = evalMaxChunkSize();

    // The real actual webextFlavor value may not be set in stone, so listen
    // for possible future changes.
    window.addEventListener('webextFlavor', function() {
        maxChunkSize = evalMaxChunkSize();
    }, { once: true });

    const options = {
        defaultDeviceName: window.navigator.platform,
        deviceName: undefined,
    };

    vAPI.localStorage.getItemAsync('deviceName').then(value => {
        options.deviceName = value;
    });

    // This is used to find out a rough count of how many chunks exists:
    // We "poll" at specific index in order to get a rough idea of how
    // large is the stored string.
    // This allows reading a single item with only 2 sync operations -- a
    // good thing given chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_MINUTE
    // and chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_HOUR.

    const getCoarseChunkCount = async function(datakey) {
        const keys = {};
        for ( let i = 0; i < maxChunkCountPerItem; i += 16 ) {
            keys[datakey + i.toString()] = '';
        }
        let bin;
        try {
            bin = await webext.storage.sync.get(keys);
        } catch (reason) {
            return String(reason);
        }
        let chunkCount = 0;
        for ( let i = 0; i < maxChunkCountPerItem; i += 16 ) {
            if ( bin[datakey + i.toString()] === '' ) { break; }
            chunkCount = i + 16;
        }
        return chunkCount;
    };

    const deleteChunks = async function(datakey, start) {
        const keys = [];

        const n = await getCoarseChunkCount(datakey);
        for ( let i = start; i < n; i++ ) {
            keys.push(datakey + i.toString());
        }
        if ( keys.length !== 0 ) {
            webext.storage.sync.remove(keys);
        }
    };

    const push = async function(details) {
        const { datakey, data, encode } = details;
        if (
            data === undefined ||
            typeof data === 'string' && data === ''
        ) {
            return deleteChunks(datakey, 0);
        }
        const item = {
            source: options.deviceName || options.defaultDeviceName,
            tstamp: Date.now(),
            data,
        };
        const json = JSON.stringify(item);
        const encoded = encode instanceof Function
            ? await encode(json)
            : json;

        // Chunkify taking into account QUOTA_BYTES_PER_ITEM:
        //   https://developer.chrome.com/extensions/storage#property-sync
        //   "The maximum size (in bytes) of each individual item in sync
        //   "storage, as measured by the JSON stringification of its value
        //   "plus its key length."
        const bin = {};
        const chunkCount = Math.ceil(encoded.length / maxChunkSize);
        for ( let i = 0; i < chunkCount; i++ ) {
            bin[datakey + i.toString()]
                = encoded.substr(i * maxChunkSize, maxChunkSize);
        }
        bin[datakey + chunkCount.toString()] = ''; // Sentinel

        // Remove potentially unused trailing chunks before storing the data,
        // this will free storage space which could otherwise cause the push
        // operation to fail.
        try {
            await deleteChunks(datakey, chunkCount + 1);
        } catch (reason) {
        }

        // Push the data to browser-provided cloud storage.
        try {
            await webext.storage.sync.set(bin);
        } catch (reason) {
            return String(reason);
        }
    };

    const pull = async function(details) {
        const { datakey, decode } = details;

        const result = await getCoarseChunkCount(datakey);
        if ( typeof result !== 'number' ) {
            return result;
        }
        const chunkKeys = {};
        for ( let i = 0; i < result; i++ ) {
            chunkKeys[datakey + i.toString()] = '';
        }

        let bin;
        try {
            bin = await webext.storage.sync.get(chunkKeys);
        } catch (reason) {
            return String(reason);
        }

        // Assemble chunks into a single string.
        // https://www.reddit.com/r/uMatrix/comments/8lc9ia/my_rules_tab_hangs_with_cloud_storage_support/
        //   Explicit sentinel is not necessarily present: this can
        //   happen when the number of chunks is a multiple of
        //   chunkCountPerFetch. Hence why we must also test against
        //   undefined.
        let encoded = [];
        let i = 0;
        for (;;) {
            const slice = bin[datakey + i.toString()];
            if ( slice === '' || slice === undefined ) { break; }
            encoded.push(slice);
            i += 1;
        }
        encoded = encoded.join('');
        const json = decode instanceof Function
            ? await decode(encoded)
            : encoded;
        let entry = null;
        try {
            entry = JSON.parse(json);
        } catch(ex) {
        }
        return entry;
    };

    const used = async function(datakey) {
        if ( webext.storage.sync.getBytesInUse instanceof Function === false ) {
            return;
        }
        const coarseCount = await getCoarseChunkCount(datakey);
        if ( typeof coarseCount !== 'number' ) { return; }
        const keys = [];
        for ( let i = 0; i < coarseCount; i++ ) {
            keys.push(`${datakey}${i}`);
        }
        let results;
        try {
            results = await Promise.all([
                webext.storage.sync.getBytesInUse(keys),
                webext.storage.sync.getBytesInUse(null),
            ]);
        } catch(ex) {
        }
        if ( Array.isArray(results) === false ) { return; }
        return { used: results[0], total: results[1], max: QUOTA_BYTES };
    };

    const getOptions = function(callback) {
        if ( typeof callback !== 'function' ) { return; }
        callback(options);
    };

    const setOptions = function(details, callback) {
        if ( typeof details !== 'object' || details === null ) { return; }

        if ( typeof details.deviceName === 'string' ) {
            vAPI.localStorage.setItem('deviceName', details.deviceName);
            options.deviceName = details.deviceName;
        }

        getOptions(callback);
    };

    return { push, pull, used, getOptions, setOptions };
})();

/******************************************************************************/
