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

    uBlock Origin - a comprehensive, efficient content blocker
    Copyright (C) 2019-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 { registerScriptlet } from './base.js';
import { safeSelf } from './safe-self.js';

// Externally added to the private namespace in which scriptlets execute.
/* global scriptletGlobals */

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

export function getRandomTokenFn() {
    const safe = safeSelf();
    return safe.String_fromCharCode(Date.now() % 26 + 97) +
        safe.Math_floor(safe.Math_random() * 982451653 + 982451653).toString(36);
}
registerScriptlet(getRandomTokenFn, {
    name: 'get-random-token.fn',
    dependencies: [
        safeSelf,
    ],
});

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

export function getExceptionTokenFn() {
    const token = getRandomTokenFn();
    const oe = self.onerror;
    self.onerror = function(msg, ...args) {
        if ( typeof msg === 'string' && msg.includes(token) ) { return true; }
        if ( oe instanceof Function ) {
            return oe.call(this, msg, ...args);
        }
    }.bind();
    return token;
}
registerScriptlet(getExceptionTokenFn, {
    name: 'get-exception-token.fn',
    dependencies: [
        getRandomTokenFn,
    ],
});

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

export function trapPropertyFn(propChain, handler, options = {}) {
    if ( propChain === '' ) { return; }
    let owner = self;
    let prop = propChain;
    for (;;) {
        const pos = prop.indexOf('.');
        if ( pos === -1 ) { break; }
        owner = owner[prop.slice(0, pos)];
        if ( owner instanceof Object === false ) { return; }
        prop = prop.slice(pos + 1);
    }
    const safe = safeSelf();
    if ( trapPropertyFn.db === undefined ) {
        trapPropertyFn.db = new WeakMap();
        trapPropertyFn.entryFromContext = (owner, prop) => {
            const handlers = trapPropertyFn.db.get(owner);
            return handlers?.get(prop);
        };
        trapPropertyFn.getter = (owner, prop) => {
            const entry = trapPropertyFn.entryFromContext(owner, prop);
            if ( entry === undefined ) { return; }
            let r = entry.value;
            for ( const desc of entry.stack ) {
                try { r = desc.get(); } catch (e) {
                    if ( entry.canThrow ) { throw e; }
                }
            }
            return r;
        };
        trapPropertyFn.setter = (owner, prop, value) => {
            const entry = trapPropertyFn.entryFromContext(owner, prop);
            if ( entry === undefined ) { return; }
            entry.value = value;
            for ( const desc of entry.stack ) {
                try { desc.set(value); } catch (e) {
                    if ( entry.canThrow ) { throw e; }
                }
            }
        };
    }
    const { db } = trapPropertyFn;
    const handlers = db.get(owner) || new Map();
    if ( handlers.size === 0 ) {
        db.set(owner, handlers);
    }
    const entry = handlers.get(prop) || {
        value: owner[prop],
        stack: [],
    };
    entry.stack.push(handler);
    if ( entry.stack.length > 1 ) { return entry.value; }
    Object.assign(entry, options);
    handlers.set(prop, entry);
    const desc = safe.Object_getOwnPropertyDescriptor(owner, prop);
    if ( desc instanceof safe.Object ) {
        if ( desc.get || desc.set ) {
            entry.stack.push(desc);
        }
    }
    try {
        safe.Object_defineProperty(owner, prop, {
            get() {
                return trapPropertyFn.getter(owner, prop);
            },
            set(value) {
                trapPropertyFn.setter(owner, prop, value);
            }
        });
    } catch {
    }
    return entry.value;
}
registerScriptlet(trapPropertyFn, {
    name: 'trap-property-access.fn',
    dependencies: [
        safeSelf,
    ],
});

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

export function collateFetchArgumentsFn(resource, options) {
    const safe = safeSelf();
    const props = [
        'body', 'cache', 'credentials', 'duplex', 'headers',
        'integrity', 'keepalive', 'method', 'mode', 'priority',
        'redirect', 'referrer', 'referrerPolicy', 'url'
    ];
    const out = {};
    if ( collateFetchArgumentsFn.collateKnownProps === undefined ) {
        collateFetchArgumentsFn.collateKnownProps = (src, out) => {
            for ( const prop of props ) {
                if ( src[prop] === undefined ) { continue; }
                out[prop] = src[prop];
            }
        };
    }
    if (
        typeof resource !== 'object' ||
        safe.Object_toString.call(resource) !== '[object Request]'
    ) {
        out.url = `${resource}`;
    } else {
        let clone;
        try {
            clone = safe.Request_clone.call(resource);
        } catch {
        }
        collateFetchArgumentsFn.collateKnownProps(clone || resource, out);
    }
    if ( typeof options === 'object' && options !== null ) {
        collateFetchArgumentsFn.collateKnownProps(options, out);
    }
    return out;
}
registerScriptlet(collateFetchArgumentsFn, {
    name: 'collate-fetch-arguments.fn',
    dependencies: [
        safeSelf,
    ],
});

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

