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

    uBlock Origin - a comprehensive, efficient content blocker
    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
*/

import { dom, qs$, qsa$ } from './dom.js';
import { i18n, i18n$ } from './i18n.js';
import { onBroadcast } from './broadcast.js';
import { deepT } from './deepblocker-i18n.js';
import { displayListName } from './deepblocker-display-names.js';
import { applyProtectionPreset, isRegionalList, markPresetSelectionModified, resetToDeepBlockerDefaults } from './deepblocker-presets.js';

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

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

let listsetDetails = {};
let filterStatusCode = 'checking';
let filterStatusText = '';
let recommendedBadgeText = 'Recommended';
let regionalBadgeText = 'Regional';
let neverText = 'Never';

const filterStatusKey = {
    checking: 'checking',
    updating: 'updateInProgress',
    pending: 'selectionPending',
    needsUpdate: 'updateRecommended',
    upToDate: 'filtersAreUpToDate',
    completed: 'updateCompleted',
    failed: 'updateFailed',
    applying: 'applyingSelection',
    selectionFailed: 'selectionApplyFailed',
    selectionSaved: 'selectionSaved',
    someListsFailed: 'someListsFailed',
};

// Status codes that should persist while the page is idle, instead of being
// overwritten by the recomputed "up to date" state.
const stickyStatusCodes = new Set([
    'completed',
    'someListsFailed',
    'selectionSaved',
]);

const setFilterSummary = (selector, text) => {
    const node = qs$(selector);
    if ( node !== null ) {
        node.textContent = text;
    }
};

const setFilterLoadError = state => {
    const node = qs$('#filterLoadError');
    if ( node !== null ) {
        node.hidden = state !== true;
    }
};

const setFilterStatusCode = code => {
    filterStatusCode = code;
    deepT(filterStatusKey[code] || 'checking').then(text => {
        filterStatusText = text;
        setFilterSummary('#deepListHealth', text);
    });
};

const formatLastUpdate = ( ) => {
    const times = Object.values(listsetDetails.cache || {})
        .map(asset => asset && asset.writeTime || 0)
        .filter(time => time > 0);
    if ( times.length === 0 ) { return neverText; }
    return i18n.renderElapsedTimeToString(Math.max(...times));
};

deepT('recommended').then(text => { recommendedBadgeText = text; });
deepT('regional').then(text => { regionalBadgeText = text; });
deepT('never').then(text => { neverText = text; });

const listEntryIsRegional = entry => {
    const key = entry && entry.dataset && entry.dataset.key || '';
    if ( key === '' ) { return false; }
    const details = (listsetDetails.available || {})[key] || {};
    return isRegionalList(key, details);
};

const listEntryNeedsContentUpdate = entry => {
    if ( entry === null ) { return false; }
    if ( dom.cl.has(entry, 'checked') === false ) { return false; }
    if ( dom.cl.has(entry, 'toRemove') ) { return false; }
    if ( dom.cl.has(entry, 'failed') ) { return true; }
    if ( dom.cl.has(entry, 'obsolete') === false ) { return false; }
    if ( dom.cl.has(entry, 'cached') === false ) { return true; }
    return dom.cl.has(entry, 'recent') === false;
};

const computeUpdateNeedCount = ( ) => {
    let count = 0;
    for ( const entry of qsa$('#lists .listEntry[data-role="leaf"]') ) {
        if ( listEntryNeedsContentUpdate(entry) ) {
            count += 1;
        }
    }
    return count;
};

const addListBadge = (entry, className, text) => {
    const detailbar = qs$(entry, ':scope > .detailbar');
    if ( detailbar === null ) { return; }
    if ( qs$(detailbar, `.${className}`) !== null ) { return; }
    const badge = document.createElement('span');
    badge.className = `deep-list-badge ${className}`;
    badge.textContent = text;
    detailbar.append(badge);
};

