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

    uBlock Origin - a browser extension to block requests.
    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
*/

'use strict';

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

µBlock.assets = (( ) => {

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

const reIsExternalPath = /^(?:[a-z-]+):\/\//,
    reIsUserAsset = /^user-/,
    errorCantConnectTo = vAPI.i18n('errorCantConnectTo'),
    noopfunc = function(){};

const api = {};

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

const observers = [];

api.addObserver = function(observer) {
    if ( observers.indexOf(observer) === -1 ) {
        observers.push(observer);
    }
};

api.removeObserver = function(observer) {
    let pos;
    while ( (pos = observers.indexOf(observer)) !== -1 ) {
        observers.splice(pos, 1);
    }
};

const fireNotification = function(topic, details) {
    let result;
    for ( const observer of observers ) {
        const r = observer(topic, details);
        if ( r !== undefined ) { result = r; }
    }
    return result;
};

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

api.fetch = function(url, options = {}) {
    return new Promise((resolve, reject) => {
    // Start of executor

    const timeoutAfter = µBlock.hiddenSettings.assetFetchTimeout * 1000 || 30000;
    const xhr = new XMLHttpRequest();
    let contentLoaded = 0;
    let timeoutTimer;

    const cleanup = function() {
        xhr.removeEventListener('load', onLoadEvent);
        xhr.removeEventListener('error', onErrorEvent);
        xhr.removeEventListener('abort', onErrorEvent);
        xhr.removeEventListener('progress', onProgressEvent);
        if ( timeoutTimer !== undefined ) {
            clearTimeout(timeoutTimer);
            timeoutTimer = undefined;
        }
    };

    // https://github.com/gorhill/uMatrix/issues/15
    const onLoadEvent = function() {
        cleanup();
        // xhr for local files gives status 0, but actually succeeds
        const details = {
            url,
            content: '',
            statusCode: this.status || 200,
            statusText: this.statusText || ''
        };
        if ( details.statusCode < 200 || details.statusCode >= 300 ) {
            return reject(details);
        }
        details.content = this.response;
        resolve(details);
    };

    const onErrorEvent = function() {
        cleanup();
        µBlock.logger.writeOne({
            realm: 'message',
            type: 'error',
            text: errorCantConnectTo.replace('{{msg}}', url)
        });
        reject({ url, content: '' });
    };

    const onTimeout = function() {
        xhr.abort();
    };

    // https://github.com/gorhill/uBlock/issues/2526
    // - Timeout only when there is no progress.
    const onProgressEvent = function(ev) {
        if ( ev.loaded === contentLoaded ) { return; }
        contentLoaded = ev.loaded;
        if ( timeoutTimer !== undefined ) {
            clearTimeout(timeoutTimer); 
        }
        timeoutTimer = vAPI.setTimeout(onTimeout, timeoutAfter);
    };

    // Be ready for thrown exceptions:
    // I am pretty sure it used to work, but now using a URL such as
    // `file:///` on Chromium 40 results in an exception being thrown.
    try {
        xhr.open('get', url, true);
        xhr.addEventListener('load', onLoadEvent);
        xhr.addEventListener('error', onErrorEvent);
        xhr.addEventListener('abort', onErrorEvent);
        xhr.addEventListener('progress', onProgressEvent);
        xhr.responseType = options.responseType || 'text';
        xhr.send();
        timeoutTimer = vAPI.setTimeout(onTimeout, timeoutAfter);
    } catch (e) {
        onErrorEvent.call(xhr);
    }

    // End of executor
    });
};

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

api.fetchText = function(url, onLoad, onError) {
    const isExternal = reIsExternalPath.test(url);
    let actualUrl = isExternal ? url : vAPI.getURL(url);

    // https://github.com/gorhill/uBlock/issues/2592
    //   Force browser cache to be bypassed, but only for resources which have
    //   been fetched more than one hour ago.
    //
    // https://github.com/uBlockOrigin/uBlock-issues/issues/682#issuecomment-515197130
    //   Provide filter list authors a way to completely bypass
    //   the browser cache.
    if ( isExternal ) {
        const cacheBypassToken =
            µBlock.hiddenSettings.updateAssetBypassBrowserCache
                ? Math.floor(Date.now() /    1000) % 86400
                : Math.floor(Date.now() / 3600000) %    12;
        const queryValue = `_=${cacheBypassToken}`;
        if ( actualUrl.indexOf('?') === -1 ) {
            actualUrl += '?';
        } else {
            actualUrl += '&';
        }
        actualUrl += queryValue;
    }

    if ( typeof onError !== 'function' ) {
        onError = onLoad;
    }

    const onResolve = function(details) {
        if ( onLoad instanceof Function ) {
            return onLoad(details);
        }
        return details;
    };

    const onReject = function(details) {
        details.content = '';
        if ( onError instanceof Function ) {
            return onError(details);
        }
        return details;
    };

    return api.fetch(url).then(details => {
        // consider an empty result to be an error
        if ( stringIsNotEmpty(details.content) === false ) {
            return onReject(details);
        }
        // we never download anything else than plain text: discard if response
        // appears to be a HTML document: could happen when server serves
        // some kind of error page I suppose
        const text = details.content.trim();
        if ( text.startsWith('<') && text.endsWith('>') ) {
            return onReject(details);
        }
        return onResolve(details);
    }).catch(details => {
        return onReject(details);
    });
};

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

// https://github.com/gorhill/uBlock/issues/3331
//   Support the seamless loading of sublists.

api.fetchFilterList = function(mainlistURL, onLoad, onError) {
    const content = [];
    const pendingSublistURLs = new Set([ mainlistURL ]);
    const loadedSublistURLs = new Set();
    const toParsedURL = api.fetchFilterList.toParsedURL;

    // https://github.com/NanoAdblocker/NanoCore/issues/239
    //   Anything under URL's root directory is allowed to be fetched. The
    //   URL of a sublist will always be relative to the URL of the parent
    //   list (instead of the URL of the root list).
    const rootDirectoryURL = toParsedURL(mainlistURL);
    if ( rootDirectoryURL !== undefined ) {
        const pos = rootDirectoryURL.pathname.lastIndexOf('/');
        if ( pos !== -1 ) {
            rootDirectoryURL.pathname =
                rootDirectoryURL.pathname.slice(0, pos + 1);
        }
    }

    let errored = false;

    const processIncludeDirectives = function(details) {
        const reInclude = /^!#include +(\S+)/gm;
        const out = [];
        const content = details.content;
        let lastIndex = 0;
        for (;;) {
            const match = reInclude.exec(content);
            if ( match === null ) { break; }
            if ( toParsedURL(match[1]) !== undefined ) { continue; }
            if ( match[1].indexOf('..') !== -1 ) { continue; }
            const subURL = toParsedURL(details.url);
            subURL.pathname = subURL.pathname.replace(/[^/]+$/, match[1]);
            if ( subURL.href.startsWith(rootDirectoryURL.href) === false ) {
                continue;
            }
            if ( pendingSublistURLs.has(subURL.href) ) { continue; }
            if ( loadedSublistURLs.has(subURL.href) ) { continue; }
            pendingSublistURLs.add(subURL.href);
            api.fetchText(subURL.href, onLocalLoadSuccess, onLocalLoadError);
            out.push(content.slice(lastIndex, match.index).trim(), subURL.href);
            lastIndex = reInclude.lastIndex;
        }
        out.push(lastIndex === 0 ? content : content.slice(lastIndex).trim());
        return out;
    };

    const onLocalLoadSuccess = function(details) {
        if ( errored ) { return; }

        const isSublist = details.url !== mainlistURL;

        pendingSublistURLs.delete(details.url);
        loadedSublistURLs.add(details.url);

        // https://github.com/uBlockOrigin/uBlock-issues/issues/329
        //   Insert fetched content at position of related #!include directive
        let slot = isSublist ? content.indexOf(details.url) : 0;
        if ( isSublist ) {
            content.splice(
                slot,
                1,
                '! >>>>>>>> ' + details.url,
                details.content.trim(),
                '! <<<<<<<< ' + details.url
            );
            slot += 1;
        } else {
            content[0] = details.content.trim();
        }

        // Find and process #!include directives
        if (
            rootDirectoryURL !== undefined &&
            rootDirectoryURL.pathname.length > 0
        ) {
            const processed = processIncludeDirectives(details);
            if ( processed.length > 1 ) {
                content.splice(slot, 1, ...processed);
            }
        }

        if ( pendingSublistURLs.size !== 0 ) { return; }

        details.url = mainlistURL;
        details.content = content.join('\n').trim();
        onLoad(details);
    };

    // https://github.com/AdguardTeam/FiltersRegistry/issues/82
    //   Not checking for `errored` status was causing repeated notifications
    //   to the caller. This can happen when more than one out of multiple
    //   sublists can't be fetched.
    const onLocalLoadError = function(details) {
        if ( errored ) { return; }

        errored = true;
        details.url = mainlistURL;
        details.content = '';
        onError(details);
    };

    this.fetchText(mainlistURL, onLocalLoadSuccess, onLocalLoadError);
};

api.fetchFilterList.toParsedURL = function(url) {
    try {
        return new URL(url);
    } catch (ex) {
    }
};

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

    The purpose of the asset source registry is to keep key detail information
    about an asset:
    - Where to load it from: this may consist of one or more URLs, either local
      or remote.
    - After how many days an asset should be deemed obsolete -- i.e. in need of
      an update.
    - The origin and type of an asset.
    - The last time an asset was registered.

**/

let assetSourceRegistryPromise,
    assetSourceRegistry = Object.create(null);

const registerAssetSource = function(assetKey, dict) {
    const entry = assetSourceRegistry[assetKey] || {};
    for ( const prop in dict ) {
        if ( dict.hasOwnProperty(prop) === false ) { continue; }
        if ( dict[prop] === undefined ) {
            delete entry[prop];
        } else {
            entry[prop] = dict[prop];
        }
    }
    let contentURL = dict.contentURL;
    if ( contentURL !== undefined ) {
        if ( typeof contentURL === 'string' ) {
            contentURL = entry.contentURL = [ contentURL ];
        } else if ( Array.isArray(contentURL) === false ) {
            contentURL = entry.contentURL = [];
        }
        let remoteURLCount = 0;
        for ( let i = 0; i < contentURL.length; i++ ) {
            if ( reIsExternalPath.test(contentURL[i]) ) {
                remoteURLCount += 1;
            }
        }
        entry.hasLocalURL = remoteURLCount !== contentURL.length;
        entry.hasRemoteURL = remoteURLCount !== 0;
    } else if ( entry.contentURL === undefined ) {
        entry.contentURL = [];
    }
    if ( typeof entry.updateAfter !== 'number' ) {
        entry.updateAfter = 5;
    }
    if ( entry.submitter ) {
        entry.submitTime = Date.now(); // To detect stale entries
    }
    assetSourceRegistry[assetKey] = entry;
};

const unregisterAssetSource = function(assetKey) {
    assetCacheRemove(assetKey);
    delete assetSourceRegistry[assetKey];
};

const saveAssetSourceRegistry = (function() {
    let timer;
    const save = function() {
        timer = undefined;
        µBlock.cacheStorage.set({ assetSourceRegistry: assetSourceRegistry });
    };
    return function(lazily) {
        if ( timer !== undefined ) {
            clearTimeout(timer);
        }
        if ( lazily ) {
            timer = vAPI.setTimeout(save, 500);
        } else {
            save();
        }
    };
})();

const updateAssetSourceRegistry = function(json, silent) {
    let newDict;
    try {
        newDict = JSON.parse(json);
    } catch (ex) {
    }
    if ( newDict instanceof Object === false ) { return; }

    const oldDict = assetSourceRegistry;

    // Remove obsolete entries (only those which were built-in).
    for ( const assetKey in oldDict ) {
        if (
            newDict[assetKey] === undefined &&
            oldDict[assetKey].submitter === undefined
        ) {
            unregisterAssetSource(assetKey);
        }
    }
    // Add/update existing entries. Notify of new asset sources.
    for ( const assetKey in newDict ) {
        if ( oldDict[assetKey] === undefined && !silent ) {
            fireNotification(
                'builtin-asset-source-added',
                { assetKey: assetKey, entry: newDict[assetKey] }
            );
        }
        registerAssetSource(assetKey, newDict[assetKey]);
    }
    saveAssetSourceRegistry();
};

const getAssetSourceRegistry = function() {
    if ( assetSourceRegistryPromise === undefined ) {
        assetSourceRegistryPromise = µBlock.cacheStorage.get(
            'assetSourceRegistry'
        ).then(bin => {
            if (
                bin instanceof Object &&
                bin.assetSourceRegistry instanceof Object
            ) {
                assetSourceRegistry = bin.assetSourceRegistry;
                return assetSourceRegistry;
            }
            return api.fetchText(
                µBlock.assetsBootstrapLocation || 'assets/assets.json'
            ).then(details => {
                return details.content !== ''
                    ? details
                    : api.fetchText('assets/assets.json');
            }).then(details => {
                updateAssetSourceRegistry(details.content, true);
                return assetSourceRegistry;
            });
        });
    }

    return assetSourceRegistryPromise;
};

api.registerAssetSource = function(assetKey, details) {
    getAssetSourceRegistry().then(( ) => {
        registerAssetSource(assetKey, details);
        saveAssetSourceRegistry(true);
    });
};

api.unregisterAssetSource = function(assetKey) {
    getAssetSourceRegistry().then(( ) => {
        unregisterAssetSource(assetKey);
        saveAssetSourceRegistry(true);
    });
};

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

    The purpose of the asset cache registry is to keep track of all assets
    which have been persisted into the local cache.

**/

const assetCacheRegistryStartTime = Date.now();
let assetCacheRegistryPromise;
let assetCacheRegistry = {};

const getAssetCacheRegistry = function() {
    if ( assetCacheRegistryPromise === undefined ) {
        assetCacheRegistryPromise = µBlock.cacheStorage.get(
            'assetCacheRegistry'
        ).then(bin => {
            if (
                bin instanceof Object &&
                bin.assetCacheRegistry instanceof Object
            ) {
                assetCacheRegistry = bin.assetCacheRegistry;
            }
        });
    }

    return assetCacheRegistryPromise.then(( ) => assetCacheRegistry);
};

const saveAssetCacheRegistry = (function() {
    let timer;
    const save = function() {
        timer = undefined;
        µBlock.cacheStorage.set({ assetCacheRegistry });
    };
    return function(lazily) {
        if ( timer !== undefined ) { clearTimeout(timer); }
        if ( lazily ) {
            timer = vAPI.setTimeout(save, 500);
        } else {
            save();
        }
    };
})();

const assetCacheRead = function(assetKey, callback) {
    const internalKey = 'cache/' + assetKey;

    const reportBack = function(content) {
        if ( content instanceof Blob ) { content = ''; }
        let details = { assetKey: assetKey, content: content };
        if ( content === '' ) { details.error = 'E_NOTFOUND'; }
        callback(details);
    };

    const onAssetRead = function(bin) {
        if (
            bin instanceof Object === false ||
            bin.hasOwnProperty(internalKey) === false
        ) {
            return reportBack('');
        }
        let entry = assetCacheRegistry[assetKey];
        if ( entry === undefined ) {
            return reportBack('');
        }
        entry.readTime = Date.now();
        saveAssetCacheRegistry(true);
        reportBack(bin[internalKey]);
    };

    Promise.all([
        getAssetCacheRegistry(),
        µBlock.cacheStorage.get(internalKey),
    ]).then(results => {
        onAssetRead(results[1]);
    });
};

const assetCacheWrite = function(assetKey, details, callback) {
    let internalKey = 'cache/' + assetKey;
    let content = '';
    if ( typeof details === 'string' ) {
        content = details;
    } else if ( details instanceof Object ) {
        content = details.content || '';
    }

    if ( content === '' ) {
        return assetCacheRemove(assetKey, callback);
    }

    const onReady = function() {
        let entry = assetCacheRegistry[assetKey];
        if ( entry === undefined ) {
            entry = assetCacheRegistry[assetKey] = {};
        }
        entry.writeTime = entry.readTime = Date.now();
        if ( details instanceof Object && typeof details.url === 'string' ) {
            entry.remoteURL = details.url;
        }
        µBlock.cacheStorage.set({ assetCacheRegistry, [internalKey]: content });
        const result = { assetKey, content };
        if ( typeof callback === 'function' ) {
            callback(result);
        }
        // https://github.com/uBlockOrigin/uBlock-issues/issues/248
        fireNotification('after-asset-updated', result);
    };

    getAssetCacheRegistry().then(( ) => onReady());
};

const assetCacheRemove = function(pattern, callback) {
    getAssetCacheRegistry().then(cacheDict => {
        const removedEntries = [];
        const removedContent = [];
        for ( const assetKey in cacheDict ) {
            if ( pattern instanceof RegExp && !pattern.test(assetKey) ) {
                continue;
            }
            if ( typeof pattern === 'string' && assetKey !== pattern ) {
                continue;
            }
            removedEntries.push(assetKey);
            removedContent.push('cache/' + assetKey);
            delete cacheDict[assetKey];
        }
        if ( removedContent.length !== 0 ) {
            µBlock.cacheStorage.remove(removedContent);
            µBlock.cacheStorage.set({ assetCacheRegistry });
        }
        if ( typeof callback === 'function' ) {
            callback();
        }
        for ( let i = 0; i < removedEntries.length; i++ ) {
            fireNotification(
                'after-asset-updated',
                { assetKey: removedEntries[i] }
            );
        }
    });
};

const assetCacheMarkAsDirty = function(pattern, exclude, callback) {
    if ( typeof exclude === 'function' ) {
        callback = exclude;
        exclude = undefined;
    }
    getAssetCacheRegistry().then(cacheDict => {
        let mustSave = false;
        for ( const assetKey in cacheDict ) {
            if ( pattern instanceof RegExp ) {
                if ( pattern.test(assetKey) === false ) { continue; }
            } else if ( typeof pattern === 'string' ) {
                if ( assetKey !== pattern ) { continue; }
            } else if ( Array.isArray(pattern) ) {
                if ( pattern.indexOf(assetKey) === -1 ) { continue; }
            }
            if ( exclude instanceof RegExp ) {
                if ( exclude.test(assetKey) ) { continue; }
            } else if ( typeof exclude === 'string' ) {
                if ( assetKey === exclude ) { continue; }
            } else if ( Array.isArray(exclude) ) {
                if ( exclude.indexOf(assetKey) !== -1 ) { continue; }
            }
            const cacheEntry = cacheDict[assetKey];
            if ( !cacheEntry.writeTime ) { continue; }
            cacheDict[assetKey].writeTime = 0;
            mustSave = true;
        }
        if ( mustSave ) {
            µBlock.cacheStorage.set({ assetCacheRegistry });
        }
        if ( typeof callback === 'function' ) {
            callback();
        }
    });
};

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

const stringIsNotEmpty = function(s) {
    return typeof s === 'string' && s !== '';
};

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

    User assets are NOT persisted in the cache storage. User assets are
    recognized by the asset key which always starts with 'user-'.

    TODO(seamless migration):
    Can remove instances of old user asset keys when I am confident all users
    are using uBO v1.11 and beyond.

**/

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

    User assets are NOT persisted in the cache storage. User assets are
    recognized by the asset key which always starts with 'user-'.

**/

const readUserAsset = function(assetKey, callback) {
    const reportBack = function(content) {
        callback({ assetKey, content });
    };
    vAPI.storage.get(assetKey, bin => {
        const content =
            bin instanceof Object && typeof bin[assetKey] === 'string'
                ? bin[assetKey]
                : '';
        return reportBack(content);
    });
    // Remove obsolete entry
    // TODO: remove once everybody is well beyond 1.18.6
    vAPI.storage.remove('assets/user/filters.txt');
};

const saveUserAsset = function(assetKey, content, callback) {
    vAPI.storage.set({ [assetKey]: content }, ( ) => {
        if ( callback instanceof Function ) {
            callback({ assetKey, content });
        }
    });
};

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

api.get = function(assetKey, options, callback) {
    if ( typeof options === 'function' ) {
        callback = options;
        options = {};
    } else if ( typeof callback !== 'function' ) {
        callback = noopfunc;
    }
    // This can happen if the method was called as a thenable.
    if ( options instanceof Object === false ) {
        options = {};
    }

    return new Promise(resolve => {
    // start of executor
    if ( assetKey === µBlock.userFiltersPath ) {
        readUserAsset(assetKey, details => {
            callback(details);
            resolve(details);
        });
        return;
    }

    let assetDetails = {},
        contentURLs,
        contentURL;

    const reportBack = (content, err) => {
        const details = { assetKey, content };
        if ( err ) {
            details.error = assetDetails.lastError = err;
        } else {
            assetDetails.lastError = undefined;
        }
        if ( options.needSourceURL ) {
            if (
                contentURL === undefined &&
                assetCacheRegistry instanceof Object &&
                assetCacheRegistry[assetKey] instanceof Object
            ) {
                details.sourceURL = assetCacheRegistry[assetKey].remoteURL;
            }
            if ( reIsExternalPath.test(contentURL) ) {
                details.sourceURL = contentURL;
            }
        }
        callback(details);
        resolve(details);
    };

    const onContentNotLoaded = ( ) => {
        let isExternal;
        while ( (contentURL = contentURLs.shift()) ) {
            isExternal = reIsExternalPath.test(contentURL);
            if ( isExternal === false || assetDetails.hasLocalURL !== true ) {
                break;
            }
        }
        if ( !contentURL ) {
            return reportBack('', 'E_NOTFOUND');
        }
        if ( assetDetails.content === 'filters' ) {
            api.fetchFilterList(contentURL, onContentLoaded, onContentNotLoaded);
        } else {
            api.fetchText(contentURL, onContentLoaded, onContentNotLoaded);
        }
    };

    const onContentLoaded = details => {
        if ( stringIsNotEmpty(details.content) === false ) {
            onContentNotLoaded();
            return;
        }
        if ( reIsExternalPath.test(contentURL) && options.dontCache !== true ) {
            assetCacheWrite(assetKey, {
                content: details.content,
                url: contentURL
            });
        }
        reportBack(details.content);
    };

    const onCachedContentLoaded = details => {
        if ( details.content !== '' ) {
            return reportBack(details.content);
        }
        getAssetSourceRegistry().then(registry => {
            assetDetails = registry[assetKey] || {};
            if ( typeof assetDetails.contentURL === 'string' ) {
                contentURLs = [ assetDetails.contentURL ];
            } else if ( Array.isArray(assetDetails.contentURL) ) {
                contentURLs = assetDetails.contentURL.slice(0);
            } else {
                contentURLs = [];
            }
            onContentNotLoaded();
        });
    };

    assetCacheRead(assetKey, onCachedContentLoaded);
    // end of executor
    });
};

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

const getRemote = function(assetKey, callback) {
    let assetDetails = {};
    let contentURLs;
    let contentURL;

    const reportBack = function(content, err) {
        const details = { assetKey: assetKey, content: content };
        if ( err ) {
            details.error = assetDetails.lastError = err;
        } else {
            assetDetails.lastError = undefined;
        }
        callback(details);
    };

    const onRemoteContentLoaded = function(details) {
        if ( stringIsNotEmpty(details.content) === false ) {
            registerAssetSource(assetKey, { error: { time: Date.now(), error: 'No content' } });
            tryLoading();
            return;
        }
        assetCacheWrite(assetKey, {
            content: details.content,
            url: contentURL
        });
        registerAssetSource(assetKey, { error: undefined });
        reportBack(details.content);
    };

    const onRemoteContentError = function(details) {
        let text = details.statusText;
        if ( details.statusCode === 0 ) {
            text = 'network error';
        }
        registerAssetSource(assetKey, { error: { time: Date.now(), error: text } });
        tryLoading();
    };

    const tryLoading = function() {
        while ( (contentURL = contentURLs.shift()) ) {
            if ( reIsExternalPath.test(contentURL) ) { break; }
        }
        if ( !contentURL ) {
            return reportBack('', 'E_NOTFOUND');
        }
        if ( assetDetails.content === 'filters' ) {
            api.fetchFilterList(contentURL, onRemoteContentLoaded, onRemoteContentError);
        } else {
            api.fetchText(contentURL, onRemoteContentLoaded, onRemoteContentError);
        }
    };

    getAssetSourceRegistry().then(registry => {
        assetDetails = registry[assetKey] || {};
        if ( typeof assetDetails.contentURL === 'string' ) {
            contentURLs = [ assetDetails.contentURL ];
        } else if ( Array.isArray(assetDetails.contentURL) ) {
            contentURLs = assetDetails.contentURL.slice(0);
        } else {
            contentURLs = [];
        }
        tryLoading();
    });
};

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

api.put = function(assetKey, content, callback) {
    return new Promise(resolve => {
        const onDone = function(details) {
            if ( typeof callback === 'function' ) {
                callback(details);
            }
            resolve(details);
        };
        if ( reIsUserAsset.test(assetKey) ) {
            saveUserAsset(assetKey, content, onDone);
        } else {
            assetCacheWrite(assetKey, content, onDone);
        }
    });
};

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

api.metadata = function(callback) {
    const onReady = function() {
        const assetDict = JSON.parse(JSON.stringify(assetSourceRegistry));
        const cacheDict = assetCacheRegistry;
        const now = Date.now();
        for ( const assetKey in assetDict ) {
            const assetEntry = assetDict[assetKey];
            const cacheEntry = cacheDict[assetKey];
            if ( cacheEntry ) {
                assetEntry.cached = true;
                assetEntry.writeTime = cacheEntry.writeTime;
                const obsoleteAfter =
                    cacheEntry.writeTime + assetEntry.updateAfter * 86400000;
                assetEntry.obsolete = obsoleteAfter < now;
                assetEntry.remoteURL = cacheEntry.remoteURL;
            } else if (
                assetEntry.contentURL &&
                assetEntry.contentURL.length !== 0
            ) {
                assetEntry.writeTime = 0;
                assetEntry.obsolete = true;
            }
        }
        callback(assetDict);
    };

    Promise.all([
        getAssetSourceRegistry(),
        getAssetCacheRegistry(),
    ]).then(
        ( ) => onReady()
    );
};

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

api.purge = assetCacheMarkAsDirty;

api.remove = function(pattern, callback) {
    assetCacheRemove(pattern, callback);
};

api.rmrf = function() {
    assetCacheRemove(/./);
};

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

// Asset updater area.
const updaterAssetDelayDefault = 120000;
const updaterUpdated = [];
const updaterFetched = new Set();

let updaterStatus,
    updaterTimer,
    updaterAssetDelay = updaterAssetDelayDefault;

const updateFirst = function() {
    updaterStatus = 'updating';
    updaterFetched.clear();
    updaterUpdated.length = 0;
    fireNotification('before-assets-updated');
    updateNext();
};

const updateNext = function() {
    let assetDict, cacheDict;

    // This will remove a cached asset when it's no longer in use.
    const garbageCollectOne = function(assetKey) {
        const cacheEntry = cacheDict[assetKey];
        if ( cacheEntry && cacheEntry.readTime < assetCacheRegistryStartTime ) {
            assetCacheRemove(assetKey);
        }
    };

    const findOne = function() {
        const now = Date.now();
        for ( const assetKey in assetDict ) {
            const assetEntry = assetDict[assetKey];
            if ( assetEntry.hasRemoteURL !== true ) { continue; }
            if ( updaterFetched.has(assetKey) ) { continue; }
            const cacheEntry = cacheDict[assetKey];
            if (
                cacheEntry &&
                (cacheEntry.writeTime + assetEntry.updateAfter * 86400000) > now
            ) {
                continue;
            }
            if (
                fireNotification(
                    'before-asset-updated',
                    { assetKey: assetKey,  type: assetEntry.content }
                ) === true
            ) {
                return assetKey;
            }
            garbageCollectOne(assetKey);
        }
    };

    const updatedOne = function(details) {
        if ( details.content !== '' ) {
            updaterUpdated.push(details.assetKey);
            if ( details.assetKey === 'assets.json' ) {
                updateAssetSourceRegistry(details.content);
            }
        } else {
            fireNotification('asset-update-failed', { assetKey: details.assetKey });
        }
        if ( findOne() !== undefined ) {
            vAPI.setTimeout(updateNext, updaterAssetDelay);
        } else {
            updateDone();
        }
    };

    const updateOne = function() {
        const assetKey = findOne();
        if ( assetKey === undefined ) {
            return updateDone();
        }
        updaterFetched.add(assetKey);
        getRemote(assetKey, updatedOne);
    };

    Promise.all([
        getAssetSourceRegistry(),
        getAssetCacheRegistry(),
    ]).then(results => {
        assetDict = results[0];
        cacheDict = results[1];
        updateOne();
    });
};

const updateDone = function() {
    const assetKeys = updaterUpdated.slice(0);
    updaterFetched.clear();
    updaterUpdated.length = 0;
    updaterStatus = undefined;
    updaterAssetDelay = updaterAssetDelayDefault;
    fireNotification('after-assets-updated', { assetKeys: assetKeys });
};

api.updateStart = function(details) {
    const oldUpdateDelay = updaterAssetDelay;
    const newUpdateDelay = typeof details.delay === 'number' ?
        details.delay :
        updaterAssetDelayDefault;
    updaterAssetDelay = Math.min(oldUpdateDelay, newUpdateDelay);
    if ( updaterStatus !== undefined ) {
        if ( newUpdateDelay < oldUpdateDelay ) {
            clearTimeout(updaterTimer);
            updaterTimer = vAPI.setTimeout(updateNext, updaterAssetDelay);
        }
        return;
    }
    updateFirst();
};

api.updateStop = function() {
    if ( updaterTimer ) {
        clearTimeout(updaterTimer);
        updaterTimer = undefined;
    }
    if ( updaterStatus !== undefined ) {
        updateDone();
    }
};

api.isUpdating = function() {
    return updaterStatus === 'updating' &&
           updaterAssetDelay <= µBlock.hiddenSettings.manualUpdateAssetFetchPeriod;
};

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

return api;

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

})();

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