iCheckMovies Enhanced

Adds new features to enhance the iCheckMovies user experience

// ==UserScript==
// @name           iCheckMovies Enhanced
// @namespace      iCheckMovies
// @description    Adds new features to enhance the iCheckMovies user experience
// @license        MIT; https://opensource.org/licenses/MIT
// @author         themagician, monk-time
// @include        http://icheckmovies.com*
// @include        http://www.icheckmovies.com*
// @include        https://icheckmovies.com*
// @include        https://www.icheckmovies.com*
// @grant          unsafeWindow
// @grant          GM_getValue
// @icon           https://www.icheckmovies.com/favicon.ico
// @version        2.0.3
// ==/UserScript==

'use strict';

const VERSION = '2.0.3';

// ----- Utils -----

const $ = sel => document.querySelector(sel); // eslint-disable-line no-redeclare
const $$ = sel => document.querySelectorAll(sel);

const save = (key, val) => localStorage.setItem(key, JSON.stringify(val));
const load = key => JSON.parse(localStorage.getItem(key));
const addCSS = css => document.head.insertAdjacentHTML('beforeend', `<style>${css}</style>`);

const extractFrom = async (url, extractor) => {
    const r = await fetch(url, { credentials: 'same-origin' });
    const html = await r.text();
    const el = new DOMParser().parseFromString(html, 'text/html');
    return extractor(el);
};

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

// ----- Data migration for 1.8.0 -> 2.0.0; remove afterwards -----

const migrateData = () => {
    // 1.8.0 didn't work in GM
    if (typeof GM_getValue === 'undefined') return; // eslint-disable-line camelcase
    const hasMigrated = localStorage.getItem('icme_migrated_1_8_0');
    if (hasMigrated) return;

    const strHiddenLists = GM_getValue('hidden_lists');
    if (strHiddenLists) {
        const hiddenLists = JSON.parse(strHiddenLists);
        console.log('Migrating hidden_lists from GM storage', hiddenLists);
        save('icme_hidden_lists', hiddenLists);
    }

    const strOwnedMovies = GM_getValue('owned_movies');
    if (strOwnedMovies) {
        const ownedMoviesArr = JSON.parse(strOwnedMovies);
        console.log('Migrating owned_movies from GM storage', ownedMoviesArr);
        const ownedMovies = Object.fromEntries(ownedMoviesArr.map(id => [id, true]));
        save('icme_owned_movies', ownedMovies);
    }

    localStorage.setItem('icme_migrated_1_8_0', true);
};

migrateData();

// ----- Interacting with ICM -----

// Mutually exclusive regexes for matching page type
const reICM = Object.freeze({
    movie: // movie pages only, not /movies/ or /movies/checked/ etc. or /rankings/
        // https://www.icheckmovies.com/movies/inception/
        // https://www.icheckmovies.com/movies/inception/comments/
        /icheckmovies\.com\/movies\/(?!$|\?|(?:(un)?checked|favorited|disliked|watchlist|owned|recommended)\/)[^/]+\/(?!rankings\/)/,
    movieList: // personal user list
        // https://www.icheckmovies.com/lists/imdbs+2010s+top+50/
        // https://www.icheckmovies.com/lists/imdbs+2010s+top+50/?sort=title
        // https://www.icheckmovies.com/lists/alfred+hitchcock+filmography/fritz/
        // https://www.icheckmovies.com/lists/alfred+hitchcock+filmography/fritz/?sort=title
        // https://www.icheckmovies.com/lists/watchlist+2015/juliske/
        /icheckmovies\.com\/lists\/(?!$|\?|(?:favorited|disliked|watchlist)\/)/,
    movieListGeneral: // /movies/ only
        // https://www.icheckmovies.com/movies/
        // https://www.icheckmovies.com/movies/?sort=title
        /icheckmovies\.com\/movies\/(?:$|\?)/,
    movieListSpecial: // /movies/checked/ etc.
        // https://www.icheckmovies.com/movies/favorited/
        // https://www.icheckmovies.com/movies/favorited/?sort=title
        // https://www.icheckmovies.com/movies/checked/
        // https://www.icheckmovies.com/movies/checked/?sort=title
        // https://www.icheckmovies.com/movies/unchecked/
        // https://www.icheckmovies.com/movies/owned/
        /icheckmovies\.com\/movies\/(?:((un)?checked|favorited|disliked|watchlist|owned|recommended)\/)/,
    movieSearch:
        // https://www.icheckmovies.com/search/movies/?query=inception
        /icheckmovies\.com\/search\/movies\//,
    movieRankings:
        // https://www.icheckmovies.com/movies/inception/rankings/
        // https://www.icheckmovies.com/movies/inception/rankings/?excludetags=user:icheckmovies
        /icheckmovies\.com\/movies\/[^/]+\/rankings\//,
    listsGeneral: // /lists/ only
        // https://www.icheckmovies.com/lists/
        // https://www.icheckmovies.com/lists/?sort=dateadded
        /icheckmovies\.com\/lists\/(?:$|\?)/,
    listsSpecial: // /lists/favorited/ etc.
        // https://www.icheckmovies.com/lists/favorited/
        // https://www.icheckmovies.com/lists/favorited/?sort=name
        /icheckmovies\.com\/lists\/(?:favorited|disliked|watchlist)\//,
    listsSearch:
        // https://www.icheckmovies.com/search/lists/?query=nolan
        /icheckmovies\.com\/search\/lists\//,
    progress:
        // https://www.icheckmovies.com/profiles/progress/
        /icheckmovies.com\/profiles\/progress\//,
});

const addToMovieListBar = htmlStr => {
    if (!$('#icmeControls')) {
        const html = '<div id="icmeControls" style="height: 35px; position: relative"></div>';
        // movieList and movieListGeneral+Special use different headers
        const elMain = $(':is(#topList, #listTitle) ~ .container:last-of-type');
        elMain.insertAdjacentHTML('beforebegin', html);
    }

    $('#icmeControls').insertAdjacentHTML('beforeend', htmlStr);
};

const addNearOrderByLinks = htmlStr => {
    addCSS(`.icmeOrderByLink {
        float: left;
        margin-right: 1em;
    }`);
    $('#listOrderingWrapper').insertAdjacentHTML('afterbegin', htmlStr);
};

// Remove the premium feature pop-up using two ways to unbind events from the button
// (only one is not enough because TM/VM launch the script at different times)
const removePremiumPopup = el => {
    const elClone = el.cloneNode(true);
    el.replaceWith(elClone);
    elClone.classList.remove('paidFeature');
    elClone.href = '#';
    return elClone;
};

// ----- Base classes and config windows -----

class BaseModule {
    constructor(globalCfg) {
        this.metadata = null; // check any module for required fields
        this.config = null; // will be created after the module has been registered
        this.globalCfg = globalCfg; // allows modules to use Save/Set/Get
    }

    // Create a necessary metadata.options item for if a module should be loaded by default.
    static getStatus(isEnabled) {
        return {
            id: 'enabled',
            desc: 'Enabled',
            type: 'checkbox',
            default: isEnabled,
        };
    }

    /**
     * Check if the current page matches at least one of given page types.
     *
     * @param {(string|string[])} keys - A key of reICM, or an array of keys
     * @returns {boolean} true if the current page matches any of specified regexes
     */
    static matchesPageType(keys) {
        if (!Array.isArray(keys)) keys = [keys];
        const matchUrl = regex => regex.test(window.location.href);
        return BaseModule.getRegexes(keys).some(matchUrl);
    }

    static getRegexes(arrOfKeys) {
        return arrOfKeys.map(key => {
            if (reICM[key] === undefined) {
                throw new TypeError(`Invalid icm-regex name: ${key}`);
            }

            return reICM[key];
        });
    }

    isOnSupportedPage() {
        return BaseModule.matchesPageType(this.metadata.enableOn);
    }

    // Synchronize the loaded config with the module's options (delete outdated, add new)
    // and make it accessible to the module.
    syncGlobalCfg() {
        const { id } = this.metadata;
        const config = {};
        for (const opt of this.metadata.options) {
            config[opt.id] = this.globalCfg.get(`${id}.${opt.id}`) ?? opt.default;
        }

        // Link module's config values to the whole config.
        // As they both reference the same object, you can modify module's config from inside it.
        // Changes through the ConfigWindow will be immediately available to modules.
        this.config = config;
        this.globalCfg.data[id] = config;
    }
}

class GlobalCfg {
    constructor() {
        // test:
        // ['1', '1.7', '1.7.1', '1.7.1.1', '1.7.1.1.1'].map(verToNumber) ===
        // [1000, 1700, 1710, 1711, 1711]
        const verToNumber = str => Number(`${str.replace(/\./g, '')}0000`.slice(0, 4));

        this.data = {
            script_info: {
                version: VERSION, // dot-separated string
                revision: verToNumber(VERSION), // 4-digit number
            },
        };

        const oldcfg = load('icm_enhanced');
        if (!oldcfg || !oldcfg.script_info) return;

        const oldInfo = oldcfg.script_info;
        const newInfo = this.data.script_info;
        // Rewrite script_info in the loaded config
        this.data = { ...oldcfg, script_info: newInfo };

        const isUpdated = oldInfo.revision !== newInfo.revision;
        if (isUpdated) {
            console.log(`Updating to ${newInfo.revision}`);
            this.save();
        }
    }

