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

    uBlock Origin Lite - a comprehensive, MV3-compliant content blocker
    Copyright (C) 2022-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
*/

import * as ut from './utils.js';

import {
    browser,
    localKeys, localRemove, localWrite,
    sessionKeys, sessionRead, sessionRemove, sessionWrite,
} from './ext.js';
import { ubolErr, ubolLog } from './debug.js';

import { fetchJSON } from './fetch.js';
import { getEnabledRulesetsDetails } from './ruleset-manager.js';
import { getFilteringModeDetails } from './mode-manager.js';
import { registerCustomFilters } from './filter-manager.js';
import { registerToolbarIconToggler } from './action.js';

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

const resourceDetailPromises = new Map();

function getScriptletDetails() {
    let promise = resourceDetailPromises.get('scriptlet');
    if ( promise !== undefined ) { return promise; }
    promise = fetchJSON('/rulesets/scriptlet-details').then(
        entries => new Map(entries)
    );
    resourceDetailPromises.set('scriptlet', promise);
    return promise;
}

function getGenericDetails() {
    let promise = resourceDetailPromises.get('generic');
    if ( promise !== undefined ) { return promise; }
    promise = fetchJSON('/rulesets/generic-details').then(
        entries => new Map(entries)
    );
    resourceDetailPromises.set('generic', promise);
    return promise;
}

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

const normalizeMatches = matches => {
    if ( matches.length <= 1 ) { return; }
    if ( matches.includes('<all_urls>') === false ) {
        if ( matches.includes('*://*/*') === false ) { return; }
    }
    matches.length = 0;
    matches.push('<all_urls>');
};

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

// The extensions API does not always return exactly what we fed it, so we
// need to normalize some entries to be sure we properly detect changes when
// comparing registered entries vs. entries to register.

const normalizeRegisteredContentScripts = registered => {
    for ( const entry of registered ) {
        const { css = [], js = [] } = entry;
        for ( let i = 0; i < css.length; i++ ) {
            const path = css[i];
            if ( path.startsWith('/') ) { continue; }
            css[i] = `/${path}`;
        }
        for ( let i = 0; i < js.length; i++ ) {
            const path = js[i];
            if ( path.startsWith('/') ) { continue; }
            js[i] = `/${path}`;
        }
    }
    return registered;
};

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

async function resetCSSCache() {
    const keys = await sessionKeys();
    return Promise.all(
        keys.filter(a => a.startsWith('cache.css.')).map(a => sessionRemove(a))
    );
}

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

function registerHighGeneric(context, genericDetails) {
    const { before, filteringModeDetails, rulesetsDetails } = context;

    const excludeHostnames = [];
    const includeHostnames = [];
    const css = [];
    for ( const details of rulesetsDetails ) {
        const hostnames = genericDetails.get(details.id);
        if ( hostnames ) {
            if ( hostnames.unhide ) {
                excludeHostnames.push(...hostnames.unhide);
            }
            if ( hostnames.hide ) {
                includeHostnames.push(...hostnames.hide);
            }
        }
        const count = details.css?.generichigh || 0;
        if ( count === 0 ) { continue; }
        css.push(`/rulesets/scripting/generichigh/${details.id}.css`);
    }

    if ( css.length === 0 ) { return; }

    const { none, basic, optimal, complete } = filteringModeDetails;
    const matches = [];
    const excludeMatches = [];
    if ( complete.has('all-urls') ) {
        excludeMatches.push(...ut.matchesFromHostnames(none));
        excludeMatches.push(...ut.matchesFromHostnames(basic));
        excludeMatches.push(...ut.matchesFromHostnames(optimal));
        excludeMatches.push(...ut.matchesFromHostnames(excludeHostnames));
        matches.push('<all_urls>');
    } else {
        matches.push(
            ...ut.matchesFromHostnames(
                ut.subtractHostnameIters(
                    Array.from(complete),
                    excludeHostnames
                )
            )
        );
    }

    if ( matches.length === 0 ) { return; }

    const registered = before.get('css-generichigh');
    before.delete('css-generichigh'); // Important!

    // https://github.com/w3c/webextensions/issues/414#issuecomment-1623992885
    // Once supported, add:
    // cssOrigin: 'USER',
    const directive = {
        id: 'css-generichigh',
        css,
        matches,
        allFrames: true,
        runAt: 'document_end',
    };
    if ( excludeMatches.length !== 0 ) {
        directive.excludeMatches = excludeMatches;
    }

    // register
    if ( registered === undefined ) {
        context.toAdd.push(directive);
        return;
    }

    // update
    if (
        ut.strArrayEq(registered.css, css, false) === false ||
        ut.strArrayEq(registered.matches, matches) === false ||
        ut.strArrayEq(registered.excludeMatches, excludeMatches) === false
    ) {
        context.toRemove.push('css-generichigh');
        context.toAdd.push(directive);
    }
}

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

