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

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

'use strict';

import { i18n, i18n$ } from './i18n.js';
import { dom, qs$, qsa$ } from './dom.js';

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

const lastUpdateTemplateString = i18n$('3pLastUpdate');
const obsoleteTemplateString = i18n$('3pExternalListObsolete');
const reValidExternalList = /^[a-z-]+:\/\/(?:\S+\/\S*|\/\S+)/m;
const recentlyUpdated = 1 * 60 * 60 * 1000; // 1 hour

let listsetDetails = {};

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

const messaging = vAPI.messaging;

vAPI.broadcastListener.add(msg => {
    switch ( msg.what ) {
    case 'assetUpdated':
        updateAssetStatus(msg);
        break;
    case 'assetsUpdated':
        dom.cl.remove(dom.body, 'updating');
        renderWidgets();
        break;
    case 'staticFilteringDataChanged':
        renderFilterLists();
        break;
    default:
        break;
    }
});

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

const renderNumber = value => {
    return value.toLocaleString();
};

const listStatsTemplate = i18n$('3pListsOfBlockedHostsPerListStats');

const renderLeafStats = (used, total) => {
    if ( isNaN(used) || isNaN(total) ) { return ''; }
    return listStatsTemplate
        .replace('{{used}}', renderNumber(used))
        .replace('{{total}}', renderNumber(total));
};

const renderNodeStats = (used, total) => {
    if ( isNaN(used) || isNaN(total) ) { return ''; }
    return `${used.toLocaleString()}/${total.toLocaleString()}`;
};

const i18nGroupName = name => {
    return i18n$('3pGroup' + name.charAt(0).toUpperCase() + name.slice(1));
};

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