    save() {
        save('icm_enhanced', this.data);
    }

    // Get a config value by a dot-separated path
    get(path) {
        return path.split('.').reduce((prev, curr) => prev && prev[curr], this.data);
    }

    // Set a config value by a dot-separated path
    set(path, value) {
        const parts = path.split('.');
        const last = parts.pop();
        let obj = this.data;
        for (const part of parts) {
            if (!(obj[part] instanceof Object)) obj[part] = {};
            obj = obj[part];
        }

        obj[last] = value;
    }

    // Set false to true and vice versa
    toggle(path) {
        const val = this.get(path);
        let toggled;

        if (val === true || val === false) {
            toggled = !val;
        } else if (val === 'asc' || val === 'desc') {
            toggled = val === 'asc' ? 'desc' : 'asc';
        } else {
            return false; // couldn't toggle the value
        }

        this.set(path, toggled);
        return true; // value has been toggled
    }
}

class ConfigWindow {
    constructor(globalCfg) {
        this.globalCfg = globalCfg;
        this.modules = [];
    }

    addModule(metadata) {
        if (!this.modules.some(m => m.id === metadata.id)) {
            this.modules.push(metadata);
        }
    }

    buildOptionHTML(path, { frontDesc, desc, type, default: def, inline, newline }) {
        let value = this.globalCfg.get(path); // always up to date
        // optValue can be a string (until a module parses it) or an array (after)
        if (Array.isArray(value)) {
            value = value.join('\n');
        }

        const attrPath = `data-cfg-path="${path}"`;
        const checkbox = () => `
            ${newline ? '<br>' : ''}
            <p${inline ? ' class="icmeCfgInlineOpt"' : ''}>
                ${frontDesc ?? ''}
                <label>
                    <input type="checkbox" ${attrPath} ${value ? 'checked="checked"' : ''}
                        title="default: ${def ? 'yes' : 'no'}">
                    ${desc}
                </label>
            </p>`;
        const textinput = () => `
            <p>
                ${desc}:
                <input type="text" ${attrPath} value="${value}" title="default: ${def}">
            </p>`;
        const textarea = () => `
            <p>
                <span class="icmeCfgTextareaDesc">${desc}:</span>
                <textarea rows="4" cols="70" ${attrPath}>${value}</textarea>
            </p>`;
        const textinputcolor = () => `
            <p>
                ${desc}:
                <input type="text" class="icmeColorPickerText" ${attrPath}
                    value="${value}" title="default: ${def}">
                <input type="color" class="icmeColorPicker" ${attrPath}
                    value="${value}" title="default: ${def}">
            </p>`;

        const htmlByType = { checkbox, textinput, textarea, textinputcolor };
        return htmlByType[type]();
    }

    loadOptions(index) {
        const { id, desc, options } = this.modules[index];
        const buildHTML = opt => this.buildOptionHTML(`${id}.${opt.id}`, opt);
        const html = `<p>${desc}</p> ${options.map(buildHTML).join('')}`;

        $('#icmeCfgModule').innerHTML = html;
        ConfigWindow.initColorPickers();
    }

    static initColorPickers() {
        $$('.icmeColorPicker').forEach(el => {
            el.addEventListener('change', () => {
                el.previousElementSibling.value = el.value;
            });
        });

        $$('.icmeColorPickerText').forEach(el => {
            el.addEventListener('change', () => {
                el.nextElementSibling.value = el.value;
            });
        });
    }

    load() {
        addCSS(`
            #icmeCfgMain { font-family: verdana, arial, sans-serif; }
            #icmeCfgMain hr {
                border: 0;
                height: 1px;
                width: 100%;
                background-color: #aaa;
                margin: 7px 0px;
            }
            #icmeCfgMain h3 { color: #bbb; }
            #icmeCfgModule { margin: 10px 0; }
            #icmeCfgModule > p { margin-bottom: 0.5em; }
            #icmeCfgModule > p.icmeCfgInlineOpt { display: inline-block; margin-right: 5px }
            #icmeCfgModule input { margin: 0px 3px; }
            #icmeCfgModule input[type=text] { font-family: monospace }
            #icmeCfgModule .icmeCfgTextareaDesc { vertical-align: top; margin-right: 5px }
        `);

        // Create and append a new item in the drop down menu under your username
        const cfgLink = `
            <li>
                <a id="icmeCfgTrigger" href="#"
                   title="Configure iCheckMovies Enhanced script options">ICM Enhanced</a>
            </li>`;

        $('ul#profileOptions').insertAdjacentHTML('beforeend', cfgLink);

        this.modules.sort((a, b) => (a.title > b.title ? 1 : -1));
        const options = this.modules.map(m => `<option>${m.title}</option>`);
        const ver = this.globalCfg.data.script_info.version;

        const cfgMainHtml = `
            <div id="icmeCfgMain">
                <h3>iCheckMovies Enhanced ${ver} configuration</h3>
                <select id="icmeCfgModuleList" name="modulelist">${options}</select>
                <hr>
                <div id="icmeCfgModule"></div>
            </div>
        `;

        document.body.insertAdjacentHTML('beforeend', cfgMainHtml);
        const elCfgMain = $('#icmeCfgMain');
        const elModuleList = elCfgMain.querySelector('#icmeCfgModuleList');

        elCfgMain.addEventListener('change', e => {
            if (!['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
            const path = e.target.dataset.cfgPath;
            if (!path) return;

            if (!this.globalCfg.toggle(path)) {
                this.globalCfg.set(path, e.target.value);
            }

            this.globalCfg.save();
        });

        elModuleList.addEventListener('change', () => {
            this.loadOptions(elModuleList.selectedIndex);
        });

        elModuleList.dispatchEvent(new Event('change'));

        ConfigWindow.loadModal(elCfgMain, $('#icmeCfgTrigger'));
    }

    static loadModal(elContent, elTrigger) {
        addCSS(`
            #icmeCfgModalOverlay {
                display: none;
                position: fixed;
                z-index: 3000;
                left: 0;
                top: 0;
                width: 100%;
                height: 100%;
                overflow: auto;
                background-color: rgba(0, 0, 0, 0.4);
            }

            .icmeCfgModal {
                background-color: #f5f5ef;
                margin: 80px auto;
                padding: 15px 30px;
                border: 1px solid #888;
                width: 800px;
                height: 450px;
            }

            .icmeCfgModalClose {
                color: #aaa;
                float: right;
                font-size: 28px;
                font-weight: bold;
            }

            .icmeCfgModalClose:hover,
            .icmeCfgModalClose:focus {
                color: black;
                cursor: pointer;
            }
        `);

        document.body.insertAdjacentHTML('beforeend', `
            <div id="icmeCfgModalOverlay">
                <div class="icmeCfgModal">
                    <span class="icmeCfgModalClose">&times;</span>
                </div>
            </div>
        `);

        const elModalOverlay = $('#icmeCfgModalOverlay');
        const elModal = elModalOverlay.querySelector('.icmeCfgModal');
        const elClose = $('.icmeCfgModalClose');

        elModal.append(elContent);

        elTrigger.addEventListener('click', e => {
            e.preventDefault();
            elModalOverlay.style.display = 'block';
        });

        elClose.addEventListener('click', () => {
            elModalOverlay.style.display = 'none';
        });

        window.addEventListener('click', e => {
            if (e.target !== elModalOverlay) return;
            elModalOverlay.style.display = 'none';
        });
    }
}

// ----- Modules -----

class RandomFilmLink extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Random film link',
            desc: 'Show a "Help me pick a film" link on movie lists with unchecked movies.' +
                '<br>Suggestions don\'t repeat until all have been shown once. ' +
                'Click on the list tab\'s label to return to the full list.',
            id: 'random_film',
            enableOn: ['movieList', 'movieListSpecial'], // movieListGeneral doesn't make sense here
            options: [BaseModule.getStatus(true)],
        };

        this.randomIndices = [];
    }

    attach() {
        // Disable on completed lists and list of checked/favs.
        // If a user unchecks a movie, it will show up only after reloading
        if (!$$('#itemListMovies > li.unchecked').length) return;

        const html =
            `<span style="float: right; margin-left: 15px">
                <a href="#" id="icmeRandomFilm">Help me pick a film!</a>
            </span>`;

        addToMovieListBar(html);

        $('#icmeRandomFilm').addEventListener('click', e => {
            e.preventDefault();
            this.pickRandomFilm();
        });

        // Allow resetting visible movies on /movies/watchlist/ etc. by clicking on tab's label
        const elActiveTab = $('.tabMenu > .active');
        if (!elActiveTab.querySelector('a')) {
            elActiveTab.addEventListener('click', () => {
                $$('#itemListMovies > li').forEach(el => {
                    el.style.display = 'list-item';
                });
            });
        }
    }

    pickRandomFilm() {
        const elUnchecked = $$('#itemListMovies > li.unchecked');
        if (!elUnchecked.length) return;

        if (!this.randomIndices.length) {
            this.randomIndices = [...Array(elUnchecked.length).keys()];
            RandomFilmLink.shuffle(this.randomIndices);
        }

        const selectedIndex = this.randomIndices.pop();

        $$('#itemListMovies > li').forEach(el => {
            el.style.display = 'none';
        });
        elUnchecked[selectedIndex].style.display = 'list-item';
    }

    // https://stackoverflow.com/a/12646864/6270692
    static shuffle(array) {
        for (let i = array.length - 1; i > 0; i--) {
            const j = Math.floor(Math.random() * (i + 1));
            [array[i], array[j]] = [array[j], array[i]];
        }

        return array;
    }
}

