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

    uBlock Origin - a browser extension to block requests.
    Copyright (C) 2015-2018 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
*/

/* global uDom */

'use strict';

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

(( ) => {

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

const showdomButton = uDom.nodeFromId('showdom');

// Don't bother if the browser is not modern enough.
if (
    typeof Map === 'undefined' ||
    Map.polyfill ||
    typeof WeakMap === 'undefined'
) {
    showdomButton.classList.add('disabled');
    return;
}

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

const logger = self.logger;
var inspectorConnectionId;
var inspectedTabId = 0;
var inspectedURL = '';
var inspectedHostname = '';
var inspector = uDom.nodeFromId('domInspector');
var domTree = uDom.nodeFromId('domTree');
var uidGenerator = 1;
var filterToIdMap = new Map();

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

const messaging = vAPI.messaging;

vAPI.MessagingConnection.addListener(function(msg) {
    if ( msg.from !== 'domInspector' || msg.to !== 'loggerUI' ) { return; }
    switch ( msg.what ) {
    case 'connectionBroken':
        if ( inspectorConnectionId === msg.id ) {
            filterToIdMap.clear();
            logger.removeAllChildren(domTree);
            inspectorConnectionId = undefined;
        }
        injectInspector();
        break;
    case 'connectionMessage':
        if ( msg.payload.what === 'domLayoutFull' ) {
            inspectedURL = msg.payload.url;
            inspectedHostname = msg.payload.hostname;
            renderDOMFull(msg.payload);
        } else if ( msg.payload.what === 'domLayoutIncremental' ) {
            renderDOMIncremental(msg.payload);
        }
        break;
    case 'connectionRequested':
        if ( msg.tabId === undefined || msg.tabId !== inspectedTabId ) {
            return;
        }
        filterToIdMap.clear();
        logger.removeAllChildren(domTree);
        inspectorConnectionId = msg.id;
        return true;
    }
});

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

const nodeFromDomEntry = function(entry) {
    var node, value;
    var li = document.createElement('li');
    li.setAttribute('id', entry.nid);
    // expander/collapser
    li.appendChild(document.createElement('span'));
    // selector
    node = document.createElement('code');
    node.textContent = entry.sel;
    li.appendChild(node);
    // descendant count
    value = entry.cnt || 0;
    node = document.createElement('span');
    node.textContent = value !== 0 ? value.toLocaleString() : '';
    node.setAttribute('data-cnt', value);
    li.appendChild(node);
    // cosmetic filter
    if ( entry.filter === undefined ) {
        return li;
    }
    node = document.createElement('code');
    node.classList.add('filter');
    value = filterToIdMap.get(entry.filter);
    if ( value === undefined ) {
        value = uidGenerator.toString();
        filterToIdMap.set(entry.filter, value);
        uidGenerator += 1;
    }
    node.setAttribute('data-filter-id', value);
    node.textContent = entry.filter;
    li.appendChild(node);
    li.classList.add('isCosmeticHide');
    return li;
};

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

const appendListItem = function(ul, li) {
    ul.appendChild(li);
    // Ancestor nodes of a node which is affected by a cosmetic filter will
    // be marked as "containing cosmetic filters", for user convenience.
    if ( li.classList.contains('isCosmeticHide') === false ) { return; }
    for (;;) {
        li = li.parentElement.parentElement;
        if ( li === null ) { break; }
        li.classList.add('hasCosmeticHide');
    }
};

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

const renderDOMFull = function(response) {
    var domTreeParent = domTree.parentElement;
    var ul = domTreeParent.removeChild(domTree);
    logger.removeAllChildren(domTree);

    filterToIdMap.clear();

    var lvl = 0;
    var entries = response.layout;
    var n = entries.length;
    var li, entry;
    for ( var i = 0; i < n; i++ ) {
        entry = entries[i];
        if ( entry.lvl === lvl ) {
            li = nodeFromDomEntry(entry);
            appendListItem(ul, li);
            continue;
        }
        if ( entry.lvl > lvl ) {
            ul = document.createElement('ul');
            li.appendChild(ul);
            li.classList.add('branch');
            li = nodeFromDomEntry(entry);
            appendListItem(ul, li);
            lvl = entry.lvl;
            continue;
        }
        // entry.lvl < lvl
        while ( entry.lvl < lvl ) {
            ul = li.parentNode;
            li = ul.parentNode;
            ul = li.parentNode;
            lvl -= 1;
        }
        li = nodeFromDomEntry(entry);
        appendListItem(ul, li);
    }
    while ( ul.parentNode !== null ) {
        ul = ul.parentNode;
    }
    ul.firstElementChild.classList.add('show');

    domTreeParent.appendChild(domTree);
};

// https://www.youtube.com/watch?v=IDGNA83mxDo

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

const patchIncremental = function(from, delta) {
    var span, cnt;
    var li = from.parentElement.parentElement;
    var patchCosmeticHide = delta >= 0 &&
                            from.classList.contains('isCosmeticHide') &&
                            li.classList.contains('hasCosmeticHide') === false;
    // Include descendants count when removing a node
    if ( delta < 0 ) {
        delta -= countFromNode(from);
    }
    for ( ; li.localName === 'li'; li = li.parentElement.parentElement ) {
        span = li.children[2];
        if ( delta !== 0 ) {
            cnt = countFromNode(li) + delta;
            span.textContent = cnt !== 0 ? cnt.toLocaleString() : '';
            span.setAttribute('data-cnt', cnt);
        }
        if ( patchCosmeticHide ) {
            li.classList.add('hasCosmeticHide');
        }
    }
};

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

const renderDOMIncremental = function(response) {
    // Process each journal entry:
    //  1 = node added
    // -1 = node removed
    var journal = response.journal;
    var nodes = new Map(response.nodes);
    var entry, previous, li, ul;
    for ( var i = 0, n = journal.length; i < n; i++ ) {
        entry = journal[i];
        // Remove node
        if ( entry.what === -1 ) {
            li = document.getElementById(entry.nid);
            if ( li === null ) { continue; }
            patchIncremental(li, -1);
            li.parentNode.removeChild(li);
            continue;
        }
        // Modify node
        if ( entry.what === 0 ) {
            // TODO: update selector/filter
            continue;
        }
        // Add node as sibling
        if ( entry.what === 1 && entry.l ) {
            previous = document.getElementById(entry.l);
            // This should not happen
            if ( previous === null ) {
                // throw new Error('No left sibling!?');
                continue;
            }
            ul = previous.parentElement;
            li = nodeFromDomEntry(nodes.get(entry.nid));
            ul.insertBefore(li, previous.nextElementSibling);
            patchIncremental(li, 1);
            continue;
        }
        // Add node as child
        if ( entry.what === 1 && entry.u ) {
            li = document.getElementById(entry.u);
            // This should not happen
            if ( li === null ) {
                // throw new Error('No parent!?');
                continue;
            }
            ul = li.querySelector('ul');
            if ( ul === null ) {
                ul = document.createElement('ul');
                li.appendChild(ul);
                li.classList.add('branch');
            }
            li = nodeFromDomEntry(nodes.get(entry.nid));
            ul.appendChild(li);
            patchIncremental(li, 1);
            continue;
        }
    }
};

// https://www.youtube.com/watch?v=6u2KPtJB9h8

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

const countFromNode = function(li) {
    var span = li.children[2];
    var cnt = parseInt(span.getAttribute('data-cnt'), 10);
    return isNaN(cnt) ? 0 : cnt;
};

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

const selectorFromNode = function(node) {
    var selector = '';
    var code;
    while ( node !== null ) {
        if ( node.localName === 'li' ) {
            code = node.querySelector('code');
            if ( code !== null ) {
                selector = code.textContent + ' > ' + selector;
                if ( selector.indexOf('#') !== -1 ) {
                    break;
                }
            }
        }
        node = node.parentElement;
    }
    return selector.slice(0, -3);
};

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

const selectorFromFilter = function(node) {
    while ( node !== null ) {
        if ( node.localName === 'li' ) {
            var code = node.querySelector('code:nth-of-type(2)');
            if ( code !== null ) {
                return code.textContent;
            }
        }
        node = node.parentElement;
    }
    return '';
};

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

const nidFromNode = function(node) {
    var li = node;
    while ( li !== null ) {
        if ( li.localName === 'li' ) {
            return li.id || '';
        }
        li = li.parentElement;
    }
    return '';
};

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

const startDialog = (function() {
    let dialog;
    let textarea;
    let hideSelectors = [];
    let unhideSelectors = [];
    let inputTimer;

    const onInputChanged = (function() {
        const parse = function() {
            inputTimer = undefined;
            hideSelectors = [];
            unhideSelectors = [];

            const re = /^([^#]*)(#@?#)(.+)$/;
            for ( let line of textarea.value.split(/\s*\n\s*/) ) {
                line = line.trim();
                if ( line === '' || line.charAt(0) === '!' ) { continue; }
                const matches = re.exec(line);
                if ( matches === null || matches.length !== 4 ) { continue; }
                if ( inspectedHostname.lastIndexOf(matches[1]) === -1 ) {
                    continue;
                }
                if ( matches[2] === '##' ) {
                    hideSelectors.push(matches[3]);
                } else {
                    unhideSelectors.push(matches[3]);
                }
            }

            showCommitted();
        };

        return function parseAsync() {
            if ( inputTimer === undefined ) {
                inputTimer = vAPI.setTimeout(parse, 743);
            }
        };
    })();

    const onClicked = function(ev) {
        var target = ev.target;

        ev.stopPropagation();

        if ( target.id === 'createCosmeticFilters' ) {
            messaging.send('loggerUI', {
                what: 'createUserFilter',
                filters: textarea.value,
            });
            // Force a reload for the new cosmetic filter(s) to take effect
            messaging.send('loggerUI', {
                what: 'reloadTab',
                tabId: inspectedTabId,
            });
            return stop();
        }
    };

    const showCommitted = function() {
        vAPI.MessagingConnection.sendTo(inspectorConnectionId, {
            what: 'showCommitted',
            hide: hideSelectors.join(',\n'),
            unhide: unhideSelectors.join(',\n')
        });
    };

    const showInteractive = function() {
        vAPI.MessagingConnection.sendTo(inspectorConnectionId, {
            what: 'showInteractive',
            hide: hideSelectors.join(',\n'),
            unhide: unhideSelectors.join(',\n')
        });
    };

    const start = function() {
        dialog = logger.modalDialog.create('#cosmeticFilteringDialog', stop);
        textarea = dialog.querySelector('textarea');
        hideSelectors = [];
        for ( const node of domTree.querySelectorAll('code.off') ) {
            if ( node.classList.contains('filter') ) { continue; }
            hideSelectors.push(selectorFromNode(node));
        }
        const taValue = [];
        for ( const selector of hideSelectors ) {
            taValue.push(inspectedHostname + '##' + selector);
        }
        const ids = new Set();
        for ( const node of domTree.querySelectorAll('code.filter.off') ) {
            const id = node.getAttribute('data-filter-id');
            if ( ids.has(id) ) { continue; }
            ids.add(id);
            unhideSelectors.push(node.textContent);
            taValue.push(inspectedHostname + '#@#' + node.textContent);
        }
        textarea.value = taValue.join('\n');
        textarea.addEventListener('input', onInputChanged);
        dialog.addEventListener('click', onClicked, true);
        showCommitted();
        logger.modalDialog.show();
    };

    const stop = function() {
        if ( inputTimer !== undefined ) {
            clearTimeout(inputTimer);
            inputTimer = undefined;
        }
        showInteractive();
        textarea.removeEventListener('input', onInputChanged);
        dialog.removeEventListener('click', onClicked, true);
        dialog = undefined;
        textarea = undefined;
        hideSelectors = [];
        unhideSelectors = [];
    };

    return start;
})();

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

const onClicked = function(ev) {
    ev.stopPropagation();

    if ( inspectedTabId === 0 ) { return; }

    var target = ev.target;
    var parent = target.parentElement;

    // Expand/collapse branch
    if (
        target.localName === 'span' &&
        parent instanceof HTMLLIElement &&
        parent.classList.contains('branch') &&
        target === parent.firstElementChild
    ) {
        var state = parent.classList.toggle('show');
        if ( !state ) {
            for ( var node of parent.querySelectorAll('.branch') ) {
                node.classList.remove('show');
            }
        }
        return;
    }

    // Not a node or filter 
    if ( target.localName !== 'code' ) { return; }

    // Toggle cosmetic filter
    if ( target.classList.contains('filter') ) {
        vAPI.MessagingConnection.sendTo(inspectorConnectionId, {
            what: 'toggleFilter',
            original: false,
            target: target.classList.toggle('off'),
            selector: selectorFromNode(target),
            filter: selectorFromFilter(target),
            nid: nidFromNode(target)
        });
        uDom('[data-filter-id="' + target.getAttribute('data-filter-id') + '"]', inspector).toggleClass(
            'off',
            target.classList.contains('off')
        );
    }
    // Toggle node
    else {
        vAPI.MessagingConnection.sendTo(inspectorConnectionId, {
            what: 'toggleNodes',
            original: true,
            target: target.classList.toggle('off') === false,
            selector: selectorFromNode(target),
            nid: nidFromNode(target)
        });
    }

    var cantCreate = domTree.querySelector('.off') === null;
    inspector.querySelector('.permatoolbar .revert').classList.toggle('disabled', cantCreate);
    inspector.querySelector('.permatoolbar .commit').classList.toggle('disabled', cantCreate);
};

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

const onMouseOver = (function() {
    var mouseoverTarget = null;
    var mouseoverTimer = null;

    var timerHandler = function() {
        mouseoverTimer = null;
        vAPI.MessagingConnection.sendTo(inspectorConnectionId, {
            what: 'highlightOne',
            selector: selectorFromNode(mouseoverTarget),
            nid: nidFromNode(mouseoverTarget),
            scrollTo: true
        });
    };

    return function(ev) {
        if ( inspectedTabId === 0 ) { return; }
        // Convenience: skip real-time highlighting if shift key is pressed.
        if ( ev.shiftKey ) { return; }
        // Find closest `li`
        var target = ev.target;
        while ( target !== null ) {
            if ( target.localName === 'li' ) { break; }
            target = target.parentElement;
        }
        if ( target === mouseoverTarget ) { return; }
        mouseoverTarget = target;
        if ( mouseoverTimer === null ) {
            mouseoverTimer = vAPI.setTimeout(timerHandler, 50);
        }
    };
})();

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

const currentTabId = function() {
    if ( showdomButton.classList.contains('active') === false ) { return 0; }
    return logger.tabIdFromPageSelector();
};

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

const injectInspector = function() {
    const tabId = currentTabId();
    if ( tabId <= 0 ) { return; }
    inspectedTabId = tabId;
    messaging.send('loggerUI', {
        what: 'scriptlet',
        tabId,
        scriptlet: 'dom-inspector',
    });
};

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

const shutdownInspector = function() {
    if ( inspectorConnectionId !== undefined ) {
        vAPI.MessagingConnection.disconnectFrom(inspectorConnectionId);
        inspectorConnectionId = undefined;
    }
    logger.removeAllChildren(domTree);
    inspector.classList.remove('vExpanded');
    inspectedTabId = 0;
};

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

const onTabIdChanged = function() {
    const tabId = currentTabId();
    if ( tabId <= 0 ) {
        return toggleOff();
    }
    if ( inspectedTabId !== tabId ) {
        shutdownInspector();
        injectInspector();
    }
};

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

const toggleVCompactView = function() {
    var state = inspector.classList.toggle('vExpanded');
    var branches = document.querySelectorAll('#domInspector li.branch');
    for ( var branch of branches ) {
        branch.classList.toggle('show', state);
    }
};

const toggleHCompactView = function() {
    inspector.classList.toggle('hCompact');
};

/******************************************************************************/
/*
var toggleHighlightMode = function() {
    vAPI.MessagingConnection.sendTo(inspectorConnectionId, {
        what: 'highlightMode',
        invert: uDom.nodeFromSelector('#domInspector .permatoolbar .highlightMode').classList.toggle('invert')
    });
};
*/
/******************************************************************************/

const revert = function() {
    uDom('#domTree .off').removeClass('off');
    vAPI.MessagingConnection.sendTo(
        inspectorConnectionId,
        { what: 'resetToggledNodes' }
    );
    inspector.querySelector('.permatoolbar .revert').classList.add('disabled');
    inspector.querySelector('.permatoolbar .commit').classList.add('disabled');
};

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

const toggleOn = function() {
    uDom.nodeFromId('inspectors').classList.add('dom');
    window.addEventListener('beforeunload', toggleOff);
    document.addEventListener('tabIdChanged', onTabIdChanged);
    domTree.addEventListener('click', onClicked, true);
    domTree.addEventListener('mouseover', onMouseOver, true);
    uDom.nodeFromSelector('#domInspector .vCompactToggler').addEventListener('click', toggleVCompactView);
    uDom.nodeFromSelector('#domInspector .hCompactToggler').addEventListener('click', toggleHCompactView);
    //uDom.nodeFromSelector('#domInspector .permatoolbar .highlightMode').addEventListener('click', toggleHighlightMode);
    uDom.nodeFromSelector('#domInspector .permatoolbar .revert').addEventListener('click', revert);
    uDom.nodeFromSelector('#domInspector .permatoolbar .commit').addEventListener('click', startDialog);
    injectInspector();
};

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

const toggleOff = function() {
    showdomButton.classList.remove('active');
    uDom.nodeFromId('inspectors').classList.remove('dom');
    shutdownInspector();
    window.removeEventListener('beforeunload', toggleOff);
    document.removeEventListener('tabIdChanged', onTabIdChanged);
    domTree.removeEventListener('click', onClicked, true);
    domTree.removeEventListener('mouseover', onMouseOver, true);
    uDom.nodeFromSelector('#domInspector .vCompactToggler').removeEventListener('click', toggleVCompactView);
    uDom.nodeFromSelector('#domInspector .hCompactToggler').removeEventListener('click', toggleHCompactView);
    //uDom.nodeFromSelector('#domInspector .permatoolbar .highlightMode').removeEventListener('click', toggleHighlightMode);
    uDom.nodeFromSelector('#domInspector .permatoolbar .revert').removeEventListener('click', revert);
    uDom.nodeFromSelector('#domInspector .permatoolbar .commit').removeEventListener('click', startDialog);
    inspectedTabId = 0;
};

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

const toggle = function() {
    if ( showdomButton.classList.toggle('active') ) {
        toggleOn();
    } else {
        toggleOff();
    }
    logger.resize();
};

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

showdomButton.addEventListener('click', toggle);

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

})();