const renderFilterLists = ( ) => {
    // Assemble a pretty list name if possible
    const listNameFromListKey = listkey => {
        const list = listsetDetails.current[listkey] || listsetDetails.available[listkey];
        const title = list && list.title || '';
        if ( title !== '' ) { return title; }
        return listkey;
    };

    const initializeListEntry = (listDetails, listEntry) => {
        const listkey = listEntry.dataset.key;
        const listEntryPrevious =
            qs$(`[data-key="${listDetails.group}"] [data-key="${listkey}"]`);
        if ( listEntryPrevious !== null ) {
            if ( dom.cl.has(listEntryPrevious, 'checked') ) {
                dom.cl.add(listEntry, 'checked');
            }
            if ( dom.cl.has(listEntryPrevious, 'stickied') ) {
                dom.cl.add(listEntry, 'stickied');
            }
            if ( dom.cl.has(listEntryPrevious, 'toRemove') ) {
                dom.cl.add(listEntry, 'toRemove');
            }
            if ( dom.cl.has(listEntryPrevious, 'searchMatch') ) {
                dom.cl.add(listEntry, 'searchMatch');
            }
        } else {
            dom.cl.toggle(listEntry, 'checked', listDetails.off !== true);
        }
        const on = dom.cl.has(listEntry, 'checked');
        dom.prop(qs$(listEntry, ':scope > .detailbar input'), 'checked', on);
        let elem = qs$(listEntry, ':scope > .detailbar a.content');
        dom.attr(elem, 'href', 'asset-viewer.html?url=' + encodeURIComponent(listkey));
        dom.attr(elem, 'type', 'text/html');
        dom.cl.remove(listEntry, 'toRemove');
        if ( listDetails.supportName ) {
            elem = qs$(listEntry, ':scope > .detailbar a.support');
            dom.attr(elem, 'href', listDetails.supportURL || '#');
            dom.attr(elem, 'title', listDetails.supportName);
        }
        if ( listDetails.external ) {
            dom.cl.add(listEntry, 'external');
        } else {
            dom.cl.remove(listEntry, 'external');
        }
        if ( listDetails.instructionURL ) {
            elem = qs$(listEntry, ':scope > .detailbar a.mustread');
            dom.attr(elem, 'href', listDetails.instructionURL || '#');
        }
        dom.cl.toggle(listEntry, 'isDefault',
            listDetails.isDefault === true || listkey === 'user-filters'
        );
        elem = qs$(listEntry, '.leafstats');
        dom.text(elem, renderLeafStats(on ? listDetails.entryUsedCount : 0, listDetails.entryCount));
        // https://github.com/chrisaljoudi/uBlock/issues/104
        const asset = listsetDetails.cache[listkey] || {};
        const remoteURL = asset.remoteURL;
        dom.cl.toggle(listEntry, 'unsecure',
            typeof remoteURL === 'string' && remoteURL.lastIndexOf('http:', 0) === 0
        );
        dom.cl.toggle(listEntry, 'failed', asset.error !== undefined);
        dom.cl.toggle(listEntry, 'obsolete', asset.obsolete === true);
        const lastUpdateString = lastUpdateTemplateString.replace('{{ago}}',
            i18n.renderElapsedTimeToString(asset.writeTime || 0)
        );
        if ( asset.obsolete === true ) {
            let title = obsoleteTemplateString;
            if ( asset.cached && asset.writeTime !== 0 ) {
                title += '\n' + lastUpdateString;
            }
            dom.attr(qs$(listEntry, ':scope > .detailbar .status.obsolete'), 'title', title);
        }
        if ( asset.cached === true ) {
            dom.cl.add(listEntry, 'cached');
            dom.attr(qs$(listEntry, ':scope > .detailbar .status.cache'), 'title', lastUpdateString);
            const timeSinceLastUpdate = Date.now() - asset.writeTime;
            dom.cl.toggle(listEntry, 'recent', timeSinceLastUpdate < recentlyUpdated);
        } else {
            dom.cl.remove(listEntry, 'cached');
        }
    };

    const createListEntry = (listDetails, depth) => {
        if ( listDetails.lists === undefined ) {
            return dom.clone('#templates .listEntry[data-role="leaf"]');
        }
        if ( depth !== 0 ) {
            return dom.clone('#templates .listEntry[data-role="node"]');
        }
        return dom.clone('#templates .listEntry[data-role="node"][data-parent="root"]');
    };

    const createListEntries = (parentkey, listTree, depth = 0) => {
        const listEntries = dom.clone('#templates .listEntries');
        const treeEntries = Object.entries(listTree);
        if ( depth !== 0 ) {
            const reEmojis = /\p{Emoji}+/gu;
            treeEntries.sort((a ,b) => {
                const as = (a[1].title || a[0]).replace(reEmojis, '');
                const bs = (b[1].title || b[0]).replace(reEmojis, '');
                return as.localeCompare(bs);
            });
        }
        for ( const [ listkey, listDetails ] of treeEntries ) {
            const listEntry = createListEntry(listDetails, depth);
            if ( dom.cl.has(dom.root, 'mobile') ) {
                const leafStats = qs$(listEntry, '.leafstats');
                if ( leafStats ) {
                    listEntry.append(leafStats);
                }
            }
            listEntry.dataset.key = listkey;
            listEntry.dataset.parent = parentkey;
            qs$(listEntry, ':scope > .detailbar .listname').append(
                i18n.patchUnicodeFlags(listDetails.title)
            );
            if ( listDetails.lists !== undefined ) {
                listEntry.append(createListEntries(listEntry.dataset.key, listDetails.lists, depth+1));
                dom.cl.toggle(listEntry, 'expanded', listIsExpanded(listkey));
                updateListNode(listEntry);
            } else {
                initializeListEntry(listDetails, listEntry);
            }
            listEntries.append(listEntry);
        }
        return listEntries;
    };

    const onListsReceived = response => {
        // Store in global variable
        listsetDetails = response;
        hashFromListsetDetails();

        // Build list tree
        const listTree = {};
        const groupKeys = [
            'user',
            'default',
            'ads',
            'privacy',
            'malware',
            'multipurpose',
            'annoyances',
            'regions',
            'custom'
        ];
        for ( const key of groupKeys ) {
            listTree[key] = {
                title: i18nGroupName(key),
                lists: {},
            };
        }
        for ( const [ listkey, listDetails ] of Object.entries(response.available) ) {
            let groupKey = listDetails.group;
            if ( groupKey === 'social' ) {
                groupKey = 'annoyances';
            }
            const groupDetails = listTree[groupKey];
            if ( listDetails.parent !== undefined ) {
                let lists = groupDetails.lists;
                for ( const parent of listDetails.parent.split('|') ) {
                    if ( lists[parent] === undefined ) {
                        lists[parent] = { title: parent, lists: {} };
                    }
                    lists = lists[parent].lists;
                }
                lists[listkey] = listDetails;
            } else {
                listDetails.title = listNameFromListKey(listkey);
                groupDetails.lists[listkey] = listDetails;
            }
        }
        const listEntries = createListEntries('root', listTree);
        qs$('#lists .listEntries').replaceWith(listEntries);

        qs$('#autoUpdate').checked = listsetDetails.autoUpdate === true;
        dom.text(
            '#listsOfBlockedHostsPrompt',
            i18n$('3pListsOfBlockedHostsPrompt')
                .replace('{{netFilterCount}}', renderNumber(response.netFilterCount))
                .replace('{{cosmeticFilterCount}}', renderNumber(response.cosmeticFilterCount))
        );
        qs$('#parseCosmeticFilters').checked =
            listsetDetails.parseCosmeticFilters === true;
        qs$('#ignoreGenericCosmeticFilters').checked =
            listsetDetails.ignoreGenericCosmeticFilters === true;
        qs$('#suspendUntilListsAreLoaded').checked =
            listsetDetails.suspendUntilListsAreLoaded === true;

        // https://github.com/gorhill/uBlock/issues/2394
        dom.cl.toggle(dom.body, 'updating', listsetDetails.isUpdating);

        renderWidgets();
    };

    messaging.send('dashboard', {
        what: 'getLists',
    }).then(response => {
        onListsReceived(response);
    });
};

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