class UpcomingAwardsList extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Upcoming awards (individual lists)',
            desc: 'Show numbers of checks needed for getting awards on individual lists',
            id: 'ua_list',
            enableOn: ['movieList'],
            options: [BaseModule.getStatus(true), {
                id: 'show_negative',
                desc: 'Show negative values for received awards',
                type: 'checkbox',
                default: true,
            }],
        };
    }

    attach() {
        if (!$('#itemListMovies')) return;

        const parseNum = sel => Number($(sel).textContent.match(/\d+/));
        const totalItems = parseNum('#listFilterMovies');
        const checks = parseNum('#topListMoviesCheckedCount');

        const getSpan = ([award, cutoff]) => {
            const neededForAward = Math.ceil(totalItems * cutoff) - checks;
            if (!this.config.show_negative && neededForAward <= 0) {
                return '';
            }

            return `<span style="margin-left: 30px">${award}: <b>${neededForAward}</b></span>`;
        };

        const awardTypes = [['Bronze', 0.5], ['Silver', 0.75], ['Gold', 0.9], ['Platinum', 1]];
        const html = `<span><b>Upcoming awards:</b>${awardTypes.map(getSpan).join('')}`;
        addToMovieListBar(html);
    }
}

class UpcomingAwardsOverview extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Upcoming awards overview',
            desc: 'Show a summary of upcoming awards on the progress page and lists of your ' +
                ' watchlisted/fav. lists',
            id: 'ua',
            enableOn: ['listsSpecial', 'progress'],
            options: [BaseModule.getStatus(true), {
                id: 'hide_imdb',
                desc: 'Add all IMDb top-50s to hidden lists (you can unhide them afterwards)',
                type: 'checkbox',
                default: false,
            }],
        };

        this.lists = [];
        this.hiddenLists = [];
    }

    attach() {
        if (!$('.listItemToplist')) return;

        const hiddenLists = this.loadHiddenLists();
        const listObjs = UpcomingAwardsOverview.parseLists();
        UpcomingAwardsOverview.sortListObjects(listObjs);
        UpcomingAwardsOverview.loadCss();
        UpcomingAwardsOverview.loadHtml(listObjs, hiddenLists);
        UpcomingAwardsOverview.addListeners(hiddenLists);
    }

    loadHiddenLists() {
        const hiddenLists = load('icme_hidden_lists') ?? [];
        if (!this.config.hide_imdb) return hiddenLists;

        const imdbUrls = [
            '1910s', '1920s', '1930s', '1940s', '1950s', '1960s', '1970s', '1980s', '1990s',
            '2000s', '2010s', 'action', 'adventure', 'animation', 'biography', 'comedy',
            'crime', 'documentary', 'drama', 'family', 'fantasy', 'film-noir', 'history',
            'horror', 'independent', 'mini-series', 'music', 'musical', 'mystery', 'romance',
            'sci-fi', 'shorts', 'sport', 'thriller', 'war', 'western',
        ].map(s => `/lists/imdbs+${s}+top+50/`);
        const hiddenAndImdb = [...new Set([...hiddenLists, ...imdbUrls])]; // remove duplicates
        save('icme_hidden_lists', hiddenAndImdb);

        // This is a one-off action, disable the option so that it's not repeated every time
        this.config.hide_imdb = false;
        this.globalCfg.save();
        return hiddenAndImdb;
    }

    static parseLists() {
        // Use different selectors depending on the page
        const sel = {
            progress: { rank: 'span.rank', title: 'h3 > a' },
            lists: { rank: 'span.info > strong:first-of-type', title: 'h2 > a.title' },
        };
        const curSel = UpcomingAwardsOverview.matchesPageType('progress') ? sel.progress : sel.lists;
        const awardTypes = [['Bronze', 0.5], ['Silver', 0.75], ['Gold', 0.9], ['Platinum', 1]];

        const elLists = $$('#progressall > li, #itemListToplists > li');
        return [...elLists].flatMap(el => {
            const counts = el.querySelector(curSel.rank).textContent.match(/\d+/g);
            if (!counts) return [];

            const [checks, totalItems] = counts.map(Number);
            const elTitle = el.querySelector(curSel.title);
            const listTitle = elTitle.title.replace(/^View the | top list$/g, '');
            const listUrl = elTitle.pathname;

            const apply = cutoff => Math.ceil(totalItems * cutoff) - checks;
            return awardTypes
                .map(([awardType, cutoff]) => ({ awardType, neededForAward: apply(cutoff) }))
                .filter(({ neededForAward }) => neededForAward > 0)
                .map((obj, i) => ({ ...obj, listTitle, listUrl, isNext: i === 0 }));
        });
    }

    static sortListObjects(listObjs) {
        // By least required checks ASC, then by award type DESC, then by list title ASC
        const awardOrder = { Bronze: 0, Silver: 1, Gold: 2, Platinum: 3 };
        listObjs.sort((a, b) =>
            a.neededForAward - b.neededForAward ||
            awardOrder[b.awardType] - awardOrder[a.awardType] ||
            a.listTitle.localeCompare(b.listTitle));
    }

    static loadCss() {
        const unhideIcon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAA' +
            'AQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW' +
            '1hZ2VSZWFkeXHJZTwAAAGrSURBVDjLvZPZLkNhFIV75zjvYm7VGFNCqoZUJ+roKUUpjR' +
            'uqp61Wq0NKDMelGGqOxBSUIBKXWtWGZxAvobr8lWjChRgSF//dv9be+9trCwAI/vIE/2' +
            '6gXmviW5bqnb8yUK028qZjPfoPWEj4Ku5HBspgAz941IXZeze8N1bottSo8BTZviVWrE' +
            'h546EO03EXpuJOdG63otJbjBKHkEp/Ml6yNYYzpuezWL4s5VMtT8acCMQcb5XL3eJE8V' +
            'gBlR7BeMGW9Z4yT9y1CeyucuhdTGDxfftaBO7G4L+zg91UocxVmCiy51NpiP3n2treUP' +
            'ujL8xhOjYOzZYsQWANyRYlU4Y9Br6oHd5bDh0bCpSOixJiWx71YY09J5pM/WEbzFcDmH' +
            'vwwBu2wnikg+lEj4mwBe5bC5h1OUqcwpdC60dxegRmR06TyjCF9G9z+qM2uCJmuMJmaN' +
            'ZaUrCSIi6X+jJIBBYtW5Cge7cd7sgoHDfDaAvKQGAlRZYc6ltJlMxX03UzlaRlBdQrzS' +
            'CwksLRbOpHUSb7pcsnxCCwngvM2Rm/ugUCi84fycr4l2t8Bb6iqTxSCgNIAAAAAElFTk' +
            'SuQmCC';
        const hideIcon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQC' +
            'AYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAIGNIUk0AAHolAACAgwAA+f8AA' +
            'IDpAAB1MAAA6mAAADqYAAAXb5JfxUYAAAE+SURBVHja1JO/SsNwEMc/P7Fos2TqE1joL' +
            'B3EpRQaobsUAj6AL9C9ce8L+ACOTlkKUiglIA5iHqAdOndpk9Ik/QXO4SeSSAShkwdf7' +
            'nf3uzvurxIRjqETjqSjA5yWpPsbAA8YAeqHrQAPgMfjS3UGudZervUIxyHXWnAciu9c6' +
            '1GutVf0UcUmJnfXUu/3jXB+TjafA3DW6UCaGpvJhPrTq6oMsL29BBC71zOKRsPw9dr8T' +
            '6cAyn7+qC4hSxJs11WbIIAoguXSIIrYBAG266osSX6fQrbfA3DY7cCyYLs1sCyjK9hUl' +
            'rC4agBIcziE2aw8g26XxXgMoJpv6+oMDmlqnH0f4phVGLIKQ4hj8H2awyGHNC2vroh8I' +
            '2zVvLBVExm05YsjgzZFXdiqeUWfUgnvF+pPi9ReSnUP/ucxfQ4ASu+wNb1N4vcAAAAAS' +
            'UVORK5CYII=';

        addCSS(`
            #icmeUAO {
                z-index: 0;
                position: relative;
                margin-top: 0;
                margin-bottom: 20px;
            }
            #icmeUAOTableContainer {
                position: relative;
                top: 0;
                width: 830px;
                height: 240px;
                overflow: scroll;
            }
            #icmeUAOTableToggleContainer {
                position: relative;
                left: 0;
                top: 0;
                width: 200px;
            }
            #icmeUAOLinks {
                position: absolute;
                right: 0;
                top: 0;
                font-weight: bold;
            }
            .icmeUAOAward td:nth-child(1) { width: 65px; }
            .icmeUAOAward td:nth-child(2) { width: 65px; }
            .icmeUAOAward td:nth-child(3) div { height: 28px; overflow: hidden; }
            .icmeUAOAward td:nth-child(4) { width: 70px; }
            .icmeToggleList {
                width: 16px;
                height: 16px;
                cursor: pointer;
            }
            .icmeUAOAward.icmeHidden .icmeToggleList { background-image: url(${unhideIcon}); }
            .icmeUAOAward:not(.icmeHidden) .icmeToggleList { background-image: url(${hideIcon}); }

            #icmeUAOTable                  .icmeUAOAward               { display: none; }
            #icmeUAOTable:not(.icmeHidden) .icmeUAOAward.icmeHidden    { display: none !important; }
            #icmeUAOTable.icmeAll          .icmeUAOAward,
            #icmeUAOTable.icmeNext         .icmeUAOAward.icmeNext,
            #icmeUAOTable.icmeBronze       .icmeUAOAward.icmeBronze,
            #icmeUAOTable.icmeSilver       .icmeUAOAward.icmeSilver,
            #icmeUAOTable.icmeGold         .icmeUAOAward.icmeGold,
            #icmeUAOTable.icmePlatinum     .icmeUAOAward.icmePlatinum,
            #icmeUAOTable.icmeHidden       .icmeUAOAward.icmeHidden    { display: table-row; }
        `);
    }

    static loadHtml(listObjs, hiddenLists) {
        const html = `
            <div id="icmeUAO">
                <p id="icmeUAOTableToggleContainer">
                    <a id="icmeUAOTableToggle" href="#">
                        <span style="display: none">Show upcoming awards</span>
                        <span>Hide upcoming awards</span>
                    </a>
                </p>
                <p id="icmeUAOLinks">
                    Display:
                    <a id="icmeAll"      class="icmeUAOFilter" href="#">All</a>,
                    <a id="icmeNext"     class="icmeUAOFilter" href="#">Next</a>,
                    <a id="icmeBronze"   class="icmeUAOFilter" href="#">Bronze</a>,
                    <a id="icmeSilver"   class="icmeUAOFilter" href="#">Silver</a>,
                    <a id="icmeGold"     class="icmeUAOFilter" href="#">Gold</a>,
                    <a id="icmePlatinum" class="icmeUAOFilter" href="#">Platinum</a>,
                    <a id="icmeHidden"   class="icmeUAOFilter" href="#">Hidden</a> |
                    <a id="icmeToggleSize" href="#">
                        <span style="display: none">Minimize</span>
                        <span>Maximize</span>
                    </a>
                </p>
                <div id="icmeUAOTableContainer" class="container">
                    <table id="icmeUAOTable" class="icmeAll">
                        <thead>
                            <tr>
                                <th>Awards</th>
                                <th>Checks</th>
                                <th>List title</th>
                                <th>(Un)Hide</th>
                            </tr>
                        </thead>
                        <tbody>
                        </tbody>
                    </table>
                </div>
            </div>`;

        const sel = UpcomingAwardsOverview.matchesPageType('progress') ? '#listOrdering' : '#itemContainer';
        $(sel).insertAdjacentHTML('beforebegin', html);

        const htmlAwards = listObjs.map(({ listTitle, listUrl, awardType, neededForAward, isNext }) => `
            <tr class="icmeUAOAward icme${awardType} ${isNext ? 'icmeNext' : ''}
                    ${hiddenLists.includes(listUrl) ? 'icmeHidden' : ''}"
                    data-list-url="${listUrl}">
                <td>${awardType}</td>
                <td>${neededForAward}</td>
                <td>
                    <div>
                        <a class="icmeListTitle" href="${listUrl}">${listTitle}</a>
                    </div>
                </td>
                <td>
                    <div class="icmeToggleList" title="Toggle the list's visibility"></div>
                </td>
            </tr>
        `).join('');

        $('#icmeUAOTable tbody').insertAdjacentHTML('beforeend', htmlAwards);
    }

    static addListeners(hiddenLists) {
        const elAwards = [...$$('#icmeUAOTable .icmeUAOAward')];

        const elTable = $('#icmeUAOTable');
        elTable.addEventListener('click', e => {
            if (!e.target.classList.contains('icmeToggleList')) return;
            e.preventDefault();

            const { listUrl } = e.target.closest('.icmeUAOAward').dataset;
            const index = hiddenLists.indexOf(listUrl);
            const isVisible = index === -1;

            if (isVisible) {
                hiddenLists.push(listUrl);
            } else {
                hiddenLists.splice(index, 1);
            }

            elAwards
                .filter(el => el.dataset.listUrl === listUrl)
                .forEach(el => { el.classList.toggle('icmeHidden'); });

            save('icme_hidden_lists', hiddenLists);
        });

        const elToggle = $('#icmeUAOTableToggle');
        elToggle.addEventListener('click', e => {
            e.preventDefault();

            const els = $$('#icmeUAOLinks, #icmeUAOTableContainer');
            [...els, ...elToggle.children].forEach(el => {
                el.style.display = el.style.display === 'none' ? '' : 'none';
            });
        });

        const elToggleSize = $('#icmeToggleSize');
        const elContainer = $('#icmeUAOTableContainer');
        elToggleSize.addEventListener('click', e => {
            e.preventDefault();

            elContainer.style.height = elContainer.style.height === 'auto' ? '240px' : 'auto';
            [...elToggleSize.children].forEach(el => {
                el.style.display = el.style.display === 'none' ? '' : 'none';
            });
        });

        $$('.icmeUAOFilter').forEach(elFilter => elFilter.addEventListener('click', e => {
            e.preventDefault();
            elTable.className = elFilter.id; // switch to data attr if you add more classes to table
        }));
    }
}

