/*
 * Copyright (C) 2020-2021  Yomichan Authors
 *
 * 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 <https://www.gnu.org/licenses/>.
 */

/* global
 * api
 */

class FrameOffsetForwarder {
    constructor(frameId) {
        this._frameId = frameId;
        this._isPrepared = false;
        this._cacheMaxSize = 1000;
        this._frameCache = new Set();
        this._unreachableContentWindowCache = new Set();
        this._windowMessageHandlers = new Map([
            ['getFrameOffset', this._onMessageGetFrameOffset.bind(this)]
        ]);
    }

    prepare() {
        if (this._isPrepared) { return; }
        window.addEventListener('message', this._onMessage.bind(this), false);
        this._isPrepared = true;
    }

    async getOffset() {
        if (window === window.parent) {
            return [0, 0];
        }

        const uniqueId = generateId(16);

        const frameOffsetPromise = yomichan.getTemporaryListenerResult(
            chrome.runtime.onMessage,
            ({action, params}, {resolve}) => {
                if (action === 'frameOffset' && isObject(params) && params.uniqueId === uniqueId) {
                    resolve(params);
                }
            },
            5000
        );

        this._getFrameOffsetParent([0, 0], uniqueId, this._frameId);

        const {offset} = await frameOffsetPromise;
        return offset;
    }

    // Private

    _onMessage(event) {
        const data = event.data;
        if (data === null || typeof data !== 'object') { return; }

        try {
            const {action, params} = event.data;
            const handler = this._windowMessageHandlers.get(action);
            if (typeof handler !== 'function') { return; }
            handler(params, event);
        } catch (e) {
            // NOP
        }
    }

    _onMessageGetFrameOffset({offset, uniqueId, frameId}, e) {
        let sourceFrame = null;
        if (!this._unreachableContentWindowCache.has(e.source)) {
            sourceFrame = this._findFrameWithContentWindow(e.source);
        }
        if (sourceFrame === null) {
            // closed shadow root etc.
            this._addToCache(this._unreachableContentWindowCache, e.source);
            this._replyFrameOffset(null, uniqueId, frameId);
            return;
        }

        const [forwardedX, forwardedY] = offset;
        const {x, y} = sourceFrame.getBoundingClientRect();
        offset = [forwardedX + x, forwardedY + y];

        if (window === window.parent) {
            this._replyFrameOffset(offset, uniqueId, frameId);
        } else {
            this._getFrameOffsetParent(offset, uniqueId, frameId);
        }
    }

    _findFrameWithContentWindow(contentWindow) {
        const ELEMENT_NODE = Node.ELEMENT_NODE;
        for (const elements of this._getFrameElementSources()) {
            while (elements.length > 0) {
                const element = elements.shift();
                if (element.contentWindow === contentWindow) {
                    this._addToCache(this._frameCache, element);
                    return element;
                }

                const shadowRoot = (
                    element.shadowRoot ||
                    element.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions
                );
                if (shadowRoot) {
                    for (const child of shadowRoot.children) {
                        if (child.nodeType === ELEMENT_NODE) {
                            elements.push(child);
                        }
                    }
                }

                for (const child of element.children) {
                    if (child.nodeType === ELEMENT_NODE) {
                        elements.push(child);
                    }
                }
            }
        }

        return null;
    }

    *_getFrameElementSources() {
        const frameCache = [];
        for (const frame of this._frameCache) {
            // removed from DOM
            if (!frame.isConnected) {
                this._frameCache.delete(frame);
                continue;
            }
            frameCache.push(frame);
        }
        yield frameCache;
        // will contain duplicates, but frame elements are cheap to handle
        yield [...document.querySelectorAll('frame,iframe')];
        yield [document.documentElement];
    }

    _addToCache(cache, value) {
        let freeSlots = this._cacheMaxSize - cache.size;
        if (freeSlots <= 0) {
            for (const cachedValue of cache) {
                cache.delete(cachedValue);
                ++freeSlots;
                if (freeSlots > 0) { break; }
            }
        }
        cache.add(value);
    }

    _getFrameOffsetParent(offset, uniqueId, frameId) {
        window.parent.postMessage({
            action: 'getFrameOffset',
            params: {
                offset,
                uniqueId,
                frameId
            }
        }, '*');
    }

    _replyFrameOffset(offset, uniqueId, frameId) {
        api.sendMessageToFrame(frameId, 'frameOffset', {offset, uniqueId});
    }
}