const renderWidgets = ( ) => {
    dom.cl.toggle('#buttonApply', 'disabled',
        filteringSettingsHash === hashFromCurrentFromSettings()
    );
    const updating = dom.cl.has(dom.body, 'updating');
    dom.cl.toggle('#buttonUpdate', 'active', updating);
    dom.cl.toggle('#buttonUpdate', 'disabled',
        updating === false &&
        qs$('#lists .listEntry.checked.obsolete:not(.toRemove)') === null
    );
    dom.cl.toggle('#buttonPurgeAll', 'disabled',
        updating || qs$('#lists .listEntry.cached:not(.obsolete)') === null
    );
};

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

const updateAssetStatus = details => {
    const listEntry = qs$(`#lists .listEntry[data-key="${details.key}"]`);
    if ( listEntry === null ) { return; }
    dom.cl.toggle(listEntry, 'failed', !!details.failed);
    dom.cl.toggle(listEntry, 'obsolete', !details.cached);
    dom.cl.toggle(listEntry, 'cached', !!details.cached);
    if ( details.cached ) {
        dom.attr(qs$(listEntry, '.status.cache'), 'title',
            lastUpdateTemplateString.replace('{{ago}}', i18n.renderElapsedTimeToString(Date.now()))
        );
        dom.cl.add(listEntry, 'recent');
    }
    updateAncestorListNodes(listEntry, ancestor => {
        updateListNode(ancestor);
    });
    renderWidgets();
};

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

    Compute a hash from all the settings affecting how filter lists are loaded
    in memory.

**/

let filteringSettingsHash = '';