class CustomMovieColors extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Custom movie colors',
            desc: 'Set movie colors on lists for your favs/watchlist/dislikes',
            id: 'movie_colors',
            enableOn: ['movieList', 'movieListGeneral', 'movieListSpecial', 'movieSearch',
                'listsGeneral', 'listsSpecial'],
            options: [BaseModule.getStatus(true), {
                id: 'favorite',
                desc: 'Favorites',
                type: 'textinputcolor',
                default: '#ffdda9',
            }, {
                id: 'watchlist',
                desc: 'Watchlist',
                type: 'textinputcolor',
                default: '#ffffd6',
            }, {
                id: 'disliked',
                desc: 'Disliked',
                type: 'textinputcolor',
                default: '#ffad99',
            }],
        };
    }

    attach() {
        const colors = [
            ['favorite', this.config.favorite],
            ['watch', this.config.watchlist],
            ['hated', this.config.disliked]];

        const buildCSS = ([className, color]) => {
            const sel = `#itemListMovies li.${className}`;
            return `${sel}, ${sel} ul.optionIconMenu { background-color: ${color} !important; }`;
        };

        addCSS(colors.map(buildCSS).join(''));
    }
}

class ListCrossRef extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Cross-reference lists',
            desc: 'Cross-reference lists to find which movies they share',
            id: 'list_cross_ref',
            enableOn: ['listsGeneral', 'listsSpecial', 'progress'],
            options: [BaseModule.getStatus(true), {
                id: 'match_all',
                desc: 'Find movies that appear on all selected lists',
                type: 'checkbox',
                default: false,
            }, {
                id: 'match_min',
                desc: 'Otherwise, find movies that appear on at least N lists (N > 1)',
                type: 'textinput',
                default: 2,
            }, {
                id: 'unchecked_only',
                desc: 'Find only unchecked movies',
                type: 'checkbox',
                default: true,
            }],
        };
    }

    attach() {
        if (!$('.listItemToplist')) return;

        const htmlActions = `
            <div id="icmeCRActions">
                Cross-reference lists:
                <button id="icmeCRStartSel">Start selection</button>
                <button id="icmeCRCancelSel">Cancel selection</button>
                <button id="icmeCRSelectAll">Select all</button>
                <button id="icmeCRRun">Run</button>
            </div>`;
        const sel = ListCrossRef.matchesPageType('progress') ? '#listOrdering' : '#itemContainer';
        $(sel).insertAdjacentHTML('beforebegin', htmlActions);

        addCSS(`
            #icmeCRActions { margin-bottom: 18px; }
            #icmeCRActions:not(.icmeCRSelecting) :not(#icmeCRStartSel) { display: none; }
            #icmeCRActions.icmeCRSelecting       #icmeCRStartSel       { display: none; }
            .icmeCRSelected, .icmeCRSelected .progress {
                background-color: #bbbbbb !important;
            }
            .icmeCRHover, .icmeCRHover .progress {
                background-color: #cccccc !important;
            }
            .icmeCRPending, .icmeCRPending .progress {
                background-color: #ffffb2 !important;
            }
        `);

        this.selectionStarted = false;
        this.attachSelectionHandlers();
        const elActions = $('#icmeCRActions');
        const [elStart, elCancel, elSelectAll, elRun] = $$('#icmeCRActions button');
        elStart.addEventListener('click', () => {
            elActions.classList.add('icmeCRSelecting');
            this.selectionStarted = true;
        });

        elCancel.addEventListener('click', () => {
            elActions.classList.remove('icmeCRSelecting');
            this.selectionStarted = false;
            $$('.icmeCRSelected, .icmeCRHover').forEach(el => {
                el.classList.remove('icmeCRSelected', 'icmeCRHover');
            });
        });

        elSelectAll.addEventListener('click', () => {
            // Select lists only from the active tab (for /progress/)
            $$(':is(ol[id^=progress]:not([style*=none]), #itemListToplists) .listItemToplist').forEach(el => {
                el.classList.add('icmeCRSelected');
            });
        });

        const setButtonState = bool => [elCancel, elSelectAll, elRun].forEach(el => {
            el.disabled = bool;
        });

        elRun.addEventListener('click', () => {
            setButtonState(true);
            this.selectionStarted = false;
            this.run().then(() => {
                setButtonState(false);
                elActions.classList.remove('icmeCRSelecting');
            });
        });
    }

    attachSelectionHandlers() {
        const eventTypes = ['click', 'mouseover', 'mouseout'];
        const elContainers = $$('ol[id^=progress], #itemListToplists');
        for (const type of eventTypes) {
            elContainers.forEach(elContainer => elContainer.addEventListener(type, e => {
                const elList = e.target.closest('.listItemToplist');
                if (!this.selectionStarted || !elList) return;

                if (e.type === 'mouseover') {
                    elList.classList.add('icmeCRHover');
                } else if (e.type === 'mouseout') {
                    elList.classList.remove('icmeCRHover');
                } else if (e.type === 'click') {
                    elList.classList.toggle('icmeCRSelected');
                }
            }));
        }
    }

    async run() {
        const elLists = [...$$('.icmeCRSelected')];
        const results = await this.fetchMovies(elLists);

        const counter = {};
        results.forEach(elMovies => ListCrossRef.updateCounter(elMovies, counter));

        this.output(elLists, counter);
    }

    async fetchMovies(elLists) {
        const sel = `#itemListMovies > li${this.config.unchecked_only ? '.unchecked' : ''}`;
        const results = [];
        for (const elList of elLists) {
            const url = elList.querySelector('a.title').href;
            elList.classList.add('icmeCRPending');

            /* eslint-disable no-await-in-loop -- Load pages one by one to reduce the load */
            const elMovies = await extractFrom(url, el => el.querySelectorAll(sel));
            results.push(elMovies);
            await sleep(500);
            /* eslint-enable no-await-in-loop */

            elList.classList.remove('icmeCRPending', 'icmeCRSelected');
        }

        return results;
    }

    static updateCounter(elMovies, counter) {
        elMovies.forEach(elMovie => {
            const { id } = elMovie;
            if (counter[id]) {
                counter[id].count += 1;
                return;
            }

            // Compatibility with the NewTabs module
            const owned = load('icme_owned_movies') ?? {};
            if (owned[id]) {
                elMovie.classList.remove('notowned');
                elMovie.classList.add('owned');
            }

            const elTitle = elMovie.querySelector('h2 a');
            const title = elTitle.textContent.trim();
            const url = elTitle.href;
            const year = elMovie.querySelector('.info > a:first-of-type').textContent;

            counter[id] = { count: 1, title, url, year, el: elMovie };
        });
    }

    output(elLists, counter) {
        let cutoff = this.config.match_all ? elLists.length : this.config.match_min;
        cutoff = Math.max(2, cutoff); // doesn't make sense to have a cutoff lower than 2
        const isOnEnoughLists = id => counter[id].count >= Math.max(2, cutoff);
        const movies = Object.keys(counter).filter(isOnEnoughLists).map(k => counter[k]);

        // Sort by checks DESC, then by year ASC, then by title ASC
        movies.sort((a, b) =>
            b.count - a.count || a.year - b.year || a.title.localeCompare(b.title));

        // Collapse visible lists from previous runs
        $$('.topListMoviesFilter.active a').forEach(el => el.click());

        const listTitles = elLists.map(el => `
            <li><b>${el.querySelector('.title').textContent.trim()}</b></li>
        `);
        const sel = ListCrossRef.matchesPageType('progress') ? '#progressall' : '#itemContainer';
        $(sel).insertAdjacentHTML('afterend', `
            <div class="icmeCRResults">
                ${movies.length} ${this.config.unchecked_only ? 'unchecked' : ''} movies
                appear on ${this.config.match_all ? 'all' : `at least ${cutoff}`} of these lists:
                <ul>${listTitles.join('')}</ul>
            </div>
        `);

        if (!movies.length) return;

        const elResults = $('.icmeCRResults');
        elResults.insertAdjacentHTML('beforeend', `
            <ul class="tabMenu tabMenuPush">
                <li class="topListMoviesFilter active">
                    <a href="#" title="View all movies">All (${movies.length})</a>
                </li>
                <li class="icmeCRExport">
                    <a href="#" title="Export all movies in CSV format">Export CSV</a>
                </li>
            </ul>
            <ol id="itemListMovies" class="itemList listViewNormal"></ol>
        `);

        // Target only the topmost list (in case there are several)
        const elMovieList = elResults.querySelector('#itemListMovies');
        for (const movie of movies) {
            movie.el.querySelector('.rank').innerHTML = movie.count;
            movie.el.style.display = ''; // movies from fetched lists might be hidden
            elMovieList.append(movie.el);
        }

        elResults.scrollIntoView(); // scroll only after all elements have been added

        // Make movie lists collapsible
        elResults.querySelector('.topListMoviesFilter a').addEventListener('click', e => {
            e.preventDefault();
            const elMovieFilter = e.target.parentElement;
            elMovieFilter.classList.toggle('active');
            elMovieList.style.display = elMovieFilter.classList.contains('active') ? '' : 'none';
        });

        // Allow exporting results as a .csv file
        const elExport = elResults.querySelector('.icmeCRExport a');
        const filename = 'Cross-referencing results';
        const { delimiter, bom } = this.globalCfg.data.export_lists;
        // eslint-disable-next-line no-use-before-define
        ExportLists.export(elExport, elMovieList.children, filename, delimiter, bom);
    }
}