const decorateDeepFilterLists = ( ) => {
    const entries = qsa$('#lists .listEntry[data-role="leaf"]');
    let checked = 0;
    let recommended = 0;
    let needsUpdate = 0;
    let failed = 0;
    for ( const entry of entries ) {
        const isChecked = dom.cl.has(entry, 'checked');
        if ( isChecked ) {
            checked += 1;
        }
        if ( dom.cl.has(entry, 'isDefault') ) {
            recommended += 1;
            addListBadge(entry, 'deep-badge-recommended', recommendedBadgeText);
        }
        if ( listEntryIsRegional(entry) ) {
            addListBadge(entry, 'deep-badge-regional', regionalBadgeText);
        }
        if ( listEntryNeedsContentUpdate(entry) ) {
            needsUpdate += 1;
        }
        if ( isChecked && dom.cl.has(entry, 'failed') ) {
            failed += 1;
        }
    }
    setFilterSummary('#deepEnabledLists', renderNumber(checked));
    setFilterSummary('#deepTotalLists', renderNumber(entries.length));
    setFilterSummary('#deepRecommendedLists', renderNumber(recommended));
    setFilterSummary('#deepStaleLists', renderNumber(needsUpdate));
    setFilterSummary('#deepFailedLists', renderNumber(failed));
    setFilterSummary('#deepLastUpdate', formatLastUpdate());
    if ( filterStatusCode === 'checking' ) {
        setFilterStatusCode(needsUpdate === 0 ? 'upToDate' : 'needsUpdate');
    } else if ( filterStatusText !== '' ) {
        setFilterSummary('#deepListHealth', filterStatusText);
    }
};

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