const hashFromListsetDetails = ( ) => {
    const hashParts = [
        listsetDetails.parseCosmeticFilters === true,
        listsetDetails.ignoreGenericCosmeticFilters === true,
    ];
    const listHashes = [];
    for ( const [ listkey, listDetails ] of Object.entries(listsetDetails.available) ) {
        if ( listDetails.off === true ) { continue; }
        listHashes.push(listkey);
    }
    hashParts.push( listHashes.sort().join(), '', false);
    filteringSettingsHash = hashParts.join();
};

const hashFromCurrentFromSettings = ( ) => {
    const hashParts = [
        qs$('#parseCosmeticFilters').checked,
        qs$('#ignoreGenericCosmeticFilters').checked,
    ];
    const listHashes = [];
    const listEntries = qsa$('#lists .listEntry[data-key]:not(.toRemove)');
    for ( const liEntry of listEntries ) {
        if ( liEntry.dataset.role !== 'leaf' ) { continue; }
        if ( dom.cl.has(liEntry, 'checked') === false ) { continue; }
        listHashes.push(liEntry.dataset.key);
    }
    const textarea = qs$('#lists .listEntry[data-role="import"].expanded textarea');
    hashParts.push(
        listHashes.sort().join(),
        textarea !== null && textarea.value.trim() || '',
        qs$('#lists .listEntry.toRemove') !== null
    );
    return hashParts.join();
};

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

const onListsetChanged = ev => {
    const input = ev.target.closest('input');
    if ( input === null ) { return; }
    toggleFilterList(input, input.checked, true);
};

dom.on('#lists', 'change', '.listEntry > .detailbar input', onListsetChanged);

const toggleFilterList = (elem, on, ui = false) => {
    const listEntry = elem.closest('.listEntry');
    if ( listEntry === null ) { return; }
    if ( listEntry.dataset.parent === 'root' ) { return; }
    const searchMode = dom.cl.has('#lists', 'searchMode');
    const input = qs$(listEntry, ':scope > .detailbar input');
    if ( on === undefined ) {
        on = input.checked === false;
    }
    input.checked = on;
    dom.cl.toggle(listEntry, 'checked', on);
    dom.cl.toggle(listEntry, 'stickied', ui && !on && !searchMode);
    // Select/unselect descendants. Twist: if in search-mode, select only
    // search-matched descendants.
    const childListEntries = searchMode
        ? qsa$(listEntry, '.listEntry.searchMatch')
        : qsa$(listEntry, '.listEntry');
    for ( const descendantList of childListEntries ) {
        dom.cl.toggle(descendantList, 'checked', on);
        qs$(descendantList, ':scope > .detailbar input').checked = on;
    }
    updateAncestorListNodes(listEntry, ancestor => {
        updateListNode(ancestor);
    });
    onFilteringSettingsChanged();
};