class HideTags extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Hide tags',
            desc: 'Hide tags on movie lists and lists of lists in normal view',
            id: 'hide_tags',
            // ICM bug: movieListGeneral and movieSearch never have tags
            enableOn: ['listsGeneral', 'listsSpecial', 'listsSearch',
                'movieList', 'movieListGeneral', 'movieListSpecial', 'movieSearch', 'movieRankings'],
            options: [BaseModule.getStatus(false), {
                id: 'list_tags',
                frontDesc: 'Hide on: ',
                desc: 'lists',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'movie_tags',
                desc: 'movies',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'show_on_hover',
                desc: 'Show tags when moving the cursor over a movie or a list',
                type: 'checkbox',
                default: false,
            }],
        };
    }

    attach() {
        if (this.config.list_tags) {
            // /lists/ and /movies/<title>/rankings/ have different structure
            addCSS(`
                #itemListToplists.listViewNormal > li > .info:last-child,
                #itemListToplists > li > .tagList {
                    display: none !important;
                }
            `);
        }

        if (this.config.movie_tags) {
            addCSS(`
                #itemListMovies.listViewNormal > li > .tagList {
                    display: none !important;
                }
            `);
        }

        if (this.config.show_on_hover) {
            addCSS(`
                #itemListToplists.listViewNormal > li:hover > .info:last-child,
                #itemListToplists > li:hover > .tagList,
                #itemListMovies.listViewNormal > li:hover > .tagList {
                    display: block !important;
                }
            `);
        }
    }
}