export function parsePropertiesToMatchFn(propsToMatch, implicit = '') {
    const safe = safeSelf();
    const needles = new Map();
    if ( propsToMatch === undefined || propsToMatch === '' ) { return needles; }
    const options = { canNegate: true };
    for ( const needle of safe.String_split.call(propsToMatch, /\s+/) ) {
        let [ prop, pattern ] = safe.String_split.call(needle, ':');
        if ( prop === '' ) { continue; }
        if ( pattern !== undefined && /[^$\w -]/.test(prop) ) {
            prop = `${prop}:${pattern}`;
            pattern = undefined;
        }
        if ( pattern !== undefined ) {
            needles.set(prop, safe.initPattern(pattern, options));
        } else if ( implicit !== '' ) {
            needles.set(implicit, safe.initPattern(prop, options));
        }
    }
    return needles;
}
registerScriptlet(parsePropertiesToMatchFn, {
    name: 'parse-properties-to-match.fn',
    dependencies: [
        safeSelf,
    ],
});

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

export function matchObjectPropertiesFn(propNeedles, ...objs) {
    const safe = safeSelf();
    const matched = [];
    for ( const obj of objs ) {
        if ( obj instanceof Object === false ) { continue; }
        for ( const [ prop, details ] of propNeedles ) {
            let value = obj[prop];
            if ( value === undefined ) { continue; }
            if ( typeof value !== 'string' ) {
                try { value = safe.JSON_stringify(value); }
                catch { }
                if ( typeof value !== 'string' ) { continue; }
            }
            if ( safe.testPattern(details, value) === false ) { return; }
            matched.push(`${prop}: ${value}`);
        }
    }
    return matched;
}
registerScriptlet(matchObjectPropertiesFn, {
    name: 'match-object-properties.fn',
    dependencies: [
        safeSelf,
    ],
});

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

// Reference:
// https://github.com/AdguardTeam/Scriptlets/blob/master/wiki/about-scriptlets.md#prevent-xhr
//
// Added `trusted` argument to allow for returning arbitrary text. Can only
// be used through scriptlets requiring trusted source.

export function generateContentFn(trusted, directive) {
    const safe = safeSelf();
    const randomize = len => {
        const chunks = [];
        let textSize = 0;
        do {
            const s = safe.Math_random().toString(36).slice(2);
            chunks.push(s);
            textSize += s.length;
        }
        while ( textSize < len );
        return chunks.join(' ').slice(0, len);
    };
    if ( directive === 'true' ) {
        return randomize(10);
    }
    if ( directive === 'emptyObj' ) {
        return '{}';
    }
    if ( directive === 'emptyArr' ) {
        return '[]';
    }
    if ( directive === 'emptyStr' ) {
        return '';
    }
    if ( directive.startsWith('length:') ) {
        const match = /^length:(\d+)(?:-(\d+))?$/.exec(directive);
        if ( match === null ) { return ''; }
        const min = parseInt(match[1], 10);
        const extent = safe.Math_max(parseInt(match[2], 10) || 0, min) - min;
        const len = safe.Math_min(min + extent * safe.Math_random(), 500000);
        return randomize(len | 0);
    }
    if ( directive.startsWith('war:') ) {
        if ( scriptletGlobals.warOrigin === undefined ) { return ''; }
        return new Promise(resolve => {
            const warOrigin = scriptletGlobals.warOrigin;
            const warName = directive.slice(4);
            const fullpath = [ warOrigin, '/', warName ];
            const warSecret = scriptletGlobals.warSecret;
            if ( warSecret !== undefined ) {
                fullpath.push('?secret=', warSecret);
            }
            const warXHR = new safe.XMLHttpRequest();
            warXHR.responseType = 'text';
            warXHR.onloadend = ev => {
                resolve(ev.target.responseText || '');
            };
            warXHR.open('GET', fullpath.join(''));
            warXHR.send();
        }).catch(( ) => '');
    }
    if ( directive.startsWith('join:') ) {
        const parts = directive.slice(7)
                .split(directive.slice(5, 7))
                .map(a => generateContentFn(trusted, a));
        return parts.some(a => a instanceof Promise)
            ? Promise.all(parts).then(parts => parts.join(''))
            : parts.join('');
    }
    if ( trusted ) {
        return directive;
    }
    return '';
}
registerScriptlet(generateContentFn, {
    name: 'generate-content.fn',
    dependencies: [
        safeSelf,
    ],
});

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