const updateListNode = listNode => {
    if ( listNode === null ) { return; }
    if ( listNode.dataset.role !== 'node' ) { return; }
    const checkedListLeaves = qsa$(listNode, '.listEntry[data-role="leaf"].checked');
    const allListLeaves = qsa$(listNode, '.listEntry[data-role="leaf"]');
    dom.text(qs$(listNode, '.nodestats'),
        renderNodeStats(checkedListLeaves.length, allListLeaves.length)
    );
    dom.cl.toggle(listNode, 'searchMatch',
        qs$(listNode, ':scope > .listEntries > .listEntry.searchMatch') !== null
    );
    if ( listNode.dataset.parent === 'root' ) { return; }
    let usedFilterCount = 0;
    let totalFilterCount = 0;
    let isCached = false;
    let isObsolete = false;
    let latestWriteTime = 0;
    let oldestWriteTime = Number.MAX_SAFE_INTEGER;
    for ( const listLeaf of checkedListLeaves ) {
        const listkey = listLeaf.dataset.key;
        const listDetails = listsetDetails.available[listkey];
        usedFilterCount += listDetails.off ? 0 : listDetails.entryUsedCount || 0;
        totalFilterCount += listDetails.entryCount || 0;
        const assetCache = listsetDetails.cache[listkey] || {};
        isCached = isCached || dom.cl.has(listLeaf, 'cached');
        isObsolete = isObsolete || dom.cl.has(listLeaf, 'obsolete');
        latestWriteTime = Math.max(latestWriteTime, assetCache.writeTime || 0);
        oldestWriteTime = Math.min(oldestWriteTime, assetCache.writeTime || Number.MAX_SAFE_INTEGER);
    }
    dom.cl.toggle(listNode, 'checked', checkedListLeaves.length !== 0);
    dom.cl.toggle(qs$(listNode, ':scope > .detailbar .checkbox'),
        'partial',
        checkedListLeaves.length !== allListLeaves.length
    );
    dom.prop(qs$(listNode, ':scope > .detailbar input'),
        'checked',
        checkedListLeaves.length !== 0
    );
    dom.text(qs$(listNode, '.leafstats'),
        renderLeafStats(usedFilterCount, totalFilterCount)
    );
    const firstLeaf = qs$(listNode, '.listEntry[data-role="leaf"]');
    if ( firstLeaf !== null ) {
        dom.attr(qs$(listNode, ':scope > .detailbar a.support'), 'href',
            dom.attr(qs$(firstLeaf, ':scope > .detailbar a.support'), 'href') || '#'
        );
        dom.attr(qs$(listNode, ':scope > .detailbar a.mustread'), 'href',
            dom.attr(qs$(firstLeaf, ':scope > .detailbar a.mustread'), 'href') || '#'
        );
    }
    dom.cl.toggle(listNode, 'cached', isCached);
    dom.cl.toggle(listNode, 'obsolete', isObsolete);
    if ( isCached ) {
        dom.attr(qs$(listNode, ':scope > .detailbar .cache'), 'title',
            lastUpdateTemplateString.replace('{{ago}}', i18n.renderElapsedTimeToString(latestWriteTime))
        );
        dom.cl.toggle(listNode, 'recent', (Date.now() - oldestWriteTime) < recentlyUpdated);
    }
    if ( qs$(listNode, '.listEntry.isDefault') !== null ) {
        dom.cl.add(listNode, 'isDefault');
    }
    if ( qs$(listNode, '.listEntry.stickied') !== null ) {
        dom.cl.add(listNode, 'stickied');
    }
};

const updateAncestorListNodes = (listEntry, fn) => {
    while ( listEntry !== null ) {
        fn(listEntry);
        listEntry = qs$(`.listEntry[data-key="${listEntry.dataset.parent}"]`);
    }
};

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

const onFilteringSettingsChanged = ( ) => {
    renderWidgets();
};

dom.on('#parseCosmeticFilters', 'change', onFilteringSettingsChanged);
dom.on('#ignoreGenericCosmeticFilters', 'change', onFilteringSettingsChanged);
dom.on('#lists', 'input', '[data-role="import"] textarea', onFilteringSettingsChanged);

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

const onRemoveExternalList = ev => {
    const listEntry = ev.target.closest('[data-key]');
    if ( listEntry === null ) { return; }
    dom.cl.toggle(listEntry, 'toRemove');
    renderWidgets();
};

dom.on('#lists', 'click', '.listEntry .remove', onRemoveExternalList);

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

const onPurgeClicked = ev => {
    const liEntry = ev.target.closest('[data-key]');
    const listkey = liEntry.dataset.key || '';
    if ( listkey === '' ) { return; }

    const assetKeys = [ listkey ];
    for ( const listLeaf of qsa$(liEntry, '[data-role="leaf"]') ) {
        assetKeys.push(listLeaf.dataset.key);
        dom.cl.add(listLeaf, 'obsolete');
        dom.cl.remove(listLeaf, 'cached');
    }

    messaging.send('dashboard', {
        what: 'purgeCaches',
        assetKeys,
    });

    // If the cached version is purged, the installed version must be assumed
    // to be obsolete.
    // https://github.com/gorhill/uBlock/issues/1733
    //   An external filter list must not be marked as obsolete, they will
    //   always be fetched anyways if there is no cached copy.
    dom.cl.add(liEntry, 'obsolete');

    if ( qs$(liEntry, 'input[type="checkbox"]').checked ) {
        renderWidgets();
    }
};