class NewTabs extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'New tabs',
            desc: 'Add additional tabs on movie lists',
            id: 'new_tabs',
            enableOn: ['movieList', 'movieListGeneral', 'movieListSpecial', 'movieSearch',
                'movie', 'movieRankings'],
            options: [BaseModule.getStatus(false), {
                id: 'owned_tab',
                frontDesc: 'Create tabs for: ',
                desc: 'owned movies',
                type: 'checkbox',
                inline: true,
                default: false,
            }, {
                id: 'wlist_tab',
                desc: 'watchlisted movies',
                type: 'checkbox',
                inline: true,
                default: false,
            }, {
                id: 'free_account',
                desc: 'Store owned movies (emulates the paid feature; ' +
                    'enable only if you have a free account)',
                type: 'checkbox',
                default: false,
            }],
        };
    }

    attach() {
        if (this.config.free_account) NewTabs.trackOwned();

        if (!$('#itemListMovies')) return;
        if (NewTabs.matchesPageType('movieList') && (this.config.wlist_tab || this.config.owned_tab)) {
            NewTabs.prepareTabBar();
            if (this.config.wlist_tab) NewTabs.addNewTab('watch', 'watchlist', 'optionAddWatchlist');
            if (this.config.owned_tab) NewTabs.addNewTab('owned', 'owned', 'optionMarkOwned');
        }
    }

    static prepareTabBar() {
        // Gain some extra space in the tab bar
        const elAllTab = $('#listFilterMovies a');
        elAllTab.textContent = elAllTab.textContent.replace(' movies', '');

        // Move the 'order by' and view switch elements to the list title
        $('#topList').insertAdjacentHTML('beforeend', `
            <div id="icmeOrderByAndView"></div>
        `);
        addCSS(`
            #icmeOrderByAndView {
                z-index: 200;
                position: absolute;
                top: 30px;
                right: 0;
                width: 300px;
                height: 20px;
            }
        `);
        const elOrderBy = $('#listOrdering');
        const elView = $('#listViewswitch');
        $('#icmeOrderByAndView').append(elOrderBy, elView);
    }

    static addNewTab(itemClass, title, btnClass) {
        const elMovieList = $('#itemListMovies');
        title = title.toLowerCase();
        const titleCap = title[0].toUpperCase() + title.slice(1);
        const count = elMovieList.querySelectorAll(`:scope > li.${itemClass}`).length;
        const tabHtml = `
            <li id="listFilter${titleCap}" class="topListMoviesFilter">
                <a title="View all your ${title} movies" href="#">
                    ${titleCap}
                    <span id="topListMovies${titleCap}Count">(${count})</span>
                </a>
            </li>`;

        $('#listFilterNew').insertAdjacentHTML('beforebegin', tabHtml);

        const elTabLink = $(`#listFilter${titleCap} a`);
        elTabLink.addEventListener('click', e => {
            e.preventDefault();
            elMovieList.querySelectorAll(':scope > li.listItem')
                .forEach(el => { el.style.display = 'none'; });
            elMovieList.querySelectorAll(`:scope > li.${itemClass}`)
                .forEach(el => { el.style.display = ''; });
            $('#topListAllMovies').style.display = 'none'; // hide 'Show all'

            const elTab = elTabLink.parentElement;
            elTab.parentElement.querySelector('.active').classList.remove('active');
            elTab.classList.add('active');
        });

        // To work around the owned button click bubbling to ICM, the button stops propagation,
        // so the tab count update must happen before that, at the capturing phase
        elMovieList.addEventListener('click', e => {
            if (!e.target.classList.contains(btnClass)) return;
            const curCount = elMovieList.querySelectorAll(`:scope > li.${itemClass}`).length;
            // This capture happens before the movie class is updated (by ICM or the script)
            const delta = e.target.closest('li.listItem').classList.contains(itemClass) ? -1 : 1;
            $(`#topListMovies${titleCap}Count`).textContent = `(${curCount + delta})`;
        }, true);
    }

    static trackOwned() {
        const owned = load('icme_owned_movies') ?? {};

        const elMarkOwnedArr = $$('.optionMarkOwned');
        elMarkOwnedArr.forEach(elMarkOwned => {
            const elCheckbox = elMarkOwned.closest('.optionIconMenu').previousElementSibling;
            const elMovie = elCheckbox.parentElement;
            const id = elCheckbox.id.replace('check', 'movie');

            if (owned[id]) {
                elMovie.classList.remove('notowned');
                elMovie.classList.add('owned');
            }

            elMarkOwned = removePremiumPopup(elMarkOwned);

            elMarkOwned.addEventListener('click', e => {
                e.preventDefault();
                // ICM intercepts clicks by the class name, throwing an error in the console
                e.stopPropagation();

                // Storage could've changed in the meanwhile in other tabs
                const ownedFresh = load('icme_owned_movies') ?? {};
                if (ownedFresh[id]) {
                    delete ownedFresh[id];
                } else {
                    ownedFresh[id] = true;
                }

                elMovie.classList.toggle('notowned');
                elMovie.classList.toggle('owned');

                save('icme_owned_movies', ownedFresh);
            });
        });
    }
}

class LargePosters extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Large posters',
            desc: 'Show large posters on individual lists (replaces normal view)',
            id: 'large_posters',
            enableOn: ['movieList', 'movieListGeneral', 'movieListSpecial'],
            options: [BaseModule.getStatus(true), {
                id: 'default_view',
                desc: 'Use as the default list view',
                type: 'checkbox',
                default: false,
            }, {
                id: 'noinfo',
                desc: 'Hide info (title, year, lists)',
                type: 'checkbox',
                default: false,
            }],
        };
    }

    attach() {
        if (!$('#itemListMovies')) return;

        if (this.config.default_view) {
            this.load();
            return;
        }

        const link = `
            <span style="float: right; margin-left: 15px">
                <a id="icmeLPLink" href="#">Large posters</a>
            </span>`;

        addToMovieListBar(link);

        const elLink = $('#icmeLPLink');
        elLink.addEventListener('click', e => {
            e.preventDefault();
            this.load();
            elLink.remove();
        });
    }

    load() {
        const root = '#itemListMovies.listViewNormal';
        let css = `
            ${root} > .listItem {
                float: left;
                width: 255px;
            }
            ${root} .listItem .listImage {
                float: none;
                width: 230px;
                height: 305px;
                left: -18px;
                top: -18px;
                margin: 0;
            }
            ${root} .listImage a {
                width: 100%;
                height: 100%;
                background: url("/images/dvdCover.png") no-repeat scroll center center transparent;
            }
            ${root} .listImage .coverImage {
                width: 190px;
                height: 258px;
                top: 21px;
                left: 19px;
                right: auto;
            }
            ${root} .listItem .rank {
                top: 15px;
                position: absolute;
                height: auto;
                width: 65px;
                right: 0;
                margin: 0;
                font-size: 30px;
            }
            ${root} .listItem .rank .positiondifference span { font-size: 12px; }
            ${root} .listItem h2 {
                z-index: 11;
                font-size: 14px;
                width: 100%;
                margin:-30px 0 0 0;
            }
            ${root} .listItem .info {
                font-size: 12px;
                width: 100%;
                height: auto;
                line-height: 16px;
                margin-top: 4px;
            }
            ${root} .checkbox { top: 85px; right: 12px; }
            ${root} .optionIconMenu { top: 120px; right: 20px; }
            ${root} .optionIconMenu li { display: block; }
            ${root} .optionIconMenuCheckbox { right: 20px; }
            ${root}.icmeLPNoInfo :is(h2, .tagList, .info) { display: none; }
            ${root}.icmeLPNoInfo .listItem { height: 270px; }
            #itemListMovies.listViewCompact > .listItem { height: auto; }
        `;
        css = css.replace(/;/g, ' !important;');
        addCSS(css);

        // Normal view is used as the basis for the large posters view
        LargePosters.enableNormalView();

        $$('#itemListMovies div.coverImage').forEach(elCover => {
            elCover.style.display = 'none';
            const imgUrl = elCover.style.backgroundImage.split('"')[1]
                .replace('/small/', '/medium/')
                .replace('defaultCoverSmall', 'defaultCoverMedium');
            const imgHtml = `<img class="coverImage" src="${imgUrl}" loading="lazy">`;
            elCover.insertAdjacentHTML('afterend', imgHtml);
        });

        if (this.config.noinfo) {
            $('#itemListMovies').classList.add('icmeLPNoInfo');
        } else {
            // Imitate click on the 'Show all' button
            const elShowAllBtn = $('#topListAllMovies');
            if (elShowAllBtn) {
                $$('#itemListMovies > .listItem')
                    .forEach(el => { el.style.display = ''; });
                elShowAllBtn.style.display = 'none';
            }

            // Tags and long titles (if they are shown) can increase item's height
            LargePosters.adjustHeights();
        }
    }

    static enableNormalView() {
        const [elNormalView, elCompactView] = $$('#listViewswitch a');
        if (elNormalView.classList.contains('active')) return;
        // Modified from ICM source code (triggering the click event requires @run-at document-idle)
        elCompactView.classList.remove('active');
        elNormalView.classList.add('active');
        const elList = $('.itemList');
        elList.classList.replace('listViewCompact', 'listViewNormal');
    }

    static adjustHeights() {
        const getHeight = el => parseFloat(getComputedStyle(el).height);
        $$('.listItemMovie:nth-child(3n-2)').forEach(el1 => {
            const el2 = el1.nextElementSibling ?? el1;
            const el3 = el2.nextElementSibling ?? el1;

            const maxHeight = Math.max(...[el1, el2, el3].map(getHeight));
            [el1, el2, el3].forEach(el => {
                el.style.height = `${maxHeight}px`;
            });
        });
    }
}