export function onIdleFn(fn, options) {
    if ( self.requestIdleCallback ) {
        return self.requestIdleCallback(fn, options);
    }
    return self.requestAnimationFrame(fn);
}
registerScriptlet(onIdleFn, {
    name: 'on-idle.fn',
});

export function offIdleFn(id) {
    if ( self.requestIdleCallback ) {
        return self.cancelIdleCallback(id);
    }
    return self.cancelAnimationFrame(id);
}
registerScriptlet(offIdleFn, {
    name: 'off-idle.fn',
});

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

export function sleepFn(ms = 0) {
    const nap = ( ) => {
        return new Promise(resolve => {
            self.requestAnimationFrame(resolve);
        });
    }
    const until = Date.now() + ms;
    const sleep = async resolve => {
        do {
            await nap();
        } while ( Date.now() < until );
        resolve();
    };
    return new Promise(resolve => { sleep(resolve); });
}
registerScriptlet(sleepFn, {
    name: 'sleep.fn',
});

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

export function lookupElementsFn(directive, until = 0) {
    if ( lookupElementsFn.querySelectorEx === undefined ) {
        lookupElementsFn.getShadowRoot = elem => {
            if ( elem.openOrClosedShadowRoot ) { // Firefox
                return elem.openOrClosedShadowRoot;
            }
            if ( self.chrome?.dom?.openOrClosedShadowRoot ) { // Chromium
                return self.chrome.dom.openOrClosedShadowRoot(elem);
            }
            return elem.shadowRoot;
        };
        lookupElementsFn.queryOrEvaluateSelector = (selector, context) => {
            if ( selector.startsWith('xpath:') === false ) {
                return Array.from(context.querySelectorAll(selector));
            }
            const result = document.evaluate(selector.slice(6), context, null, 7, null);
            const out = [];
            if ( result.resultType === 7 ) {
                for ( let i = 0; i < result.snapshotLength; i++ ) {
                    out[i] = result.snapshotItem(i);
                }
            }
            return out;
        }
        lookupElementsFn.querySelectorEx = (selector, context = document) => {
            const pos = selector.indexOf(' >>> ');
            if ( pos === -1 ) {
                return lookupElementsFn.queryOrEvaluateSelector(selector, context);
            }
            const outside = selector.slice(0, pos).trim();
            const inside = selector.slice(pos + 5).trim();
            const elems = lookupElementsFn.queryOrEvaluateSelector(outside, context);
            const out = [];
            for ( let i = 0; i < elems.length; i++ ) {
                const shadowRoot = lookupElementsFn.getShadowRoot(elems[i]);
                if ( Boolean(shadowRoot) === false ) { continue; }
                lookupElementsFn.querySelectorEx(inside, shadowRoot).forEach(a => out.push(a));
            }
            return out;
        };
        lookupElementsFn.lookup = directive => {
            const beVisible = directive.startsWith('when-visible:');
            const selector = beVisible ? directive.slice(13) : directive;
            const elems = lookupElementsFn.querySelectorEx(selector);
            if ( beVisible !== true ) { return elems; }
            return elems.filter(a => a.checkVisibility({
                opacityProperty: true,
                visibilityProperty: true,
            }));
        };
        lookupElementsFn.lookupAsync = details => {
            const elems = lookupElementsFn.lookup(details.directive);
            if ( elems.length || Date.now() >= details.until ) {
                if ( details.observer ) {
                    details.observer.disconnect();
                    details.observer = undefined;
                }
                if ( details.timer ) {
                    offIdleFn(details.timer);
                    details.timer = undefined;
                }
                return details.resolve(elems);
            }
            if ( details.observer === undefined ) {
                details.observer = new MutationObserver(( ) => {
                    lookupElementsFn.lookupAsync(details);
                });
                details.observer.observe(document, {
                    attributes: true,
                    childList: true,
                    subtree: true,
                });
            }
            if ( details.timer === undefined ) {
                details.timer = onIdleFn(( ) => {
                    details.timer = undefined;
                    lookupElementsFn.lookupAsync(details);
                }, { timeout: 151 });
            }
        };
    }
    if ( until === 0 ) {
        return lookupElementsFn.lookup(directive);
    }
    return new Promise(resolve => {
        lookupElementsFn.lookupAsync({ directive, until, resolve });
    });
}
registerScriptlet(lookupElementsFn, {
    name: 'lookup-elements.fn',
    dependencies: [
        offIdleFn,
        onIdleFn,
    ],
});