dom.on('#lists', 'click', 'span.cache', onPurgeClicked);

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

const selectFilterLists = async ( ) => {
    // Cosmetic filtering switch
    let checked = qs$('#parseCosmeticFilters').checked;
    messaging.send('dashboard', {
        what: 'userSettings',
        name: 'parseAllABPHideFilters',
        value: checked,
    });
    listsetDetails.parseCosmeticFilters = checked;

    checked = qs$('#ignoreGenericCosmeticFilters').checked;
    messaging.send('dashboard', {
        what: 'userSettings',
        name: 'ignoreGenericCosmeticFilters',
        value: checked,
    });
    listsetDetails.ignoreGenericCosmeticFilters = checked;

    // Filter lists to remove/select
    const toSelect = [];
    const toRemove = [];
    for ( const liEntry of qsa$('#lists .listEntry[data-role="leaf"]') ) {
        const listkey = liEntry.dataset.key;
        if ( listsetDetails.available.hasOwnProperty(listkey) === false ) {
            continue;
        }
        const listDetails = listsetDetails.available[listkey];
        if ( dom.cl.has(liEntry, 'toRemove') ) {
            toRemove.push(listkey);
            listDetails.off = true;
            continue;
        }
        if ( dom.cl.has(liEntry, 'checked') ) {
            toSelect.push(listkey);
            listDetails.off = false;
        } else {
            listDetails.off = true;
        }
    }

    // External filter lists to import
    const textarea = qs$('#lists .listEntry[data-role="import"].expanded textarea');
    const toImport = textarea !== null && textarea.value.trim() || '';
    if ( textarea !== null ) {
        dom.cl.remove(textarea.closest('.expandable'), 'expanded');
        textarea.value = '';
    }

    hashFromListsetDetails();

    await messaging.send('dashboard', {
        what: 'applyFilterListSelection',
        toSelect,
        toImport,
        toRemove,
    });
};

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

const buttonApplyHandler = async ( ) => {
    await selectFilterLists();
    dom.cl.add(dom.body, 'working');
    dom.cl.remove('#lists .listEntry.stickied', 'stickied');
    renderWidgets();
    await messaging.send('dashboard', { what: 'reloadAllFilters' });
    dom.cl.remove(dom.body, 'working');
};

dom.on('#buttonApply', 'click', ( ) => { buttonApplyHandler(); });

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

const buttonUpdateHandler = async ( ) => {
    dom.cl.remove('#lists .listEntry.stickied', 'stickied');
    await selectFilterLists();
    dom.cl.add(dom.body, 'updating');
    renderWidgets();
    messaging.send('dashboard', { what: 'forceUpdateAssets' });
};

dom.on('#buttonUpdate', 'click', ( ) => { buttonUpdateHandler(); });

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

const buttonPurgeAllHandler = async hard => {
    await messaging.send('dashboard', {
        what: 'purgeAllCaches',
        hard,
    });
    renderFilterLists(true);
};

dom.on('#buttonPurgeAll', 'click', ev => { buttonPurgeAllHandler(ev.shiftKey); });

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

const userSettingCheckboxChanged = ( ) => {
    const target = event.target;
    messaging.send('dashboard', {
        what: 'userSettings',
        name: target.id,
        value: target.checked,
    });
    listsetDetails[target.id] = target.checked;
};

dom.on('#autoUpdate', 'change', userSettingCheckboxChanged);
dom.on('#suspendUntilListsAreLoaded', 'change', userSettingCheckboxChanged);

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

