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

    uMatrix - a browser extension to benchmark browser session.
    Copyright (C) 2015-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/sessbench
*/

/* global publicSuffixList, uDom, uMatrixScopeWidget */

'use strict';

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

(function() {

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

var tbody = document.querySelector('#content tbody');
var trJunkyard = [];
var tdJunkyard = [];
var firstVarDataCol = 1;  // currently, column 2 (0-based index)
var lastVarDataIndex =
    document.querySelector('#content colgroup').childElementCount - 1;
var maxEntries = 0;
var noTabId = '';
var pageStores = new Map();
var pageStoresToken;
var ownerId = Date.now();

var emphasizeTemplate = document.querySelector('#emphasizeTemplate > span');

var prettyRequestTypes = {
    'main_frame': 'doc',
    'stylesheet': 'css',
    'sub_frame': 'frame',
    'xmlhttprequest': 'xhr'
};

var dontEmphasizeSet = new Set([
    'COOKIE',
    'CSP',
    'REFERER'
]);

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

// Adjust top padding of content table, to match that of toolbar height.

document.getElementById('content').style.setProperty(
    'margin-top',
    document.getElementById('toolbar').clientHeight + 'px'
);

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

let removeChildren = function(node) {
    while ( node.firstChild ) {
        node.removeChild(node.firstChild);
    }
};

let removeSelf = function(node) {
    let parent = node && node.parentNode;
    if ( parent ) {
        parent.removeChild(node);
    }
};

let prependChild = function(parent, child) {
    parent.insertBefore(child, parent.firstElementChild);
};

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

// We will lookup domains locally.

let domainFromSrcHostname = (function() {
    let srcHn = '', srcDn = '';
    return function(hn) {
        if ( hn !== srcHn ) {
            srcHn = hn;
            srcDn = publicSuffixList.getDomain(hn);
        }
        return srcDn;
    };
})();

let domainFromDesHostname = (function() {
    let desHn = '', desDn = '';
    return function(hn) {
        if ( hn !== desHn ) {
            desHn = hn;
            desDn = publicSuffixList.getDomain(hn);
        }
        return desDn;
     };
})();

let is3rdParty = function(srcHn, desHn) {
    return domainFromSrcHostname(srcHn) !== domainFromDesHostname(desHn);
};

vAPI.messaging.send(
    'logger-ui.js',
    { what: 'getPublicSuffixListData' },
    response => {
        publicSuffixList.fromSelfie(response);
    }
);

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

// Emphasize hostname and cookie name.

var emphasizeCookie = function(s) {
    var pnode = emphasizeHostname(s);
    if ( pnode.childNodes.length !== 3 ) {
        return pnode;
    }
    var prefix = '-cookie:';
    var text = pnode.childNodes[2].textContent;
    var beg = text.indexOf(prefix);
    if ( beg === -1 ) {
        return pnode;
    }
    beg += prefix.length;
    var end = text.indexOf('}', beg);
    if ( end === -1 ) {
        return pnode;
    }
    var cnode = emphasizeTemplate.cloneNode(true);
    cnode.childNodes[0].textContent = text.slice(0, beg);
    cnode.childNodes[1].textContent = text.slice(beg, end);
    cnode.childNodes[2].textContent = text.slice(end);
    pnode.replaceChild(cnode.childNodes[0], pnode.childNodes[2]);
    pnode.appendChild(cnode.childNodes[0]);
    pnode.appendChild(cnode.childNodes[0]);
    return pnode;
};

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

// Emphasize hostname in URL.

var emphasizeHostname = function(url) {
    var hnbeg = url.indexOf('://');
    if ( hnbeg === -1 ) {
        return document.createTextNode(url);
    }
    hnbeg += 3;

    var hnend = url.indexOf('/', hnbeg);
    if ( hnend === -1 ) {
        hnend = url.slice(hnbeg).search(/\?#/);
        if ( hnend !== -1 ) {
            hnend += hnbeg;
        } else {
            hnend = url.length;
        }
    }

    var node = emphasizeTemplate.cloneNode(true);
    node.childNodes[0].textContent = url.slice(0, hnbeg);
    node.childNodes[1].textContent = url.slice(hnbeg, hnend);
    node.childNodes[2].textContent = url.slice(hnend);
    return node;
};

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

var createCellAt = function(tr, index) {
    var td = tr.cells[index];
    var mustAppend = !td;
    if ( mustAppend ) {
        td = tdJunkyard.pop();
    }
    if ( td ) {
        td.removeAttribute('colspan');
        td.textContent = '';
    } else {
        td = document.createElement('td');
    }
    if ( mustAppend ) {
        tr.appendChild(td);
    }
    return td;
};

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

var createRow = function(layout) {
    let tr = trJunkyard.pop();
    if ( tr ) {
        tr.className = '';
    } else {
        tr = document.createElement('tr');
    }
    let index;
    for ( index = 0; index < firstVarDataCol; index++ ) {
        createCellAt(tr, index);
    }
    let i = 1, span = 1;
    let td;
    for (;;) {
        td = createCellAt(tr, index);
        if ( i === lastVarDataIndex ) { break; }
        if ( layout.charAt(i) !== '1' ) {
            span += 1;
        } else {
            if ( span !== 1 ) {
                td.setAttribute('colspan', span);
            }
            index += 1;
            span = 1;
        }
        i += 1;
    }
    if ( span !== 1 ) {
        td.setAttribute('colspan', span);
    }
    index += 1;
    for (;;) {
        td = tr.cells[index];
        if ( !td ) { break; }
        tdJunkyard.push(tr.removeChild(td));
    }
    tr.removeAttribute('data-tabid');
    tr.removeAttribute('data-srchn');
    tr.removeAttribute('data-deshn');
    tr.removeAttribute('data-type');
    return tr;
};

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

var padTo2 = function(v) {
    return v < 10 ? '0' + v : v;
};

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

var createGap = function(tabId, url) {
    var tr = createRow('1');
    tr.classList.add('doc');
    tr.classList.add('tab');
    tr.classList.add('canMtx');
    tr.setAttribute('data-tabid', tabId);
    tr.cells[firstVarDataCol].textContent = url;
    tbody.insertBefore(tr, tbody.firstChild);
};

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

var renderLogEntry = function(entry) {
    let details;
    try {
        details = JSON.parse(entry.details);
    } catch(ex) {
        console.error(ex);
    }
    if ( details instanceof Object === false ) { return; }

    let tr;
    let fvdc = firstVarDataCol;

    if ( details.error !== undefined ) {
        tr = createRow('1');
        tr.classList.add('cat_error');
        tr.cells[fvdc].textContent = details.error;
    } else if ( details.info !== undefined ) {
        tr = createRow('1');
        tr.classList.add('cat_info');
        if ( details.prettify === 'cookie' ) {
            tr.cells[fvdc].appendChild(emphasizeCookie(details.info));
        } else {
            tr.cells[fvdc].textContent = details.info;
        }
    } else if ( details.srcHn !== undefined && details.desHn !== undefined ) {
        tr = createRow('11111');
        tr.classList.add('canMtx');
        tr.classList.add('cat_net');
        tr.setAttribute('data-srchn', details.srcHn);
        tr.setAttribute('data-deshn', details.desHn);
        tr.setAttribute('data-type', details.type);
        // If the request is that of a root frame, insert a gap in the table
        // in order to visually separate entries for different documents. 
        if ( details.type === 'doc' && details.tabId !== noTabId ) {
            createGap(details.tabId, details.desURL);
        }
        tr.cells[fvdc+0].textContent = details.srcHn;
        if ( details.blocked ) {
            tr.classList.add('blocked');
            tr.cells[fvdc+1].textContent = '--';
        } else {
            tr.cells[fvdc+1].textContent = '';
        }
        tr.cells[fvdc+2].textContent =
            prettyRequestTypes[details.type] || details.type;
        if ( dontEmphasizeSet.has(details.type) ) {
            tr.cells[fvdc+3].textContent = details.desURL;
        } else {
            tr.cells[fvdc+3].appendChild(emphasizeHostname(details.desURL));
        }
        tr.cells[fvdc+4].textContent =
            is3rdParty(details.srcHn, details.desHn) ? '3p' : '';
    } else if ( details.header ) {
        tr = createRow('11111');
        tr.classList.add('canMtx');
        tr.classList.add('cat_net');
        tr.cells[fvdc+0].textContent = details.srcHn || '';
        if ( details.change === -1 ) {
            tr.classList.add('blocked');
            tr.cells[fvdc+1].textContent = '--';
        } else {
            tr.cells[fvdc+1].textContent = '';
        }
        tr.cells[fvdc+2].textContent = details.header.name;
        tr.cells[fvdc+3].textContent = details.header.value;
        tr.cells[fvdc+4].textContent = '';
    } else {
        tr = createRow('1');
        tr.cells[fvdc].textContent = 'huh?';
    }

    // Fields common to all rows.
    let time = logDate;
    time.setTime(entry.tstamp - logDateTimezoneOffset);
    tr.cells[0].textContent = padTo2(time.getUTCHours()) + ':' +
                              padTo2(time.getUTCMinutes()) + ':' +
                              padTo2(time.getSeconds());

    if ( details.tabId ) {
        tr.classList.add('tab');
        tr.setAttribute('data-tabid', details.tabId);
    } else {
        tr.removeAttribute('data-tabid');
    }

    rowFilterer.filterOne(tr, true);

    tbody.insertBefore(tr, tbody.firstChild);
};

// Reuse date objects.
var logDate = new Date(),
    logDateTimezoneOffset = logDate.getTimezoneOffset() * 60000;

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

var renderLogEntries = function(response) {
    let entries = response.entries;
    if ( entries.length === 0 ) { return; }

    // Preserve scroll position
    let height = tbody.offsetHeight;

    for ( let i = 0, n = entries.length; i < n; i++ ) {
        renderLogEntry(entries[i]);
    }

    // Prevent logger from growing infinitely and eating all memory. For
    // instance someone could forget that it is left opened for some
    // dynamically refreshed pages.
    truncateLog(maxEntries);

    let yDelta = tbody.offsetHeight - height;
    if ( yDelta === 0 ) { return; }

    // Chromium:
    //   body.scrollTop = good value
    //   body.parentNode.scrollTop = 0
    if ( document.body.scrollTop !== 0 ) {
        document.body.scrollTop += yDelta;
        return;
    }

    // Firefox:
    //   body.scrollTop = 0
    //   body.parentNode.scrollTop = good value
    let parentNode = document.body.parentNode;
    if ( parentNode && parentNode.scrollTop !== 0 ) {
        parentNode.scrollTop += yDelta;
    }
};

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

var synchronizeTabIds = function(newPageStores) {
    let oldPageStores = pageStores;
    let autoDeleteVoidRows = !!vAPI.localStorage.getItem('loggerAutoDeleteVoidRows');
    let rowVoided = false;
    for ( let tabId of oldPageStores.keys() ) {
        if ( newPageStores.has(tabId) ) { continue; }
        // Mark or remove voided rows
        let trs = uDom('[data-tabid="' + tabId + '"]');
        if ( autoDeleteVoidRows ) {
            toJunkyard(trs);
        } else {
            trs.removeClass('canMtx');
            rowVoided = true;
        }
    }

    let select = document.getElementById('pageSelector');
    let selectValue = select.value;
    let tabIds = Array.from(newPageStores.keys()).sort(function(a, b) {
        return newPageStores.get(a).localeCompare(newPageStores.get(b));
    });
    for ( var i = 0, j = 2; i < tabIds.length; i++ ) {
        let tabId = tabIds[i];
        if ( tabId === noTabId ) { continue; }
        let option = select.options[j];
        j += 1;
        if ( !option ) {
            option = document.createElement('option');
            select.appendChild(option);
        }
        option.textContent = newPageStores.get(tabId);
        option.value = tabId;
        if ( option.value === selectValue ) {
            option.setAttribute('selected', '');
        } else {
            option.removeAttribute('selected');
        }
    }
    while ( j < select.options.length ) {
        select.removeChild(select.options[j]);
    }
    if ( select.value !== selectValue ) {
        select.selectedIndex = 0;
        select.value = '';
        select.options[0].setAttribute('selected', '');
        pageSelectorChanged();
    }

    pageStores = newPageStores;

    return rowVoided;
};

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

var truncateLog = function(size) {
    if ( size === 0 ) {
        size = 5000;
    }
    var tbody = document.querySelector('#content tbody');
    size = Math.min(size, 10000);
    var tr;
    while ( tbody.childElementCount > size ) {
        tr = tbody.lastElementChild;
        trJunkyard.push(tbody.removeChild(tr));
    }
};

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

var onLogBufferRead = function(response) {
    if ( !response || response.unavailable ) {
        readLogBufferAsync();
        return;
    }

    // This tells us the behind-the-scene tab id
    noTabId = response.noTabId;

    // This may have changed meanwhile
    if ( response.maxLoggedRequests !== maxEntries ) {
        maxEntries = response.maxLoggedRequests;
        uDom('#maxEntries').val(maxEntries || '');
    }

    // Neuter rows for which a tab does not exist anymore
    var rowVoided = false;
    if ( response.pageStoresToken !== pageStoresToken ) {
        if ( Array.isArray(response.pageStores) ) {
            rowVoided = synchronizeTabIds(new Map(response.pageStores));
        }
        pageStoresToken = response.pageStoresToken;
    }

    renderLogEntries(response);

    if ( rowVoided ) {
        uDom('#clean').toggleClass(
            'disabled',
            tbody.querySelector('tr.tab:not(.canMtx)') === null
        );
    }

    // Synchronize toolbar with content of log
    uDom('#clear').toggleClass(
        'disabled',
        tbody.querySelector('tr') === null
    );

    readLogBufferAsync();
};

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

// This can be called only once, at init time. After that, this will be called
// automatically. If called after init time, this will be messy, and this would
// require a bit more code to ensure no multi time out events.

var readLogBuffer = function() {
    if ( ownerId === undefined ) { return; }
    vAPI.messaging.send(
        'logger-ui.js',
        {
            what: 'readMany',
            ownerId: ownerId,
            pageStoresToken: pageStoresToken
        },
        onLogBufferRead
    );
};

var readLogBufferAsync = function() {
    if ( ownerId === undefined ) { return; }
    vAPI.setTimeout(readLogBuffer, 1200);
};

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

var tabIdFromPageSelector = function() {
    let tabId = parseInt(document.getElementById('pageSelector').value, 10);
    return isNaN(tabId) === false ? tabId : 0;
};

var pageSelectorChanged = function() {
    let style = document.getElementById('tabFilterer');
    let tabId = tabIdFromPageSelector();
    let sheet = style.sheet;
    while ( sheet.cssRules.length !== 0 )  {
        sheet.deleteRule(0);
    }
    if ( tabId !== 0 ) {
        sheet.insertRule(
            '#content table tr:not([data-tabid="' + tabId + '"]) { display: none; }',
            0
        );
    }
    document.getElementById('reloadTab').classList.toggle('disabled', tabId <= 0);
    document.getElementById('popupPanelButton').classList.toggle('disabled', tabId <= 0);
    popupPanel.update();
};

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

var reloadTab = function(ev) {
    let tabId = tabIdFromPageSelector();
    if ( tabId <= 0 ) { return; }
    vAPI.messaging.send(
        'default',
        {
            what: 'forceReloadTab',
            tabId: tabId,
            bypassCache: ev && (ev.ctrlKey || ev.metaKey || ev.shiftKey)
        }
    );
};

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

var onMaxEntriesChanged = function() {
    var raw = uDom(this).val();
    try {
        maxEntries = parseInt(raw, 10);
        if ( isNaN(maxEntries) ) {
            maxEntries = 0;
        }
    } catch (e) {
        maxEntries = 0;
    }

    vAPI.messaging.send('logger-ui.js', {
        what: 'userSettings',
        name: 'maxLoggedRequests',
        value: maxEntries
    });

    truncateLog(maxEntries);
};

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

var popupPanel = (function() {
    let tabId = 0;
    let popupObserver = null;
    let timer;

    let resizePopup = function() {
        if ( timer !== undefined ) {
            cancelAnimationFrame(timer);
            timer = undefined;
        }
        let container = document.getElementById('popupPanelContainer');
        let popup = container.querySelector('iframe');
        if ( popup === null ) { return; }
        let popupBody = popup.contentWindow.document.body;
        if (
            popupBody.clientWidth !== 0 &&
            container.clientWidth !== popupBody.clientWidth
        ) {
            container.style.setProperty('width', popupBody.clientWidth + 'px');
        }
        popup.style.removeProperty('height');
        if (
            popupBody.clientHeight !== 0 &&
            popup.clientHeight !== popupBody.clientHeight
        ) {
            popup.style.setProperty('height', popupBody.clientHeight + 'px');
        }
        let ph = document.documentElement.clientHeight;
        let crect = container.getBoundingClientRect();
        if ( crect.height > ph ) {
            popup.style.setProperty('height', 'calc(' + ph + 'px - 1.8em)');
        }
        // Adjust width for presence/absence of vertical scroll bar which may
        // have appeared as a result of last operation.
        let cw = container.clientWidth;
        let dw = popup.contentWindow.document.documentElement.clientWidth;
        if ( cw !== dw ) {
            container.style.setProperty('width', 2 * cw - dw + 'px');
        }
    };

    let onResizeRequested = function() {
        let popup = document.querySelector('#popupPanelContainer iframe');
        if ( popup === null ) { return; }
        let popupBody = popup.contentWindow.document.body;
        if ( popupBody.hasAttribute('data-resize-popup') === false ) { return; }
        popupBody.removeAttribute('data-resize-popup');
        if ( timer === undefined ) {
            timer = requestAnimationFrame(( ) => {
                timer = undefined;
                resizePopup();
            });
        }
    };

    let onLoad = function() {
        resizePopup();
        let popup = document.querySelector('#popupPanelContainer iframe');
        if ( popup === null ) { return; }
        let popupBody = popup.contentDocument.body;
        popupBody.removeAttribute('data-resize-popup');
        if ( popupObserver === null ) {
            popupObserver = new MutationObserver(onResizeRequested);
        }
        popupObserver.observe(popupBody, {
            attributes: true,
            attributesFilter: [ 'data-resize-popup' ]
        });
        document.body.classList.add('popupPanelOn');
    };

    let start = function() {
        let newTabId = tabIdFromPageSelector();
        if ( newTabId <= 0 ) {
            return stop();
        }
        if ( newTabId === tabId ) { return; }
        tabId = newTabId;
        let container = document.getElementById('popupPanelContainer');
        let popup = container.querySelector('iframe');
        if ( popup === null ) {
            popup = document.createElement('iframe');
            popup.addEventListener('load', onLoad);
        }
        if ( popupObserver !== null ) {
            popupObserver.disconnect();
        }
        popup.setAttribute('src', 'popup.html?tabId=' + tabId);
        if ( popup.parentNode !== null ) { return; }
        let rect = document.getElementById('toolbar').getBoundingClientRect();
        container.style.top = rect.height + 'px';
        container.appendChild(popup);
    };

    let stop = function() {
        document.body.classList.remove('popupPanelOn');
        popupObserver.disconnect();
        popupObserver = null;
        let popup = document.querySelector('#popupPanelContainer iframe');
        if ( popup !== null ) {
            popup.removeEventListener('load', onLoad);
            removeSelf(popup);
        }
        tabId = 0;
    };

    return {
        get tabId() {
            return tabId;
        },
        toggle: function(state) {
            if ( state === undefined ) {
                state = tabId === 0;
            }
            if ( state === false ) {
                stop();
            } else {
                start();
            }
        },
        update: function() {
            if ( tabId !== 0 ) {
                start();
            }
        }
    };
})();

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

var rowFilterer = (function() {
    var filters = [];

    var parseInput = function() {
        filters = [];

        var rawPart, hardBeg, hardEnd;
        var raw = uDom('#filterInput').val().trim();
        var rawParts = raw.split(/\s+/);
        var reStr, reStrs = [], not = false;
        var n = rawParts.length;
        for ( var i = 0; i < n; i++ ) {
            rawPart = rawParts[i];
            if ( rawPart.charAt(0) === '!' ) {
                if ( reStrs.length === 0 ) {
                    not = true;
                }
                rawPart = rawPart.slice(1);
            }
            hardBeg = rawPart.charAt(0) === '|';
            if ( hardBeg ) {
                rawPart = rawPart.slice(1);
            }
            hardEnd = rawPart.slice(-1) === '|';
            if ( hardEnd ) {
                rawPart = rawPart.slice(0, -1);
            }
            if ( rawPart === '' ) {
                continue;
            }
            // https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions
            reStr = rawPart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
            if ( hardBeg ) {
                reStr = '(?:^|\\s)' + reStr;
            }
            if ( hardEnd ) {
                reStr += '(?:\\s|$)';
            }
            reStrs.push(reStr);
            if ( i < (n - 1) && rawParts[i + 1] === '||' ) {
                i += 1;
                continue;
            }
            reStr = reStrs.length === 1 ? reStrs[0] : reStrs.join('|');
            filters.push({
                re: new RegExp(reStr, 'i'),
                r: !not
            });
            reStrs = [];
            not = false;
        }
    };

    var filterOne = function(tr, clean) {
        var ff = filters;
        var fcount = ff.length;
        if ( fcount === 0 && clean === true ) {
            return;
        }
        // do not filter out doc boundaries, they help separate important
        // section of log.
        var cl = tr.classList;
        if ( cl.contains('doc') ) {
            return;
        }
        if ( fcount === 0 ) {
            cl.remove('f');
            return;
        }
        var cc = tr.cells;
        var ccount = cc.length;
        var hit, j, f;
        // each filter expression must hit (implicit and-op)
        // if...
        //   positive filter expression = there must one hit on any field
        //   negative filter expression = there must be no hit on all fields
        for ( var i = 0; i < fcount; i++ ) {
            f = ff[i];
            hit = !f.r;
            for ( j = 0; j < ccount; j++ ) {
                if ( f.re.test(cc[j].textContent) ) {
                    hit = f.r;
                    break;
                }
            }
            if ( !hit ) {
                cl.add('f');
                return;
            }
        }
        cl.remove('f');
    };

    var filterAll = function() {
        // Special case: no filter
        if ( filters.length === 0 ) {
            uDom('#content tr').removeClass('f');
            return;
        }
        var tbody = document.querySelector('#content tbody');
        var rows = tbody.rows;
        var i = rows.length;
        while ( i-- ) {
            filterOne(rows[i]);
        }
    };

    var onFilterChangedAsync = (function() {
        var timer = null;
        var commit = function() {
            timer = null;
            parseInput();
            filterAll();
        };
        return function() {
            if ( timer !== null ) {
                clearTimeout(timer);
            }
            timer = vAPI.setTimeout(commit, 750);
        };
    })();

    var onFilterButton = function() {
        var cl = document.body.classList;
        cl.toggle('f', cl.contains('f') === false);
    };

    uDom('#filterButton').on('click', onFilterButton);
    uDom('#filterInput').on('input', onFilterChangedAsync);

    return {
        filterOne: filterOne,
        filterAll: filterAll
    };
})();

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

var ruleEditor = (function() {
    let ruleEditorNode = document.getElementById('ruleEditor');
    let ruleActionPicker = document.getElementById('ruleActionPicker');
    let listeners = [];

    let addListener = function(node, type, handler, bits) {
        let options;
        if ( typeof bits === 'number' && (bits & 0b11) !== 0 ) {
            options = {};
            if ( bits & 0b01 ) {
                options.capture = true;
            }
            if ( bits & 0b10 ) {
                options.passive = true;
            }
        }
        listeners.push({ node, type, handler, options });
        return node.addEventListener(type, handler, options);
    };

    let setup = function(details) {
        ruleEditorNode.setAttribute('data-tabid', details.tabId);
        ruleEditorNode.classList.toggle(
            'colorblind',
            details.options.colorBlindFriendly === true
        );

        // Initialize scope selector
        let srcDn = domainFromSrcHostname(details.srcHn);
        let scope = details.options.popupScopeLevel === '*' ?
            '*' :
            details.options.popupScopeLevel === 'domain' ?
                srcDn :
                details.srcHn;
        uMatrixScopeWidget.init(srcDn, details.srcHn, scope, ruleEditorNode);

        // Create rule rows
        let ruleWidgets = ruleEditorNode.querySelector('.ruleWidgets');
        removeChildren(ruleWidgets);
        let ruleWidgetTemplate =
            document.querySelector('#ruleRowTemplate .ruleRow');

        // Rules: specific to desHn, from broadest to narrowest
        let desHn = details.desHn;
        let desDn = domainFromDesHostname(desHn);
        for (;;) {
            let ruleRow = ruleWidgetTemplate.cloneNode(true);
            ruleRow.setAttribute('data-deshn', desHn);
            ruleRow.children[0].textContent = desHn;
            ruleRow.children[1].setAttribute('data-type', details.type);
            if ( desHn === details.desHn ) {
                ruleRow.children[1].textContent = '1';
            }
            prependChild(ruleWidgets, ruleRow);
            if ( desHn === desDn ) { break; }
            let pos = desHn.indexOf('.');
            if ( pos === -1 ) { break; }
            desHn = desHn.slice(pos + 1);
        }

        // Rules: 1st-party, if needed
        if ( desDn === srcDn ) {
            let ruleRow = ruleWidgetTemplate.cloneNode(true);
            ruleRow.setAttribute('data-deshn', '1st-party');
            ruleRow.children[0].textContent = '1st-party';
            ruleRow.children[1].setAttribute('data-type', details.type);
            prependChild(ruleWidgets, ruleRow);
        }

        // Rules: unspecific
        {
            let ruleRow = ruleWidgetTemplate.cloneNode(true);
            ruleRow.setAttribute('data-deshn', '*');
            ruleRow.children[0].textContent = 'all';
            ruleRow.children[1].setAttribute('data-type', details.type);
            ruleRow.children[1].textContent = details.type;
            prependChild(ruleWidgets, ruleRow);
        }

        colorize();

        addListener(ruleEditorNode, 'click', quitHandler, 0b01);
        addListener(window, 'uMatrixScopeWidgetChange', scopeChangeHandler);
        addListener(ruleWidgets, 'mouseenter', attachRulePicker, 0b11);
        addListener(ruleWidgets, 'mouseleave', removeRulePicker, 0b11);
        addListener(ruleActionPicker, 'click', rulePickerHandler, 0b11);
        addListener(ruleEditorNode.querySelector('#matrixReloadButton'), 'click', reload);
        addListener(ruleEditorNode.querySelector('#matrixRevertButton'), 'click', revert);
        addListener(ruleEditorNode.querySelector('#matrixPersistButton'), 'click', persist);

        document.body.appendChild(ruleEditorNode);
    };

    let colorize = function() {
        let srcHn = uMatrixScopeWidget.getScope();
        let ruleCells = ruleEditorNode.querySelectorAll('.ruleCell');
        let ruleParts = [];
        for ( let ruleCell of ruleCells ) {
            ruleParts.push(
                srcHn,
                ruleCell.closest('.ruleRow').getAttribute('data-deshn'),
                ruleCell.getAttribute('data-type')
            );
        }
        vAPI.messaging.send(
            'default',
            { what: 'getCellColors', ruleParts },
            response => {
                let tColors = response.tColors,
                    pColors = response.pColors,
                    diffCount = 0;
                for ( let i = 0; i < ruleCells.length; i++ ) {
                    let ruleCell = ruleCells[i];
                    let tColor = tColors[i];
                    let pColor = pColors[i];
                    ruleCell.setAttribute('data-tcolor', tColor);
                    ruleCell.setAttribute('data-pcolor', pColor);
                    if ( tColor === pColor ) { continue; }
                    if ( tColor < 128 && pColor < 128 ) { continue; }
                    diffCount += 1;
                }
                let dirty = diffCount !== 0;
                ruleEditorNode
                    .querySelector('#matrixPersistButton .fa-icon-badge')
                    .textContent = dirty ? diffCount : '';
                ruleEditorNode
                    .querySelector('#matrixRevertButton')
                    .classList
                    .toggle('disabled', !dirty);
                ruleEditorNode
                    .querySelector('#matrixPersistButton')
                    .classList
                    .toggle('disabled', !dirty);
            }
        );
    };

    let quitHandler = function(ev) {
        let target = ev.target;
        if ( target.classList.contains('modalDialog') ) {
            stop();
        }
    };

    let scopeChangeHandler = function() {
        colorize();
    };

    let attachRulePicker = function(ev) {
        let target = ev.target;
        if (
            target instanceof HTMLElement === false ||
            target.classList.contains('ruleCell') === false
        ) {
            return;
        }
        target.appendChild(ruleActionPicker);
    };

    let removeRulePicker = function(ev) {
        let target = ev.target;
        if (
            target instanceof HTMLElement === false ||
            ruleActionPicker.closest('.ruleCell') === target.closest('.ruleCell')
        ) {
            return;
        }
        removeSelf(ruleActionPicker);
    };

    let rulePickerHandler = function(ev) {
        let action = ev.target.className;
        if ( action !== 'allowRule' && action !== 'blockRule' ) { return; }
        let cell = ev.target.closest('.ruleCell');
        if ( cell === null ) { return; }
        let row = cell.closest('.ruleRow');
        let desHn = row.getAttribute('data-deshn');
        let type = cell.getAttribute('data-type');
        let color = parseInt(cell.getAttribute('data-tcolor'), 10);
        let what;
        if ( color === 1 || color === 2 ) {
            what = action === 'blockRule' ?
                'blacklistMatrixCell' :
                'whitelistMatrixCell';
        } else if ( desHn === '*' && type === '*' ) {
            what = color === 130 ?
                'blacklistMatrixCell' :
                'whitelistMatrixCell';
        } else {
            what = 'graylistMatrixCell';
        }
        let request = {
            what,
            srcHostname: uMatrixScopeWidget.getScope(),
            desHostname: desHn,
            type
        };
        vAPI.messaging.send('default', request, colorize);
    };


    let reload = function(ev) {
        vAPI.messaging.send('default', {
            what: 'forceReloadTab',
            tabId: parseInt(ruleEditorNode.getAttribute('data-tabid'), 10),
            bypassCache: ev && (ev.ctrlKey || ev.metaKey || ev.shiftKey)
        });
    };

    let diff = function() {
        let entries = [];
        let cells = ruleEditorNode.querySelectorAll('.ruleCell');
        let srcHn = uMatrixScopeWidget.getScope();
        for ( let cell of cells ) {
            let tColor = cell.getAttribute('data-tcolor');
            let pColor = cell.getAttribute('data-pcolor');
            if ( tColor === pColor || tColor < 128 && pColor < 128 ) {
                continue;
            }
            let row = cell.closest('.ruleRow');
            entries.push({
                srcHn,
                desHn: row.getAttribute('data-deshn'),
                type: cell.getAttribute('data-type')
            });
        }
        return entries;
    };

    let persist = function() {
        let entries = diff();
        if ( entries.length === 0 ) { return; }
        vAPI.messaging.send(
            'default',
            { what: 'rulesetPersist', entries },
            colorize
        );
    };

    let revert = function() {
        let entries = diff();
        if ( entries.length === 0 ) { return; }
        vAPI.messaging.send(
            'default',
            { what: 'rulesetRevert', entries },
            colorize
        );
    };

    let start = function(ev) {
        let targetRow = ev.target.parentElement;
        let srcHn = targetRow.getAttribute('data-srchn') || '';
        let desHn = targetRow.getAttribute('data-deshn') || '';
        let type = targetRow.getAttribute('data-type') || '';
        if ( srcHn === '' || desHn === '' || type === '' ) { return; }
        let tabId = parseInt(targetRow.getAttribute('data-tabid'), 10);

        vAPI.messaging.send(
            'logger-ui.js',
            { what: 'getRuleEditorOptions' },
            options => { setup({ tabId, srcHn, desHn, type, options }); }
        );
    };

    let stop = function() {
        for ( let { node, type, handler, options } of listeners ) {
            node.removeEventListener(type, handler, options);
        }
        listeners = [];
        ruleEditorNode
            .querySelector('#matrixReloadButton')
            .removeEventListener('click', reload);
        removeSelf(ruleEditorNode);
    };

    return { start, stop };
})();

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

var toJunkyard = function(trs) {
    trs.remove();
    var i = trs.length;
    while ( i-- ) {
        trJunkyard.push(trs.nodeAt(i));
    }
};

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

var clearBuffer = function() {
    var tbody = document.querySelector('#content tbody');
    var tr;
    while ( tbody.firstChild !== null ) {
        tr = tbody.lastElementChild;
        trJunkyard.push(tbody.removeChild(tr));
    }
    uDom('#clear').addClass('disabled');
    uDom('#clean').addClass('disabled');
};

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

var cleanBuffer = function() {
    var rows = uDom('#content tr.tab:not(.canMtx)').remove();
    var i = rows.length;
    while ( i-- ) {
        trJunkyard.push(rows.nodeAt(i));
    }
    uDom('#clean').addClass('disabled');
};

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

var toggleCompactView = function() {
    document.body.classList.toggle('compactView');
    uDom('#content table .vExpanded').removeClass('vExpanded');
};

var toggleCompactRow = function(ev) {
    ev.target.parentElement.classList.toggle('vExpanded');
};

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

var grabView = function() {
    if ( ownerId === undefined ) {
        ownerId = Date.now();
    }
    readLogBufferAsync();
};

var releaseView = function() {
    if ( ownerId === undefined ) { return; }
    vAPI.messaging.send(
        'logger-ui.js',
        { what: 'releaseView', ownerId: ownerId }
    );
    ownerId = undefined;
};

window.addEventListener('pagehide', releaseView);
window.addEventListener('pageshow', grabView);
// https://bugzilla.mozilla.org/show_bug.cgi?id=1398625
window.addEventListener('beforeunload', releaseView);

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

readLogBuffer();

uDom('#pageSelector').on('change', pageSelectorChanged);
uDom('#reloadTab').on('click', reloadTab);
uDom('#popupPanelButton').on('click', ( ) => popupPanel.toggle());
uDom('#compactViewToggler').on('click', toggleCompactView);
uDom('#clean').on('click', cleanBuffer);
uDom('#clear').on('click', clearBuffer);
uDom('#maxEntries').on('change', onMaxEntriesChanged);
uDom('#content table').on('click', 'tr > td:nth-of-type(1)', toggleCompactRow);
uDom('#content table').on('click', 'tr[data-srchn][data-deshn][data-type] > td:nth-of-type(3)', ruleEditor.start);

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

})();