function registerGeneric(context, genericDetails) {
    const { before, filteringModeDetails, rulesetsDetails } = context;

    const excludedByFilter = [];
    const includedByFilter = [];
    const js = [];
    for ( const details of rulesetsDetails ) {
        const hostnames = genericDetails.get(details.id);
        if ( hostnames ) {
            if ( hostnames.unhide ) {
                excludedByFilter.push(...hostnames.unhide);
            }
            if ( hostnames.hide ) {
                includedByFilter.push(...hostnames.hide);
            }
        }
        const count = details.css?.generic || 0;
        if ( count === 0 ) { continue; }
        js.push(`/rulesets/scripting/generic/${details.id}.js`);
    }

    if ( js.length === 0 ) { return; }

    js.unshift('/js/scripting/css-api.js', '/js/scripting/isolated-api.js');
    js.push('/js/scripting/css-generic.js');

    const { none, basic, optimal, complete } = filteringModeDetails;
    const includedByMode = [ ...complete ];
    const excludedByMode = [ ...none, ...basic, ...optimal ];

    if ( complete.has('all-urls') === false ) {
        const matches = [
            ...ut.matchesFromHostnames(
                ut.subtractHostnameIters(includedByMode, excludedByFilter)
            ),
            ...ut.matchesFromHostnames(
                ut.intersectHostnameIters(includedByMode, includedByFilter)
            ),
        ];
        if ( matches.length === 0 ) { return; }
        const registered = before.get('css-generic-some');
        before.delete('css-generic-some'); // Important!
        const directive = {
            id: 'css-generic-some',
            js,
            allFrames: true,
            matches,
            runAt: 'document_idle',
        };
        if ( registered === undefined ) { // register
            context.toAdd.push(directive);
        } else if ( // update
            ut.strArrayEq(registered.js, js, false) === false ||
            ut.strArrayEq(registered.matches, directive.matches) === false
        ) {
            context.toRemove.push('css-generic-some');
            context.toAdd.push(directive);
        }
        return;
    }

    const excludeMatches = [
        ...ut.matchesFromHostnames(excludedByMode),
        ...ut.matchesFromHostnames(excludedByFilter),
    ];
    const registeredAll = before.get('css-generic-all');
    before.delete('css-generic-all'); // Important!
    const directiveAll = {
        id: 'css-generic-all',
        js,
        allFrames: true,
        matches: [ '<all_urls>' ],
        runAt: 'document_idle',
    };
    if ( excludeMatches.length !== 0 ) {
        directiveAll.excludeMatches = excludeMatches;
    }

    if ( registeredAll === undefined ) { // register
        context.toAdd.push(directiveAll);
    } else if ( // update
        ut.strArrayEq(registeredAll.js, js, false) === false ||
        ut.strArrayEq(registeredAll.excludeMatches, directiveAll.excludeMatches) === false
    ) {
        context.toRemove.push('css-generic-all');
        context.toAdd.push(directiveAll);
    }
    const matches = [
        ...ut.matchesFromHostnames(
            ut.subtractHostnameIters(includedByFilter, excludedByMode)
        ),
    ];
    if ( matches.length === 0 ) { return; }
    const registeredSome = before.get('css-generic-some');
    before.delete('css-generic-some'); // Important!
    const directiveSome = {
        id: 'css-generic-some',
        js,
        allFrames: true,
        matches,
        runAt: 'document_idle',
    };
    if ( registeredSome === undefined ) { // register
        context.toAdd.push(directiveSome);
    } else if ( // update
        ut.strArrayEq(registeredSome.js, js, false) === false ||
        ut.strArrayEq(registeredSome.matches, directiveSome.matches) === false
    ) {
        context.toRemove.push('css-generic-some');
        context.toAdd.push(directiveSome);
    }
}

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