const searchFilterLists = ( ) => {
    const pattern = dom.prop('.searchbar input', 'value') || '';
    dom.cl.toggle('#lists', 'searchMode', pattern !== '');
    if ( pattern === '' ) { return; }
    const reflectSearchMatches = listEntry => {
        if ( listEntry.dataset.role !== 'node' ) { return; }
        dom.cl.toggle(listEntry, 'searchMatch',
            qs$(listEntry, ':scope > .listEntries > .listEntry.searchMatch') !== null
        );
    };
    const toI18n = tags => {
        if ( tags === '' ) { return ''; }
        return tags.toLowerCase().split(/\s+/).reduce((a, v) => {
            let s = i18n$(v);
            if ( s === '' ) {
                s = i18nGroupName(v);
                if ( s === '' ) { return a; }
            }
            return `${a} ${s}`.trim();
        }, '');
    };
    const re = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
    for ( const listEntry of qsa$('#lists [data-role="leaf"]') ) {
        const listkey = listEntry.dataset.key;
        const listDetails = listsetDetails.available[listkey];
        if ( listDetails === undefined ) { continue; }
        let haystack = perListHaystack.get(listDetails);
        if ( haystack === undefined ) {
            haystack = [
                listDetails.title,
                listDetails.group || '',
                i18nGroupName(listDetails.group || ''),
                listDetails.tags || '',
                toI18n(listDetails.tags || ''),
            ].join(' ').trim();
            perListHaystack.set(listDetails, haystack);
        }
        dom.cl.toggle(listEntry, 'searchMatch', re.test(haystack));
        updateAncestorListNodes(listEntry, reflectSearchMatches);
    }
};

const perListHaystack = new WeakMap();

dom.on('.searchbar input', 'input', searchFilterLists);

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

const expandedListSet = new Set([
    'uBlock filters',
    'AdGuard – Annoyances',
    'EasyList – Annoyances',
]);

const listIsExpanded = which => {
    return expandedListSet.has(which);
};

const applyListExpansion = listkeys => {
    if ( listkeys === undefined ) {
        listkeys = Array.from(expandedListSet);
    }
    expandedListSet.clear();
    dom.cl.remove('#lists [data-role="node"]', 'expanded');
    listkeys.forEach(which => {
        expandedListSet.add(which);
        dom.cl.add(`#lists [data-key="${which}"]`, 'expanded');
    });
};

const toggleListExpansion = which => {
    const isExpanded = expandedListSet.has(which);
    if ( which === '*' ) {
        if ( isExpanded ) {
            expandedListSet.clear();
            dom.cl.remove('#lists .expandable', 'expanded');
            dom.cl.remove('#lists .stickied', 'stickied');
        } else {
            expandedListSet.clear();
            expandedListSet.add('*');
            dom.cl.add('#lists .rootstats', 'expanded');
            for ( const expandable of qsa$('#lists > .listEntries .expandable') ) {
                const listkey = expandable.dataset.key || '';
                if ( listkey === '' ) { continue; }
                expandedListSet.add(listkey);
                dom.cl.add(expandable, 'expanded');
            }
        }
    } else {
        if ( isExpanded ) {
            expandedListSet.delete(which);
            const listNode = qs$(`#lists > .listEntries [data-key="${which}"]`);
            dom.cl.remove(listNode, 'expanded');
            if ( listNode.dataset.parent === 'root' ) {
                dom.cl.remove(qsa$(listNode, '.stickied'), 'stickied');
            }
        } else {
            expandedListSet.add(which);
            dom.cl.add(`#lists > .listEntries [data-key="${which}"]`, 'expanded');
        }
    }
    vAPI.localStorage.setItem('expandedListSet', Array.from(expandedListSet));
    vAPI.localStorage.removeItem('hideUnusedFilterLists');
};

dom.on('#listsOfBlockedHostsPrompt', 'click', ( ) => {
    toggleListExpansion('*');
});