onBroadcast(msg => {
    switch ( msg.what ) {
    case 'assetUpdated':
        updateAssetStatus(msg);
        break;
    case 'assetsUpdated': {
        dom.cl.remove(dom.body, 'updating');
        const failedCount = qsa$('#lists .listEntry[data-role="leaf"].checked.failed').length;
        setFilterStatusCode(failedCount > 0 ? 'someListsFailed' : 'completed');
        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 => {
    const groupname = i18n$('3pGroup' + name.charAt(0).toUpperCase() + name.slice(1));
    if ( groupname !== '' ) { return groupname; }
    return `${name.charAt(0).toLocaleUpperCase}${name.slice(1)}`;
};

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

const renderFilterLists = async ( ) => {
    // 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 groupkey = listDetails.group2 || listDetails.group;
        const listEntryPrevious =
            qs$(`[data-key="${groupkey}"] [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 ||
            listDetails.isImportant === 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 ap = a[1].preferred === true;
                const bp = b[1].preferred === true;
                if ( ap !== bp ) { return ap ? -1 : 1; }
                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(displayListName(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',
            'cookies',
            'social',
            'annoyances',
            'regions',
            'unknown',
            'custom'
        ];
        for ( const key of groupKeys ) {
            listTree[key] = {
                title: i18nGroupName(key),
                lists: {},
            };
        }
        for ( const [ listkey, listDetails ] of Object.entries(response.available) ) {
            let groupkey = listDetails.group2 || listDetails.group;
            if ( Object.hasOwn(listTree, groupkey) === false ) {
                groupkey = 'unknown';
            }
            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: displayListName(parent), lists: {} };
                    }
                    if ( listDetails.preferred === true ) {
                        lists[parent].preferred = true;
                    }
                    lists = lists[parent].lists;
                }
                lists[listkey] = listDetails;
            } else {
                listDetails.title = listNameFromListKey(listkey);
                groupDetails.lists[listkey] = listDetails;
            }
        }
        // https://github.com/uBlockOrigin/uBlock-issues/issues/3154#issuecomment-1975413427
        //   Remove empty sections
        for ( const groupkey of groupKeys ) {
            const groupDetails = listTree[groupkey];
            if ( groupDetails === undefined ) { continue; }
            if ( Object.keys(groupDetails.lists).length !== 0 ) { continue; }
            delete listTree[groupkey];
        }

        const listEntries = createListEntries('root', listTree);
        qs$('#lists .listEntries').replaceWith(listEntries);
        setFilterLoadError(false);

        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();
        decorateDeepFilterLists();
    };

    let lastError;
    for ( let i = 0; i < 40; i++ ) {
        try {
            const response = await vAPI.messaging.send('dashboard', {
                what: 'getLists',
            });
            if (
                response instanceof Object &&
                response.available instanceof Object &&
                response.cache instanceof Object
            ) {
                onListsReceived(response);
                return true;
            }
        } catch (ex) {
            lastError = ex;
        }
        dom.text(
            '#listsOfBlockedHostsPrompt',
            'Loading recommended protection lists...'
        );
        await vAPI.defer.once(250);
    }
    deepT('filterListsCouldNotLoad').then(text => {
        dom.text('#listsOfBlockedHostsPrompt', text);
    });
    setFilterLoadError(true);
    dom.cl.add('#buttonApply', 'disabled');
    dom.cl.add('#buttonUpdate', 'disabled');
    if ( lastError !== undefined ) {
        console.warn('DeepBlocker filter list loading failed', lastError);
    }
    return false;
};

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

const renderWidgets = ( ) => {
    const updating = dom.cl.has(dom.body, 'updating');
    const selectionPending = filteringSettingsHash !== hashFromCurrentFromSettings();
    const needsUpdate = computeUpdateNeedCount();
    dom.cl.toggle('#buttonApply', 'disabled',
        selectionPending === false
    );
    dom.cl.toggle('#buttonUpdate', 'active', updating);
    dom.cl.toggle('#buttonUpdate', 'disabled',
        updating === false && needsUpdate === 0
    );
    if ( updating ) {
        setFilterStatusCode('updating');
    } else if ( selectionPending ) {
        setFilterStatusCode('pending');
    } else if ( needsUpdate !== 0 ) {
        setFilterStatusCode('needsUpdate');
    } else if ( stickyStatusCodes.has(filterStatusCode) === false ) {
        setFilterStatusCode('upToDate');
    }
    decorateDeepFilterLists();
};

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

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');
        dom.cl.remove(listEntry, 'obsolete');
    }
    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');
    }

    vAPI.messaging.send('dashboard', {
        what: 'listsUpdateNow',
        assetKeys,
        preferOrigin: ev.shiftKey,
    });

    // 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(dom.body, 'updating');
    dom.cl.add(liEntry, 'obsolete');

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

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

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

const selectFilterLists = async ( ) => {
    // External filter lists to import
    // Find stock list matching entries in lists to import
    const toImport = (( ) => {
        const textarea = qs$('#lists .listEntry[data-role="import"].expanded textarea');
        if ( textarea === null ) { return ''; }
        const lists = listsetDetails.available;
        const lines = textarea.value.split(/\s+/);
        const after = [];
        for ( const line of lines ) {
            after.push(line);
            if ( /^https?:\/\//.test(line) === false ) { continue; }
            for ( const [ listkey, list ] of Object.entries(lists) ) {
                if ( list.content !== 'filters' ) { continue; }
                if ( list.contentURL === undefined ) { continue; }
                if ( list.contentURL.includes(line) === false ) { continue; }
                const groupkey = list.group2 || list.group;
                const listEntry = qs$(`[data-key="${groupkey}"] [data-key="${listkey}"]`);
                if ( listEntry === null ) { break; }
                toggleFilterList(listEntry, true);
                after.pop();
                break;
            }
        }
        dom.cl.remove(textarea.closest('.expandable'), 'expanded');
        textarea.value = '';
        return after.join('\n');
    })();

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

    checked = qs$('#ignoreGenericCosmeticFilters').checked;
    vAPI.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 ( Object.hasOwn(listsetDetails.available, 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;
        }
    }

    await vAPI.messaging.send('dashboard', {
        what: 'applyFilterListSelection',
        toSelect,
        toImport,
        toRemove,
        preserveCache: true,
    });
    await markPresetSelectionModified();
    hashFromListsetDetails();
};

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

const buttonApplyHandler = async ( ) => {
    dom.cl.add(dom.body, 'working');
    setFilterStatusCode('applying');
    try {
        await selectFilterLists();
        dom.cl.remove('#lists .listEntry.stickied', 'stickied');
        renderWidgets();
        await vAPI.messaging.send('dashboard', { what: 'reloadAllFilters' });
        setFilterStatusCode(computeUpdateNeedCount() === 0 ? 'selectionSaved' : 'needsUpdate');
    } catch (ex) {
        setFilterStatusCode('selectionFailed');
        console.warn('DeepBlocker filter selection failed', ex);
    } finally {
        dom.cl.remove(dom.body, 'working');
        renderWidgets();
    }
};

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

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

const buttonUpdateHandler = async ( ) => {
    dom.cl.remove('#lists .listEntry.stickied', 'stickied');
    setFilterStatusCode('updating');
    try {
        if ( self.hasUnsavedData() ) {
            await selectFilterLists();
        }
        dom.cl.add(dom.body, 'updating');
        renderWidgets();
        await vAPI.messaging.send('dashboard', { what: 'updateNow' });
    } catch (ex) {
        dom.cl.remove(dom.body, 'updating');
        setFilterStatusCode('failed');
        renderWidgets();
        console.warn('DeepBlocker filter update failed', ex);
    }
};

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

const buttonRecommendedHandler = async ( ) => {
    if ( self.hasUnsavedData() && window.confirm(await deepT('applyRecommendedConfirm')) !== true ) {
        return;
    }
    dom.cl.add(dom.body, 'working');
    try {
        await applyProtectionPreset('strong', { reload: true });
        await renderFilterLists();
    } catch (ex) {
        dom.text('#listsOfBlockedHostsPrompt', await deepT('recommendedCouldNotApply'));
        console.warn('DeepBlocker recommended lists failed', ex);
    } finally {
        dom.cl.remove(dom.body, 'working');
    }
};

const buttonResetDefaultsHandler = async ( ) => {
    if ( window.confirm(await deepT('resetDeepConfirm')) !== true ) {
        return;
    }
    dom.cl.add(dom.body, 'working');
    try {
        await resetToDeepBlockerDefaults({ reload: true });
        await renderFilterLists();
    } catch (ex) {
        dom.text('#listsOfBlockedHostsPrompt', await deepT('defaultsFailed'));
        console.warn('DeepBlocker defaults failed', ex);
    } finally {
        dom.cl.remove(dom.body, 'working');
    }
};

dom.on('#buttonRecommended', 'click', buttonRecommendedHandler);
dom.on('#buttonResetDefaults', 'click', buttonResetDefaultsHandler);
dom.on('#buttonRetryLists', 'click', ( ) => { renderFilterLists(); });

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

const userSettingCheckboxChanged = ev => {
    const target = ev.target;
    vAPI.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('.searchfield 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 ) {
            const groupkey = listDetails.group2 || listDetails.group || '';
            haystack = [
                listDetails.title,
                groupkey,
                i18nGroupName(groupkey),
                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('.searchfield input', 'input', searchFilterLists);

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

const expandedListSet = new Set([
    'cookies',
    'social',
]);

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. DeepBlocker does not expose cloud sync in the visible
// UI, but these hooks stay guarded for hidden legacy pages.

const cloudHooks = self.cloud || (self.cloud = {});

cloudHooks.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;
};

cloudHooks.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.wikilink = 'https://github.com/gorhill/uBlock/wiki/Dashboard:-Filter-lists';

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

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

renderFilterLists().then(success => {
    if ( success !== true ) { return; }
    const buttonUpdate = qs$('#buttonUpdate');
    if ( dom.cl.has(buttonUpdate, 'active') ) { return; }
    if ( dom.cl.has(buttonUpdate, 'disabled') ) { return; }
    if ( listsetDetails.autoUpdate !== true ) { return; }
    buttonUpdateHandler();
});

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