async function registerCosmetic(realm, context) {
    const { before, filteringModeDetails, rulesetsDetails } = context;

    {
        const keys = await localKeys();
        for ( const key of keys ) {
            if ( key.startsWith(`css.${realm}.`) === false ) { continue; }
            localRemove(key);
        }
    }

    const rulesetIds = [];
    for ( const rulesetDetails of rulesetsDetails ) {
        const count = rulesetDetails.css?.[realm] || 0;
        if ( count === 0 ) { continue; }
        rulesetIds.push(rulesetDetails.id);
    }
    if ( rulesetIds.length === 0 ) { return; }

    const { none, basic, optimal, complete } = filteringModeDetails;
    const matches = [
        ...ut.matchesFromHostnames(optimal),
        ...ut.matchesFromHostnames(complete),
    ];
    if ( matches.length === 0 ) { return; }

    {
        const promises = [];
        for ( const id of rulesetIds ) {
            promises.push(
                fetchJSON(`/rulesets/scripting/${realm}/${id}`).then(data => {
                    return localWrite(`css.${realm}.${id}`, data);
                })
            );
        }
        await Promise.all(promises);
    }

    normalizeMatches(matches);

    const realmid = `css-${realm}`;
    const js = rulesetIds.map(id => `/rulesets/scripting/${realm}/${id}.js`);
    js.unshift('/js/scripting/css-api.js', '/js/scripting/isolated-api.js');
    js.push(`/js/scripting/${realmid}.js`);

    const excludeMatches = [];
    if ( none.has('all-urls') === false && basic.has('all-urls') === false ) {
        const toExclude = [
            ...ut.matchesFromHostnames(none),
            ...ut.matchesFromHostnames(basic),
        ];
        for ( const hn of toExclude ) {
            excludeMatches.push(hn);
        }
    }

    const registered = before.get(realmid);
    before.delete(realmid); // Important!

    const directive = {
        id: realmid,
        js,
        matches,
        allFrames: true,
        runAt: 'document_start',
    };
    if ( excludeMatches.length !== 0 ) {
        directive.excludeMatches = excludeMatches;
    }

    // register
    if ( registered === undefined ) {
        context.toAdd.push(directive);
        return;
    }

    // update
    if (
        ut.strArrayEq(registered.js, js, false) === false ||
        ut.strArrayEq(registered.matches, matches) === false ||
        ut.strArrayEq(registered.excludeMatches, excludeMatches) === false
    ) {
        context.toRemove.push(realmid);
        context.toAdd.push(directive);
    }
}

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

function registerScriptlet(context, scriptletDetails) {
    const { before, filteringModeDetails, rulesetsDetails } = context;

    const hasBroadHostPermission =
        filteringModeDetails.optimal.has('all-urls') ||
        filteringModeDetails.complete.has('all-urls');

    const permissionRevokedMatches = [
        ...ut.matchesFromHostnames(filteringModeDetails.none),
        ...ut.matchesFromHostnames(filteringModeDetails.basic),
    ];
    const permissionGrantedHostnames = [
        ...filteringModeDetails.optimal,
        ...filteringModeDetails.complete,
    ];

    for ( const rulesetId of rulesetsDetails.map(v => v.id) ) {
        const worlds = scriptletDetails.get(rulesetId);
        if ( worlds === undefined ) { continue; }
        for ( const world of Object.keys(worlds) ) {
            const id = `${rulesetId}.${world.toLowerCase()}`;

            const matches = [];
            const excludeMatches = [];
            const hostnames = worlds[world];
            let targetHostnames = [];
            if ( hasBroadHostPermission ) {
                excludeMatches.push(...permissionRevokedMatches);
                targetHostnames = hostnames;
            } else if ( permissionGrantedHostnames.length !== 0 ) {
                if ( hostnames.includes('*') ) {
                    targetHostnames = permissionGrantedHostnames;
                } else {
                    targetHostnames = ut.intersectHostnameIters(
                        hostnames,
                        permissionGrantedHostnames
                    );
                }
            }
            if ( targetHostnames.length === 0 ) { continue; }
            matches.push(...ut.matchesFromHostnames(targetHostnames));
            normalizeMatches(matches);

            const registered = before.get(id);
            before.delete(id); // Important!

            const directive = {
                id,
                js: [ `/rulesets/scripting/scriptlet/${world.toLowerCase()}/${rulesetId}.js` ],
                matches,
                allFrames: true,
                matchOriginAsFallback: true,
                runAt: 'document_start',
                world,
            };
            if ( excludeMatches.length !== 0 ) {
                directive.excludeMatches = excludeMatches;
            }

            // register
            if ( registered === undefined ) {
                context.toAdd.push(directive);
                continue;
            }

            // update
            if (
                ut.strArrayEq(registered.matches, matches) === false ||
                ut.strArrayEq(registered.excludeMatches, excludeMatches) === false
            ) {
                context.toRemove.push(id);
                context.toAdd.push(directive);
            }
        }
    }
}

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