dom.on('#lists', 'click', '.listExpander', ev => {
    const expandable = ev.target.closest('.expandable');
    if ( expandable === null ) { return; }
    const which = expandable.dataset.key;
    if ( which !== undefined ) {
        toggleListExpansion(which);
    } else {
        dom.cl.toggle(expandable, 'expanded');
        if ( expandable.dataset.role === 'import' ) {
            onFilteringSettingsChanged();
        }
    }
    ev.preventDefault();
});

dom.on('#lists', 'click', '[data-parent="root"] > .detailbar .listname', ev => {
    const listEntry = ev.target.closest('.listEntry');
    if ( listEntry === null ) { return; }
    const listkey = listEntry.dataset.key;
    if ( listkey === undefined ) { return; }
    toggleListExpansion(listkey);
    ev.preventDefault();
});

dom.on('#lists', 'click', '[data-role="import"] > .detailbar .listname', ev => {
    const expandable = ev.target.closest('.listEntry');
    if ( expandable === null ) { return; }
    dom.cl.toggle(expandable, 'expanded');
    ev.preventDefault();
});

dom.on('#lists', 'click', '.listEntry > .detailbar .nodestats', ev => {
    const listEntry = ev.target.closest('.listEntry');
    if ( listEntry === null ) { return; }
    const listkey = listEntry.dataset.key;
    if ( listkey === undefined ) { return; }
    toggleListExpansion(listkey);
    ev.preventDefault();
});

// Initialize from saved state.
vAPI.localStorage.getItemAsync('expandedListSet').then(listkeys => {
    if ( Array.isArray(listkeys) === false ) { return; }
    applyListExpansion(listkeys);
});

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

// Cloud storage-related.

self.cloud.onPush = function toCloudData() {
    const bin = {
        parseCosmeticFilters: qs$('#parseCosmeticFilters').checked,
        ignoreGenericCosmeticFilters: qs$('#ignoreGenericCosmeticFilters').checked,
        selectedLists: []
    };

    const liEntries = qsa$('#lists .listEntry.checked[data-role="leaf"]');
    for ( const liEntry of liEntries ) {
        bin.selectedLists.push(liEntry.dataset.key);
    }

    return bin;
};

self.cloud.onPull = function fromCloudData(data, append) {
    if ( typeof data !== 'object' || data === null ) { return; }

    let elem = qs$('#parseCosmeticFilters');
    let checked = data.parseCosmeticFilters === true || append && elem.checked;
    elem.checked = listsetDetails.parseCosmeticFilters = checked;

    elem = qs$('#ignoreGenericCosmeticFilters');
    checked = data.ignoreGenericCosmeticFilters === true || append && elem.checked;
    elem.checked = listsetDetails.ignoreGenericCosmeticFilters = checked;

    const selectedSet = new Set(data.selectedLists);
    for ( const listEntry of qsa$('#lists .listEntry[data-role="leaf"]') ) {
        const listkey = listEntry.dataset.key;
        const mustEnable = selectedSet.has(listkey);
        selectedSet.delete(listkey);
        if ( mustEnable === false && append ) { continue; }
        toggleFilterList(listEntry, mustEnable);
    }

    // If there are URL-like list keys left in the selected set, import them.
    for ( const listkey of selectedSet ) {
        if ( reValidExternalList.test(listkey) ) { continue; }
        selectedSet.delete(listkey);
    }
    if ( selectedSet.size !== 0 ) {
        const textarea = qs$('#lists .listEntry[data-role="import"] textarea');
        const lines = append
            ? textarea.value.split(/[\n\r]+/)
            : [];
        lines.push(...selectedSet);
        if ( lines.length !== 0 ) { lines.push(''); }
        textarea.value = lines.join('\n');
        dom.cl.toggle('#lists .listEntry[data-role="import"]', 'expanded', textarea.value !== '');
    }

    renderWidgets();
};

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

self.hasUnsavedData = function() {
    return hashFromCurrentFromSettings() !== filteringSettingsHash;
};

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

renderFilterLists();

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