class ProgressPage extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Progress page',
            desc: 'Change the order of lists on the progress page.<br>All settings can be toggled ' +
                'without reloading the page; click on the tab label to apply them',
            id: 'progress_page',
            enableOn: ['progress'],
            options: [BaseModule.getStatus(false), {
                id: 'sort_by_completion',
                frontDesc: '',
                desc: 'Sort lists by completion rate',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'desc_order',
                desc: 'in descending order',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'left_to_right',
                desc: 'Fill columns from left to right',
                type: 'checkbox',
                default: false,
            }, {
                id: 'single_col',
                desc: 'Show as a single column',
                type: 'checkbox',
                default: false,
            }, {
                id: 'hide_imdb',
                desc: 'Hide IMDb lists from "All" tab',
                type: 'checkbox',
                default: false,
            }],
        };
    }

    attach() {
        addCSS('.itemList.icmePPSingleCol .listItem.listItemProgress { float: none !important; }');

        this.originalOrder = {};
        this.rearrange('all');

        const elFilters = $$('#progressFilter [id^=progressFilter-]');
        elFilters.forEach(el => el.addEventListener('click', () => {
            const [, section] = el.id.split('-');
            this.rearrange(section);
        }));
    }

    rearrange(section) {
        const elContainer = $(`#progress${section}`);
        elContainer.classList.toggle('icmePPSingleCol', this.config.single_col);
        let elLists = [...elContainer.children];
        elLists.forEach(el => el.remove());

        elLists = ProgressPage.straighten(elLists);

        // Remember the original order at the page load (elLists must not be mutated)
        if (!this.originalOrder[section]) {
            this.originalOrder[section] = elLists;
        }

        // Undo further manipulations in case settings have changed
        elLists = this.originalOrder[section];

        if (this.config.sort_by_completion) {
            const order = this.config.desc_order === true ? -1 : 1;
            const getWidth = el => parseFloat(el.querySelector('.progress').style.width);
            const widths = new Map(elLists.map(el => [el, getWidth(el)]));
            elLists = [...elLists].sort((a, b) => order * (widths.get(a) - widths.get(b)));
        }

        if (this.config.hide_imdb && section === 'all') {
            elLists = elLists.filter(el => !el.classList.contains('imdb'));
        }

        if (!this.config.single_col && !this.config.left_to_right) {
            // Restore the default two-column view
            elLists = ProgressPage.interweave(elLists);
        }

        elContainer.append(...elLists);
    }

    // [1, 'a', 2, 'b', 3, 'c']    -> [1, 2, 3, 'a', 'b', 'c']
    // [1, 'a', 2, 'b', 3, 'c', 4] -> [1, 2, 3, 4, 'a', 'b', 'c']
    static straighten(list) {
        const even = list.filter((_, i) => i % 2 === 0);
        const odd = list.filter((_, i) => i % 2 !== 0);
        return [...even, ...odd];
    }

    // [1, 2, 3, 'a', 'b', 'c']    -> [1, 'a', 2, 'b', 3, 'c']
    // [1, 2, 3, 4, 'a', 'b', 'c'] -> [1, 'a', 2, 'b', 3, 'c', 4]
    static interweave(list) {
        const res = [];
        const halfLen = Math.ceil(list.length / 2);
        for (let i = 0; i < halfLen; i++) {
            res.push(list[i]);
            if (i + halfLen < list.length) {
                res.push(list[i + halfLen]);
            }
        }

        return res;
    }
}

class GroupMovieLists extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Group movie lists',
            desc: 'Organize a movie\'s "In lists" tab (<a href="/movies/pulp+fiction/rankings/">' +
                'example</a>) by grouping lists together and moving them to the top.<br>To create ' +
                'a group with your watchlisted/fav. lists click "Copy urls for a list group" ' +
                'on their page and paste into the fields below. You can also edit the groups manually',
            id: 'group_movie_lists',
            enableOn: ['movie', 'movieList', 'movieListGeneral', 'movieListSpecial',
                'movieRankings', 'movieSearch', 'listsGeneral', 'listsSpecial'],
            options: [BaseModule.getStatus(true), {
                id: 'redirect',
                desc: 'Redirect "in # lists" links to the tab with all lists',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'by_name',
                desc: 'sorted by name',
                type: 'checkbox',
                inline: true,
                default: false,
            }, {
                id: 'sort_official',
                frontDesc: 'Move to the top: ',
                desc: 'official',
                type: 'checkbox',
                inline: true,
                newline: true,
                default: true,
            }, {
                id: 'sort_own',
                desc: 'created by you',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'sort_groups',
                desc: 'from groups 1-2',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'sort_filmos',
                desc: 'filmographies',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'sort_nonpersonal',
                desc: 'non-personal',
                type: 'checkbox',
                inline: true,
                default: true,
            }, {
                id: 'group1',
                desc: 'Group 1',
                type: 'textarea',
                default: [],
            }, {
                id: 'group2',
                desc: 'Group 2',
                type: 'textarea',
                default: [],
            }],
        };

        // multiline regex that leaves only list name, excl. a common beginning and parameters
        this.reURL = /^[ \t]*(?:https?:\/\/)?(?:www\.)?(?:icheckmovies.com)?\/?(?:lists)?\/?([^?\s]+\/)(?:\?.+)?[ \t]*$/gm;
    }

    attach() {
        if (GroupMovieLists.matchesPageType('movieRankings')) this.reorderLists();
        if (GroupMovieLists.matchesPageType('listsSpecial')) GroupMovieLists.addExportLink();
        if (!this.config.redirect) return;
        this.fixLinks();
        this.fixLinksInNewNodes();
    }

    reorderLists() {
        addCSS(`
            .icmeGMLGroupEnd:not(:last-child) {
                margin-bottom: 25px;
                border-bottom: 2px solid #555;
            }
        `);

        const elContainer = $('#itemListToplists');
        let lists = [...elContainer.children];
        const isNotInArr = toExclude => el => !toExclude.includes(el);
        const getShortUrl = el => el.querySelector('a.title').pathname.slice(7);
        const group1Urls = this.getGroup('group1');
        const group2Urls = this.getGroup('group2');
        const username = $('.showProfileOptions').href.match(/profiles\/(.+)\//)?.[1];

        const groupLogic = [
            {
                option: this.config.sort_official,
                isInGroup: el =>
                    el.querySelector('.tagList a[href$="user%3Aicheckmovies"]') &&
                    // ICM bug: deleted lists reset to icheckmovies user
                    !el.querySelector('.title').href.endsWith('//'),
            }, {
                option: this.config.sort_own,
                isInGroup: el => el.querySelector(`.tagList a[href$="user%3A${username}"]`),
            }, {
                option: this.config.sort_groups,
                isInGroup: el => group1Urls.includes(getShortUrl(el)),
            }, {
                option: this.config.sort_groups,
                isInGroup: el => group2Urls.includes(getShortUrl(el)),
            }, {
                option: this.config.sort_filmos,
                isInGroup: el => el.textContent.toLowerCase().includes('filmography'),
            }, {
                option: this.config.sort_nonpersonal,
                isInGroup: el => !el.querySelector('.tagList a[href$="category%3Apersonal"]'),
            },
        ];

        for (const { option, isInGroup } of groupLogic) {
            if (!option) continue;
            const group = lists.filter(isInGroup);
            GroupMovieLists.move(group, elContainer);
            lists = lists.filter(isNotInArr(group));
        }
    }

    static move(elLists, elContainer) {
        if (!elLists.length) return;
        const elGroupEnds = elContainer.querySelectorAll('.icmeGMLGroupEnd');
        if (elGroupEnds.length) {
            elGroupEnds[elGroupEnds.length - 1].after(...elLists);
        } else {
            elContainer.prepend(...elLists);
        }

        elLists[elLists.length - 1].classList.add('icmeGMLGroupEnd');
    }

    getGroup(group) {
        let groupUrls = this.config[group];
        if (typeof groupUrls !== 'string') return groupUrls;
        console.log(`GroupMovieLists: parsing ${group}`);
        groupUrls = groupUrls.trim().replace(this.reURL, '$1').split('\n');
        this.config[group] = groupUrls;
        this.globalCfg.save();
        return groupUrls;
    }

    static addExportLink() {
        addNearOrderByLinks(`
            <a id="icmeGMLLink" class="icmeOrderByLink" href="#">Copy urls for a list group</a>
        `);
        $('#icmeGMLLink').addEventListener('click', e => {
            e.preventDefault();
            const listLinks = [...$$('#itemListToplists > li')]
                .filter(el => !el.querySelector('.tagList a[href$="user%3Aicheckmovies"'))
                .map(el => el.querySelector('.title').href.split('/lists/')[1]);
            const msg = 'Done! Now you can paste the urls into the "Group 1/Group 2" fields in the "Group movie lists" settings.';
            navigator.clipboard.writeText(listLinks.join('\n')).then(() => alert(msg));
        });
    }

    fixLinks(elContainer = document) {
        const sel = '.listItemMovie .info a[href*="/rankings/"], #listFilterLists a';
        const elLinks = elContainer.querySelectorAll(sel);
        elLinks.forEach(el => {
            el.href = el.href.replace('?tags=user:icheckmovies', '');
            el.href += this.config.by_name ? '?sort=name' : '';
        });
    }

    // Cross-referencing adds new blocks that must also be fixed
    fixLinksInNewNodes() {
        const onListOfLists = GroupMovieLists.matchesPageType(['listsGeneral', 'listsSpecial']);
        const isCREnabled = this.globalCfg.data.list_cross_ref.enabled;
        const elCRActions = $('#icmeCRActions');
        if (!onListOfLists || !isCREnabled || !elCRActions) return;
        const mut = new MutationObserver(mutList => mutList.forEach(({ addedNodes }) => {
            for (const el of addedNodes) {
                if (el.classList?.contains('icmeCRResults')) {
                    this.fixLinks(el);
                }
            }
        }));
        mut.observe(elCRActions.parentElement, { childList: true });
    }
}