// Issue: Safari appears to completely ignore excludeMatches
// https://github.com/radiolondra/ExcludeMatches-Test

export async function registerInjectables() {
    if ( browser.scripting === undefined ) { return false; }

    if ( registerInjectables.barrier ) { return true; }
    registerInjectables.barrier = true;

    const [
        filteringModeDetails,
        rulesetsDetails,
        scriptletDetails,
        genericDetails,
        registered,
    ] = await Promise.all([
        getFilteringModeDetails(),
        getEnabledRulesetsDetails(),
        getScriptletDetails(),
        getGenericDetails(),
        browser.scripting.getRegisteredContentScripts(),
    ]);
    const before = new Map(
        normalizeRegisteredContentScripts(registered).map(
            entry => [ entry.id, entry ]
        )
    );
    const toAdd = [], toRemove = [];
    const context = {
        filteringModeDetails,
        rulesetsDetails,
        before,
        toAdd,
        toRemove,
    };

    await Promise.all([
        registerScriptlet(context, scriptletDetails),
        registerCosmetic('specific', context),
        registerCosmetic('procedural', context),
        registerGeneric(context, genericDetails),
        registerHighGeneric(context, genericDetails),
        registerCustomFilters(context),
        registerToolbarIconToggler(context),
    ]);

    toRemove.push(...Array.from(before.keys()));

    if ( toRemove.length !== 0 ) {
        ubolLog(`Unregistered ${toRemove} content (css/js)`);
        try {
            await browser.scripting.unregisterContentScripts({ ids: toRemove });
            localRemove('$scripting.unregisterContentScripts');
        } catch(reason) {
            ubolErr(`unregisterContentScripts/${reason}`);
        }
    }

    if ( toAdd.length !== 0 ) {
        ubolLog(`Registered ${toAdd.map(v => v.id)} content (css/js)`);
        try {
            await browser.scripting.registerContentScripts(toAdd);
            localRemove('$scripting.registerContentScripts');
        } catch(reason) {
            ubolErr(`registerContentScripts/${reason}`);
        }
    }

    await resetCSSCache();

    registerInjectables.barrier = false;

    return true;
}

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

export async function onWakeupRun() {
    const cleanupTime = await sessionRead('scripting.manager.cleanup.time') || 0;
    const now = Date.now();
    const since = now - cleanupTime;
    if ( since < (15 * 60 * 1000) ) { return; } // 15 minutes
    const MAX_CACHE_ENTRY_LOW = 256;
    const MAX_CACHE_ENTRY_HIGH = MAX_CACHE_ENTRY_LOW +
        Math.max(Math.round(MAX_CACHE_ENTRY_LOW / 8), 8);
    const keys = await sessionKeys() || [];
    const cacheKeys = keys.filter(a => a.startsWith('cache.css.'));
    if ( cacheKeys.length < MAX_CACHE_ENTRY_HIGH ) { return; }
    const entries = await Promise.all(cacheKeys.map(async a => {
        const entry = await sessionRead(a) || {};
        entry.key = a;
        return entry;
    }));
    entries.sort((a, b) => b.t - a.t);
    entries.slice(MAX_CACHE_ENTRY_LOW).map(a => sessionRemove(a.key));
    sessionWrite('scripting.manager.cleanup.time', now)
}

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