class ExportLists extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Export lists',
            desc: 'Download any list as .csv (doesn\'t support search results).<br>' +
                'Emulates the paid feature, enable only if you have a free account. Keep in mind ' +
                'that some sites (like Letterboxd) accept only comma-separated .csv',
            id: 'export_lists',
            enableOn: ['movieList', 'movieListGeneral', 'movieListSpecial'],
            options: [BaseModule.getStatus(false), {
                id: 'delimiter',
                desc: 'Use as delimiter (accepts \';\' or \',\'; otherwise uses \\t)',
                type: 'textinput',
                default: ',',
            }, {
                id: 'bom',
                desc: 'Include BOM (required for Excel)',
                type: 'checkbox',
                default: true,
            }],
        };
    }

    attach() {
        if (!$('#itemListMovies')) return;
        let elExport = $('.optionExport');
        if (!elExport) { // /movies/unchecked/, /movies/checked/
            $('#listTitle').insertAdjacentHTML('afterbegin', `
                <ul class="optionIconMenu">
                    <li>
                        <a class="optionIcon optionExport" href="#" title="Export this list to CSV">
                            Export this list to CSV
                        </a>
                    </li>
                </ul>
            `);
            elExport = $('.optionExport');
        }

        elExport = removePremiumPopup(elExport);
        const elMovies = $$('#itemListMovies > li');
        const filename = $(':is(#topList, #listTitle) > h1').textContent;
        ExportLists.export(elExport, elMovies, filename, this.config.delimiter, this.config.bom);
    }

    static export(elExport, elMovies, filename, sep, useBom) {
        if (sep !== ',' && sep !== ';') sep = '\t';
        const wrap = field => (field.includes('"') || field.includes(sep) ?
            `"${field.replace(/"/g, '""')}"` : field);
        const colNames = ['rank', 'title', 'aka', 'year', 'official_toplists',
            'checked', 'favorite', 'dislike', 'imdburl'];
        elExport.addEventListener('click', () => {
            const rows = [...elMovies].map(el => {
                const rank = el.querySelector('.rank')?.textContent.match(/\d+/)[0] ?? '-';
                const title = wrap(el.querySelector('h2 > a').textContent);
                const aka = wrap(el.querySelector('.info > em')?.textContent ?? '');
                const year = el.querySelector('.info > a:first-of-type')?.textContent ?? '';
                const toplists = el.querySelector('.info > a:nth-of-type(2)')?.textContent.match(/\d+/)[0] ?? 0;
                const checked = el.classList.contains('checked') ? 'yes' : 'no';
                const isFav = el.classList.contains('favorite') ? 'yes' : 'no';
                const isDislike = el.classList.contains('hated') ? 'yes' : 'no';
                const imdbUrl = el.querySelector('.optionIMDB').href;
                const cols = [rank, title, aka, year, toplists, checked, isFav, isDislike, imdbUrl];
                return `${cols.join(sep)}`;
            });
            const data = `${colNames.join(sep)}\n${rows.join('\n')}`;
            // For Excel compat: BOM, ; or , as separator and no sep=
            const bom = useBom ? '\uFEFF' : '';

            elExport.href = `data:text/csv;charset=utf-8,${bom}${encodeURIComponent(data)}`;
            elExport.download = `${filename}.csv`;
        }, { once: true });
    }
}

class ProgressTopX extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Progress: checks for Top-1000',
            desc: 'Find out how many checks you need to get into Top-25/50/100/1000/...' +
                '<br>Adds a link to the progress page that will attach this number to each list.',
            id: 'progress_top_x',
            enableOn: ['progress'],
            options: [BaseModule.getStatus(true), {
                id: 'target_page',
                desc: 'Ranking page you want to be on (page x 25 = rank)',
                type: 'textinput',
                default: '40',
            }],
        };
    }

    attach() {
        addNearOrderByLinks(`
            <a id="icmePTXLink" class="icmeOrderByLink" href="#">
                Checks to get into Top-${Number(this.config.target_page) * 25}
            </a>
        `);
        const elLink = $('#icmePTXLink');
        // Can't pass the value directly in case of user changing it and not reloading
        elLink.addEventListener('click', event => this.addStats(event));
    }

    addStats(event) {
        event.preventDefault();
        const targetPage = Number(this.config.target_page); // * 25 = target rank
        const elActiveTab = [...$$('.itemListCompact[id^="progress"]')]
            .filter(el => el.style.display !== 'none')[0];
        const elListsWithoutStats = [...elActiveTab.children]
            .filter(el => !el.querySelector('.rank a:first-child'));
        const lists = elListsWithoutStats.map(elList => ({
            elTarget: elList.querySelector('.rank'),
            listUrl: elList.querySelector('.title').href,
            checks: Number(elList.querySelector('.rank').textContent.match(/\d+|-/g)[0]),
            rank: Number(elList.querySelector('.rank').textContent.match(/\d+|-/g)[2]),
        }));

        const getMinChecksFromTopusersPage = el => {
            const elLastProfile = el.querySelector('.listItemProfile:last-child');
            return Number(elLastProfile.querySelector('.info strong').textContent);
        };

        lists.forEach(async ({ elTarget, listUrl, checks, rank }) => {
            if (rank < targetPage * 25) return; // don't skip NaNs for lists with 0 checks
            // Pages higher than the last available page return the last page
            const url = `${listUrl}topusers/?page=${targetPage}`;
            const minChecks = await extractFrom(url, getMinChecksFromTopusersPage);
            const dif = minChecks - checks;

            const elText = elTarget.childNodes[0];
            elText.remove();
            elTarget.insertAdjacentHTML('afterbegin', `
                <a href="${url}" title="Checks needed to get into Top-${targetPage * 25}">
                    ${elText.textContent} - ${dif}
                </a>
            `);
        });
    }
}

class QuickListReorder extends BaseModule {
    constructor(globalCfg) {
        super(globalCfg);

        this.metadata = {
            title: 'Quick list reordering',
            desc: 'Double-click a list\'s rank to edit it. ' +
                'Hit Enter key or click outside to move the list to that position.',
            id: 'quick_list_reorder',
            enableOn: ['listsSpecial', 'movieListSpecial'],
            options: [BaseModule.getStatus(true)],
        };
    }

    attach() { // eslint-disable-line class-methods-use-this
        const elContainer = $('#itemListToplists.sortable, #itemListMovies.sortable');
        if (!elContainer) return; // /movies/checked/ are not sortable
        let oldRank;

        elContainer.addEventListener('dblclick', e => {
            if (!e.target.matches('.rank')) return;
            e.target.contentEditable = 'true';
            e.target.focus();
            oldRank = Number(e.target.textContent.trim());
        });

        elContainer.addEventListener('keydown', e => {
            if (!e.target.matches('.rank') || e.which !== 13) return;
            e.target.blur(); // sends the 'focusout' event
        });

        elContainer.addEventListener('focusout', e => {
            if (!e.target.matches('.rank')) return;
            const newRank = Number(e.target.textContent.trim());
            QuickListReorder.moveList(oldRank, newRank, e.target, elContainer);
        });
    }

    static moveList(oldRank, newRank, elRank, elContainer) {
        const inProperRange = newRank > 0 && newRank <= elContainer.children.length;
        if (!newRank || !inProperRange || newRank === oldRank) {
            elRank.textContent = oldRank;
            return;
        }

        const elList = elRank.closest('.listItem');
        const elListToShift = elContainer.children[newRank - 1];
        const moveDir = newRank < oldRank ? 'before' : 'after';
        elListToShift[moveDir](elList);
        // Modified from ICM source code
        const { id } = $('#itemListToplists, #itemListMovies');
        unsafeWindow.$.iCheckMovies.reOrderTypeSerializedItems[id] =
            unsafeWindow.$('#itemListToplists, #itemListMovies').sortable('serialize');
        unsafeWindow.$.iCheckMovies.reOrder(id);
    }
}

// ----- Main -----

// Main application; initializes, registers and loads modules.
class App {
    constructor(globalCfg) {
        this.modules = [];
        this.globalCfg = globalCfg;
        this.configWindow = new ConfigWindow(globalCfg);
    }

    register(Module) {
        const module = new Module(this.globalCfg);
        this.modules.push(module);
        module.syncGlobalCfg();
        this.configWindow.addModule(module.metadata);
    }

    load() {
        for (const m of this.modules) {
            if (m.isOnSupportedPage()) {
                if (m.config.enabled) {
                    console.log(`Attaching ${m.constructor.name}`);
                    m.attach();
                } else {
                    console.log(`Skipping ${m.constructor.name}`);
                }
            }
        }

        this.configWindow.load();
    }
}

const globalCfg = new GlobalCfg();

const useModules = [
    RandomFilmLink,
    HideTags,
    UpcomingAwardsList,
    CustomMovieColors,
    UpcomingAwardsOverview,
    ListCrossRef,
    NewTabs,
    LargePosters,
    ProgressPage,
    GroupMovieLists,
    ExportLists,
    ProgressTopX,
    QuickListReorder,
];

const app = new App(globalCfg);
useModules.forEach(m => app.register(m));
app.load();
console.log('ICM Enhanced is ready.');

// Links for testing, make sure every attached module works (check the console):
// https://www.icheckmovies.com/lists/favorited/
// https://www.icheckmovies.com/profiles/progress/
// https://www.icheckmovies.com/lists/venice+film+festival+-+golden+lion/
// https://www.icheckmovies.com/movies/watchlist/
// https://www.icheckmovies.com/movies/metropolis/rankings/