// ==UserScript==
// @name         Filterboxd
// @namespace    https://github.com/blakegearin/filterboxd
// @version      1.0.0
// @description  Filter content on Letterboxd
// @author       Blake Gearin
// @match        https://letterboxd.com/*
// @require      https://openuserjs.org/src/libs/sizzle/GM_config.js
// @grant        GM.getValue
// @grant        GM.setValue
// @license      MIT
// @icon         https://raw.githubusercontent.com/blakegearin/filterboxd/main/img/logo.svg
// @supportURL   https://github.com/blakegearin/filterboxd/issues
// ==/UserScript==
/*global GM_config*/
(function() {
  'use strict';
  const RESET = false;
  const SILENT = 0;
  const QUIET = 1;
  const INFO = 2;
  const DEBUG = 3;
  const VERBOSE = 4;
  const TRACE = 5;
  let CURRENT_LOG_LEVEL = INFO;
  const USERSCRIPT_NAME = 'Filterboxd';
  function log(level, message, variable = -1) {
    if (CURRENT_LOG_LEVEL < level) return;
    console.log(`${USERSCRIPT_NAME}: ${message}`);
    if (variable !== -1) console.log(variable);
  }
  function logError(message, variable = null) {
    console.error(`${USERSCRIPT_NAME}: ${message}`);
    if (variable) console.log(variable);
  }
  log(TRACE, 'Starting');
  function updateLogLevel() {
    CURRENT_LOG_LEVEL = {
      silent: SILENT,
      quiet: QUIET,
      info: INFO,
      debug: DEBUG,
      verbose: VERBOSE,
      trace: TRACE,
    }[GMC.get('logLevel')];
  }
  function startObserving() {
    log(DEBUG, 'startObserving()');
    OBSERVER.observe(
      document.body,
      {
        childList: true,
        subtree: true,
      },
    );
  }
  function modifyThenObserve(callback) {
    log(DEBUG, 'modifyThenObserve()');
    OBSERVER.disconnect();
    callback();
    startObserving();
  }
  function observeAndModify(mutationsList) {
    log(VERBOSE, 'observeAndModify()');
    if (IDLE_MUTATION_COUNT > MAX_IDLE_MUTATIONS) {
      // This is a failsafe to prevent infinite loops
      logError('MAX_IDLE_MUTATIONS exceeded');
      OBSERVER.disconnect();
      return;
    } else if (UPDATES_COUNT >= MAX_HEADER_UPDATES) {
      // This is a failsafe to prevent infinite loops
      logError('MAX_HEADER_UPDATES exceeded');
      OBSERVER.disconnect();
      return;
    }
    for (const mutation of mutationsList) {
      // Use header id to determine if updates have already been applied
      if (mutation.type !== 'childList') return;
      log(TRACE, 'mutation', mutation);
      maybeAddListItemToSidebar();
      const outcome = addListItemToPopMenu();
      applyFilters();
      log(DEBUG, 'outcome', outcome);
      if (outcome === 'continue') continue;
      if (outcome === 'break') break;
    }
  }
  function createId(string) {
    log(TRACE, 'createId()');
    if (string.startsWith('#')) return string;
    if (string.startsWith('.')) {
      logError(`Attempted to create an id from a class: "${string}"`);
      return;
    }
    if (string.startsWith('[')) {
      logError(`Attempted to create an id from an attribute selector: "${string}"`);
      return;
    }
    return `#${string}`;
  }
  const MAX_IDLE_MUTATIONS = 100;
  const MAX_HEADER_UPDATES = 100;
  const FILM_BEHAVIORS = [
    'Remove',
    'Fade',
    'Blur',
    'Replace poster',
    'Custom',
  ];
  const REVIEW_BEHAVIORS = [
    'Remove',
    'Fade',
    'Blur',
    'Replace text',
    'Custom',
  ];
  let IDLE_MUTATION_COUNT = 0;
  let UPDATES_COUNT = 0;
  let SELECTORS = {
    filmPosterPopMenu: {
      self: '.film-poster-popmenu',
      userscriptListItemClass: 'filterboxd-list-item',
      addToList: '.film-poster-popmenu .menu-item-add-to-list',
      addThisFilm: '.film-poster-popmenu .menu-item-add-this-film',
    },
    filter: {
      filmClass: 'filterboxd-filter-film',
      reviewClass: 'filterboxd-filter-review',
      reviewsWithSpoilers: '.film-detail:has(.contains-spoilers)',
    },
    processedClass: {
      apply: 'filterboxd-hide-processed',
      remove: 'filterboxd-unhide-processed',
    },
    settings: {
      clear: '.clear',
      favoriteFilms: '.favourite-films-selector',
      filteredTitleLinkClass: 'filtered-title-span',
      note: '.note',
      posterList: '.poster-list',
      removePendingClass: 'remove-pending',
      savedBadgeClass: 'filtered-saved',
      subNav: '.sub-nav',
      subtitle: '.mob-subtitle',
      tabbedContentId: '#tabbed-content',
    },
    userpanel: {
      self: '#userpanel',
      userscriptListItemId: 'filterboxd-list-item',
      addThisFilm: '#userpanel .add-this-film',
    },
  };
  function addListItemToPopMenu() {
    log(DEBUG, 'addListItemToPopMenu()');
    const filmPosterPopMenus = document.querySelectorAll(SELECTORS.filmPosterPopMenu.self);
    if (!filmPosterPopMenus) {
      log(`Selector ${SELECTORS.filmPosterPopMenu.self} not found`, DEBUG);
      return 'break';
    }
    filmPosterPopMenus.forEach(filmPosterPopMenu => {
      const userscriptListItem = filmPosterPopMenu.querySelector(`.${SELECTORS.filmPosterPopMenu.userscriptListItemClass}`);
      if (userscriptListItem) return;
      const lastListItem = filmPosterPopMenu.querySelector('li:last-of-type');
      if (!lastListItem) {
        logError(`Selector ${SELECTORS.filmPosterPopMenu} li:last-of-type not found`);
        return 'break';
      }
      const addToListLink = filmPosterPopMenu.querySelector(SELECTORS.filmPosterPopMenu.addToList);
      if (!addToListLink) {
        logError(`Selector ${SELECTORS.filmPosterPopMenu.addToList} not found`);
        return 'break';
      }
      const addThisFilmLink = filmPosterPopMenu.querySelector(SELECTORS.filmPosterPopMenu.addThisFilm);
      if (!addThisFilmLink) {
        logError(`Selector ${SELECTORS.filmPosterPopMenu.addThisFilm} not found`);
        return 'break';
      }
      modifyThenObserve(() => {
        let userscriptListItem = lastListItem.cloneNode(true);
        userscriptListItem.classList.add(SELECTORS.filmPosterPopMenu.userscriptListItemClass);
        userscriptListItem = buildUserscriptLink(userscriptListItem, addToListLink, addThisFilmLink);
        lastListItem.parentNode.append(userscriptListItem);
      });
    });
    return;
  }
  function addFilterToFilm({ id, slug }) {
    log(DEBUG, 'addFilterToFilm()');
    const idMatch = `[data-film-id="${id}"]`;
    let appliedSelector = `.${SELECTORS.processedClass.apply}`;
    const replaceBehavior = GMC.get('filmBehaviorType') === 'Replace poster';
    log(VERBOSE, 'replaceBehavior', replaceBehavior);
    if (replaceBehavior) appliedSelector = '[data-original-img-src]';
    // Activity page reviews
    document.querySelectorAll(`section.activity-row ${idMatch}`).forEach(posterElement => {
      applyFilterToFilm(posterElement, 3);
    });
    // Activity page likes
    document.querySelectorAll(`section.activity-row .activity-summary a[href*="${slug}"]:not(${appliedSelector})`).forEach(posterElement => {
      applyFilterToFilm(posterElement, 3);
    });
    // New from friends
    document.querySelectorAll(`.poster-container ${idMatch}:not(${appliedSelector})`).forEach(posterElement => {
      applyFilterToFilm(posterElement, 1);
    });
    // Reviews
    document.querySelectorAll(`.review-tile ${idMatch}:not(${appliedSelector})`).forEach(posterElement => {
      applyFilterToFilm(posterElement, 3);
    });
    // Diary
    document.querySelectorAll(`.td-film-details [data-original-img-src]${idMatch}:not(${appliedSelector})`).forEach(posterElement => {
      applyFilterToFilm(posterElement, 2);
    });
    // Popular with friends, competitions
    const remainingElements = document.querySelectorAll(
      `div:not(.popmenu):not(.actions-panel) ${idMatch}:not(aside [data-film-id="${id}"]):not(${appliedSelector})`,
    );
    remainingElements.forEach(posterElement => {
      applyFilterToFilm(posterElement, 0);
    });
  }
  function addToHiddenTitles(filmMetadata) {
    log(DEBUG, 'addToHiddenTitles()');
    const filmFilter = getFilter('filmFilter');
    filmFilter.push(filmMetadata);
    log(VERBOSE, 'filmFilter', filmFilter);
    setFilter('filmFilter', filmFilter);
  }
  function applyFilters() {
    log(DEBUG, 'applyFilters()');
    const filmFilter = getFilter('filmFilter');
    log(VERBOSE, 'filmFilter', filmFilter);
    const reviewFilter = getFilter('reviewFilter');
    log(VERBOSE, 'reviewFilter', reviewFilter);
    const replaceBehavior = GMC.get('reviewBehaviorType') === 'Replace text';
    log(VERBOSE, 'replaceBehavior', replaceBehavior);
    const reviewBehaviorReplaceValue = GMC.get('reviewBehaviorReplaceValue');
    log(VERBOSE, 'reviewBehaviorReplaceValue', reviewBehaviorReplaceValue);
    modifyThenObserve(() => {
      filmFilter.forEach(filmMetadata => addFilterToFilm(filmMetadata));
      const reviewsToFilter = [];
      if (reviewFilter.spoilers) {
        const reviewsWithSpoilers = document.querySelectorAll(SELECTORS.filter.reviewsWithSpoilers);
        reviewsToFilter.push(...reviewsWithSpoilers);
      }
      reviewsToFilter.forEach(filteredTitleLink => {
        if (replaceBehavior) {
          filteredTitleLink.querySelector('.body-text').innerText = reviewBehaviorReplaceValue;
        } else {
          filteredTitleLink.classList.add(SELECTORS.filter.reviewClass);
        }
      });
    });
  }
  function applyFilterToFilm(element, levelsUp = 0) {
    log(DEBUG, 'applyFilterToFilm()');
    const replaceBehavior = GMC.get('filmBehaviorType') === 'Replace poster';
    log(VERBOSE, 'replaceBehavior', replaceBehavior);
    if (replaceBehavior) {
      const filmBehaviorReplaceValue = GMC.get('filmBehaviorReplaceValue');
      log(VERBOSE, 'filmBehaviorReplaceValue', filmBehaviorReplaceValue);
      const elementImg = element.querySelector('img');
      if (!elementImg) return;
      const originalImgSrc = elementImg.src;
      if (!originalImgSrc) return;
      if (originalImgSrc === filmBehaviorReplaceValue) return;
      element.setAttribute('data-original-img-src', originalImgSrc);
      element.querySelector('img').src = filmBehaviorReplaceValue;
      element.querySelector('img').srcset = filmBehaviorReplaceValue;
      element.classList.add(SELECTORS.processedClass.apply);
    } else {
      let target = element;
      for (let i = 0; i < levelsUp; i++) {
        if (target.parentNode) {
          target = target.parentNode;
        } else {
          break;
        }
      }
      log(VERBOSE, 'target', target);
      target.classList.add(SELECTORS.filter.filmClass);
      element.classList.add(SELECTORS.processedClass.apply);
    }
  }
  function buildBehaviorFormRows(parentDiv, filterName, selectArrayValues, behaviorsMetadata) {
    const behaviorValue = GMC.get(`${filterName}BehaviorType`);
    log(DEBUG, 'behaviorValue', behaviorValue);
    const behaviorChange = (event) => {
      const filmBehaviorType = event.target.value;
      updateBehaviorCSSVariables(filterName, filmBehaviorType);
    };
    const columnOneWidth = '33%';
    const columnTwoWidth = '64.8%';
    const behaviorFormRow = createFormRow({
      formRowStyle: `width: ${columnOneWidth};`,
      labelText: 'Behavior',
      inputValue: behaviorValue,
      inputType: 'select',
      selectArray: selectArrayValues,
      selectOnChange: behaviorChange,
    });
    parentDiv.appendChild(behaviorFormRow);
    // Fade amount
    const behaviorFadeAmount = parseInt(GMC.get(behaviorsMetadata.fade.fieldName));
    log(DEBUG, 'behaviorFadeAmount', behaviorFadeAmount);
    const fadeAmountFormRow = createFormRow({
      formRowClass: ['update-details'],
      formRowStyle: `width: ${columnTwoWidth}; float: right; display: var(--filterboxd-${filterName}-behavior-fade);`,
      labelText: 'Amount',
      inputValue: behaviorFadeAmount,
      inputType: 'select',
      inputStyle: 'width: 100px !important;',
      selectArray: [ 0, 5, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90],
      notes: '%',
      notesStyle: 'width: 10px; margin-left: 14px;',
    });
    parentDiv.appendChild(fadeAmountFormRow);
    // Blur amount
    const behaviorBlurAmount = parseInt(GMC.get(behaviorsMetadata.blur.fieldName));
    log(DEBUG, 'behaviorBlurAmount', behaviorBlurAmount);
    const blurAmountFormRow = createFormRow({
      formRowClass: ['update-details'],
      formRowStyle: `width: ${columnTwoWidth}; float: right; display: var(--filterboxd-${filterName}-behavior-blur);`,
      labelText: 'Amount',
      inputValue: behaviorBlurAmount,
      inputType: 'select',
      inputStyle: 'width: 100px !important;',
      selectArray: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 1000 ],
      notes: 'px',
      notesStyle: 'width: 10px; margin-left: 14px;',
    });
    parentDiv.appendChild(blurAmountFormRow);
    // Replace value
    const behaviorReplaceValue = GMC.get(behaviorsMetadata.replace.fieldName);
    log(DEBUG, 'behaviorReplaceValue', behaviorReplaceValue);
    const replaceValueFormRow = createFormRow({
      formRowStyle: `width: ${columnTwoWidth}; float: right; display: var(--filterboxd-${filterName}-behavior-replace);`,
      labelText: behaviorsMetadata.replace.labelText,
      inputValue: behaviorReplaceValue,
      inputType: 'text',
    });
    parentDiv.appendChild(replaceValueFormRow);
    // Custom CSS
    const behaviorCustomValue = GMC.get(behaviorsMetadata.custom.fieldName);
    log(DEBUG, 'behaviorCustomValue', behaviorCustomValue);
    const cssFormRow = createFormRow({
      formRowStyle: `width: ${columnTwoWidth}; float: right; display: var(--filterboxd-${filterName}-behavior-custom);`,
      labelText: 'CSS',
      inputValue: behaviorCustomValue,
      inputType: 'text',
    });
    parentDiv.appendChild(cssFormRow);
    return [
      behaviorFormRow,
      fadeAmountFormRow,
      blurAmountFormRow,
      replaceValueFormRow,
      behaviorCustomValue,
    ];
  }
  function buildListItemToggle(labelText, checked, filterName, fieldName) {
    const listItem = document.createElement('li');
    listItem.classList.add('option');
    const label = document.createElement('label');
    listItem.appendChild(label);
    label.classList.add('option-label', '-toggle', 'switch-control');
    const labelSpan = document.createElement('span');
    label.appendChild(labelSpan);
    labelSpan.classList.add('label');
    labelSpan.innerText = labelText;
    const labelInput = document.createElement('input');
    label.appendChild(labelInput);
    labelInput.classList.add('checkbox');
    labelInput.setAttribute('type', 'checkbox');
    labelInput.setAttribute('role', 'switch');
    labelInput.setAttribute('data-filter-name', filterName);
    labelInput.setAttribute('data-field-name', fieldName);
    labelInput.checked = checked;
    const labelCheckboxSpan = document.createElement('span');
    label.appendChild(labelCheckboxSpan);
    labelCheckboxSpan.classList.add('state');
    const checkboxTrackSpan = document.createElement('span');
    labelCheckboxSpan.appendChild(checkboxTrackSpan);
    checkboxTrackSpan.classList.add('track');
    const checkboxHandleSpan = document.createElement('span');
    checkboxTrackSpan.appendChild(checkboxHandleSpan);
    checkboxHandleSpan.classList.add('handle');
    return listItem;
  }
  function buildUserscriptLink(userscriptListItem, addToListLink, addThisFilmLink) {
    const userscriptLink = userscriptListItem.firstElementChild;
    userscriptListItem.onclick = (event) => {
      event.preventDefault();
      log(DEBUG, 'userscriptListItem clicked');
      const link = event.target;
      const id = parseInt(link.getAttribute('data-film-id'));
      const slug = link.getAttribute('data-film-slug');
      const name = link.getAttribute('data-film-name');
      const year = link.getAttribute('data-film-release-year');
      const filmMetadata = {
        id,
        slug,
        name,
        year,
      };
      const titleIsHidden = link.getAttribute('data-title-hidden') === 'true';
      modifyThenObserve(() => {
        if (titleIsHidden) {
          removeFilterFromFilm(filmMetadata);
          removeFromFilmFilter(filmMetadata);
        } else {
          addFilterToFilm(filmMetadata);
          addToHiddenTitles(filmMetadata);
        }
        updateLinkInPopMenu(!titleIsHidden, link);
      });
    };
    const titleId = parseInt(addToListLink.getAttribute('data-film-id'));
    userscriptLink.setAttribute('data-film-id', titleId);
    const filmAction = addToListLink.getAttribute('data-new-list-with-film-action');
    log(VERBOSE, 'filmAction', filmAction);
    const titleSlug = filmAction.split('/').at(-2);
    userscriptLink.setAttribute('data-film-slug', titleSlug);
    const titleName = addThisFilmLink.getAttribute('data-film-name');
    userscriptLink.setAttribute('data-film-name', titleName);
    const titleYear = addThisFilmLink.getAttribute('data-film-release-year');
    userscriptLink.setAttribute('data-film-release-year', titleYear);
    const titleIsHidden = getFilter('filmFilter').some(filteredFilm => filteredFilm.id === titleId);
    updateLinkInPopMenu(titleIsHidden, userscriptLink);
    userscriptLink.removeAttribute('class');
    return userscriptListItem;
  }
  function createFormRow({
    formRowClass = [],
    formRowStyle = '',
    labelText = '',
    inputValue = '',
    inputType = 'text',
    inputStyle = '',
    selectArray = [],
    selectOnChange = () => {},
    notes = '',
    notesStyle = '',
  }) {
    const formRow = document.createElement('div');
    formRow.classList.add('form-row');
    formRow.style.cssText = formRowStyle;
    formRow.classList.add(...formRowClass);
    const selectList = document.createElement('div');
    formRow.appendChild(selectList);
    selectList.classList.add('select-list');
    const label = document.createElement('label');
    selectList.appendChild(label);
    label.classList.add('label');
    label.textContent = labelText;
    const inputDiv = document.createElement('div');
    selectList.appendChild(inputDiv);
    inputDiv.classList.add('input');
    inputDiv.style.cssText = inputStyle;
    if (inputType === 'select') {
      const select = document.createElement('select');
      inputDiv.appendChild(select);
      select.classList.add('select');
      selectArray.forEach(option => {
        const optionElement = document.createElement('option');
        select.appendChild(optionElement);
        optionElement.value = option;
        optionElement.textContent = option;
        if (option === inputValue) optionElement.setAttribute('selected', 'selected');
      });
      select.onchange = selectOnChange;
    } else if (inputType === 'text') {
      const input = document.createElement('input');
      inputDiv.appendChild(input);
      input.type = 'text';
      input.classList.add('field');
      input.value = inputValue;
    }
    if (notes) {
      const notesElement = document.createElement('p');
      selectList.appendChild(notesElement);
      notesElement.classList.add('notes');
      notesElement.style.cssText = notesStyle;
      notesElement.textContent = notes;
    }
    return formRow;
  }
  function displaySavedBadge() {
    const savedBadge = document.querySelector(`.${SELECTORS.settings.savedBadgeClass}`);
    savedBadge.classList.remove('hidden');
    savedBadge.classList.add('fade');
    setTimeout(() => {
      savedBadge.classList.add('fade-out');
    }, 2000);
    setTimeout(() => {
      savedBadge.classList.remove('fade', 'fade-out');
      savedBadge.classList.add('hidden');
    }, 3000);
  }
  function getFilter(filterName) {
    return JSON.parse(GMC.get(filterName));
  }
  function getFilterBehaviorStyle(filterName) {
    let behaviorStyle;
    let behaviorType = GMC.get(`${filterName}BehaviorType`);
    log(VERBOSE, 'behaviorType', behaviorType);
    const behaviorFadeAmount = GMC.get(`${filterName}BehaviorFadeAmount`);
    log(VERBOSE, 'behaviorFadeAmount', behaviorFadeAmount);
    const behaviorBlurAmount = GMC.get(`${filterName}BehaviorBlurAmount`);
    log(VERBOSE, 'behaviorBlurAmount', behaviorBlurAmount);
    const behaviorCustomValue = GMC.get(`${filterName}BehaviorCustomValue`);
    log(VERBOSE, 'behaviorCustomValue', behaviorCustomValue);
    switch (behaviorType) {
      case 'Remove':
        behaviorStyle = 'display: none !important;';
        break;
      case 'Fade':
        behaviorStyle = `opacity: ${behaviorFadeAmount}%`;
        break;
      case 'Blur':
        behaviorStyle = `filter: blur(${behaviorBlurAmount}px)`;
        break;
      case 'Custom':
        behaviorStyle = behaviorCustomValue;
        break;
    }
    updateBehaviorCSSVariables(filterName, behaviorType);
    return behaviorStyle;
  }
  function gmcInitialized() {
    log(DEBUG, 'gmcInitialized()');
    updateLogLevel();
    log(QUIET, 'Running');
    GMC.css.basic = '';
    if (RESET) {
      log(QUIET, 'Resetting GMC');
      setFilter('filmFilter', []);
      setFilter('reviewFilter', {});
      GMC.reset();
      GMC.save();
    }
    let userscriptStyle = document.createElement('style');
    userscriptStyle.setAttribute('id', 'filterboxd-style');
    const filmBehaviorStyle = getFilterBehaviorStyle('film');
    log(VERBOSE, 'filmBehaviorStyle', filmBehaviorStyle);
    const reviewBehaviorStyle = getFilterBehaviorStyle('review');
    log(VERBOSE, 'reviewBehaviorStyle', reviewBehaviorStyle);
    userscriptStyle.textContent += `
      .${SELECTORS.filter.filmClass}
      {
        ${filmBehaviorStyle}
      }
      .${SELECTORS.filter.reviewClass}
      {
        ${reviewBehaviorStyle}
      }
      .${SELECTORS.settings.filteredTitleLinkClass}
      {
        cursor: pointer;
        margin-right: 0.3rem !important;
      }
      .${SELECTORS.settings.filteredTitleLinkClass}:hover
      {
        background: #303840;
        color: #def;
      }
      .${SELECTORS.settings.removePendingClass}
      {
        outline: 1px dashed #ee7000;
        outline-offset: -1px;
      }
      .hidden {
        visibility: hidden;
      }
      .fade {
        opacity: 1;
        transition: opacity 1s ease-out;
      }
      .fade.fade-out {
        opacity: 0;
      }
    `;
    document.body.appendChild(userscriptStyle);
    applyFilters();
    maybeAddConfigurationToSettings();
    startObserving();
  }
  function maybeAddConfigurationToSettings() {
    log(DEBUG, 'maybeAddConfigurationToSettings()');
    const userscriptTabId = 'tab-filterboxd';
    const configurationExists = document.querySelector(createId(userscriptTabId));
    log(VERBOSE, 'configurationExists', configurationExists);
    const onSettingsPage = window.location.href.includes('/settings/');
    log(VERBOSE, 'onSettingsPage', onSettingsPage);
    if (!onSettingsPage || configurationExists) {
      log(DEBUG, 'Not in settings or Filterboxd configuration tab is present');
      return;
    }
    const userscriptTabDiv = document.createElement('div');
    const settingsTabbedContent = document.querySelector(SELECTORS.settings.tabbedContentId);
    settingsTabbedContent.appendChild(userscriptTabDiv);
    userscriptTabDiv.setAttribute('id', userscriptTabId);
    userscriptTabDiv.classList.add('tabbed-content-block');
    const tabTitle = document.createElement('h2');
    userscriptTabDiv.append(tabTitle);
    tabTitle.style.cssText = 'margin-bottom: 1em;';
    tabTitle.innerText = 'Filterboxd';
    const tabPrimaryColumn = document.createElement('div');
    userscriptTabDiv.append(tabPrimaryColumn);
    tabPrimaryColumn.classList.add('col-10', 'overflow');
    const asideColumn = document.createElement('aside');
    userscriptTabDiv.append(asideColumn);
    asideColumn.classList.add('col-12', 'overflow', 'col-right', 'js-hide-in-app');
    // Filter reviews
    const filteredReviewsTitle = document.createElement('h3');
    asideColumn.append(filteredReviewsTitle);
    filteredReviewsTitle.classList.add('title-3');
    filteredReviewsTitle.style.cssText = 'margin-top: 0em;';
    filteredReviewsTitle.innerText = 'Filter Reviews';
    const filteredReviewsUnorderedList = document.createElement('ul');
    asideColumn.append(filteredReviewsUnorderedList);
    filteredReviewsUnorderedList.classList.add('options-list', '-toggle-list', 'js-toggle-list');
    const fieldName = 'spoilers';
    const reviewFilter = getFilter('reviewFilter');
    const checked = reviewFilter[fieldName] || false;
    const spoilerListItem = buildListItemToggle(
      'Filter reviews that contain spoilers',
      checked,
      'reviewFilter',
      fieldName,
    );
    filteredReviewsUnorderedList.appendChild(spoilerListItem);
    let reviewColumnsDiv = document.createElement('div');
    asideColumn.appendChild(reviewColumnsDiv);
    reviewColumnsDiv.classList.add('form-columns', '-cols2');
    const reviewBehaviorsMetadata = {
      fade: {
        fieldName: 'reviewBehaviorFadeAmount',
      },
      blur: {
        fieldName: 'reviewBehaviorBlurAmount',
      },
      replace: {
        fieldName: 'reviewBehaviorReplaceValue',
        labelText: 'Text',
      },
      custom: {
        fieldName: 'reviewBehaviorCustomValue',
      },
    };
    const reviewFormRows = buildBehaviorFormRows(
      reviewColumnsDiv,
      'review',
      REVIEW_BEHAVIORS,
      reviewBehaviorsMetadata,
    );
    // Filter films
    const favoriteFilmsDiv = document.querySelector(SELECTORS.settings.favoriteFilms);
    const filteredFilmsDiv = favoriteFilmsDiv.cloneNode(true);
    tabPrimaryColumn.appendChild(filteredFilmsDiv);
    const posterList = filteredFilmsDiv.querySelector(SELECTORS.settings.posterList);
    posterList.remove();
    filteredFilmsDiv.querySelector(SELECTORS.settings.subtitle).innerText = 'Filter Films';
    filteredFilmsDiv.querySelector(SELECTORS.settings.note).innerText =
      'Right click to mark for removal.';
    let hiddenTitlesDiv = document.createElement('div');
    filteredFilmsDiv.append(hiddenTitlesDiv);
    const hiddenTitlesParagraph = document.createElement('p');
    hiddenTitlesDiv.appendChild(hiddenTitlesParagraph);
    hiddenTitlesDiv.classList.add('text-sluglist');
    const filmFilter = getFilter('filmFilter');
    log(VERBOSE, 'filmFilter', filmFilter);
    filmFilter.forEach(filteredFilm => {
      log(VERBOSE, 'filteredFilm', filteredFilm);
      let filteredTitleLink = document.createElement('a');
      hiddenTitlesParagraph.appendChild(filteredTitleLink);
      filteredTitleLink.href= `/film/${filteredFilm.slug}`;
      filteredTitleLink.classList.add(
        'text-slug',
        SELECTORS.processedClass.apply,
        SELECTORS.settings.filteredTitleLinkClass,
      );
      filteredTitleLink.setAttribute('data-film-id', filteredFilm.id);
      filteredTitleLink.innerText = `${filteredFilm.name} (${filteredFilm.year})`;
      filteredTitleLink.oncontextmenu = (event) => {
        event.preventDefault();
        filteredTitleLink.classList.toggle(SELECTORS.settings.removePendingClass);
      };
    });
    let formColumnsDiv = document.createElement('div');
    filteredFilmsDiv.appendChild(formColumnsDiv);
    formColumnsDiv.classList.add('form-columns', '-cols2');
    // Filter films behavior
    const filmBehaviorsMetadata = {
      fade: {
        fieldName: 'filmBehaviorFadeAmount',
      },
      blur: {
        fieldName: 'filmBehaviorBlurAmount',
      },
      replace: {
        fieldName: 'filmBehaviorReplaceValue',
        labelText: 'URL',
      },
      custom: {
        fieldName: 'filmBehaviorCustomValue',
      },
    };
    const filmFormRows = buildBehaviorFormRows(
      formColumnsDiv,
      'film',
      FILM_BEHAVIORS,
      filmBehaviorsMetadata,
    );
    const clearDiv = filteredFilmsDiv.querySelector(SELECTORS.settings.clear);
    clearDiv.remove();
    let buttonsRowDiv = document.createElement('div');
    userscriptTabDiv.appendChild(buttonsRowDiv);
    buttonsRowDiv.style.cssText = 'display: flex; align-items: center;';
    buttonsRowDiv.classList.add('buttons', 'clear', 'row');
    let saveInput = document.createElement('input');
    buttonsRowDiv.appendChild(saveInput);
    saveInput.classList.add('button', 'button-action');
    saveInput.setAttribute('value', 'Save Changes');
    saveInput.setAttribute('type', 'submit');
    saveInput.onclick = (event) => {
      event.preventDefault();
      const pendingRemovals = hiddenTitlesParagraph.querySelectorAll(`.${SELECTORS.settings.removePendingClass}`);
      pendingRemovals.forEach(removalLink => {
        const id = parseInt(removalLink.getAttribute('data-film-id'));
        const filteredFilm = filmFilter.find(filteredFilm => filteredFilm.id === id);
        removeFilterFromFilm(filteredFilm);
        removeFromFilmFilter(filteredFilm);
        removalLink.remove();
      });
      saveBehaviorSettings('film', filmFormRows);
      saveBehaviorSettings('review', reviewFormRows);
      const inputToggles = userscriptTabDiv.querySelectorAll('input[type="checkbox"]');
      inputToggles.forEach(inputToggle => {
        const filterName = inputToggle.getAttribute('data-filter-name');
        const filter = getFilter(filterName);
        const fieldName = inputToggle.getAttribute('data-field-name');
        const checked = inputToggle.checked;
        filter[fieldName] = checked;
        setFilter(filterName, filter);
      });
      displaySavedBadge();
    };
    let checkContainerDiv = document.createElement('div');
    buttonsRowDiv.appendChild(checkContainerDiv);
    checkContainerDiv.classList.add('check-container');
    checkContainerDiv.style.cssText = 'margin-left: 10px;';
    let usernameAvailableParagraph = document.createElement('p');
    checkContainerDiv.appendChild(usernameAvailableParagraph);
    usernameAvailableParagraph.classList.add(
      'username-available',
      'has-icon',
      'hidden',
      SELECTORS.settings.savedBadgeClass,
    );
    usernameAvailableParagraph.style.cssText = 'float: left;';
    let iconSpan = document.createElement('span');
    usernameAvailableParagraph.appendChild(iconSpan);
    iconSpan.classList.add('icon');
    const savedText = document.createTextNode('Saved');
    usernameAvailableParagraph.appendChild(savedText);
    const settingsSubNav = document.querySelector(SELECTORS.settings.subNav);
    const userscriptSubNabListItem = document.createElement('li');
    settingsSubNav.appendChild(userscriptSubNabListItem);
    const userscriptSubNabLink = document.createElement('a');
    userscriptSubNabListItem.appendChild(userscriptSubNabLink);
    const userscriptSettingsLink = '/settings/?filterboxd';
    userscriptSubNabLink.setAttribute('href', userscriptSettingsLink);
    userscriptSubNabLink.setAttribute('data-id', 'filterboxd');
    userscriptSubNabLink.innerText = 'Filterboxd';
    userscriptSubNabLink.onclick = (event) => {
      event.preventDefault();
      Array.from(settingsSubNav.children).forEach(listItem => {
        const link = listItem.querySelector('a');
        if (link.getAttribute('data-id') === 'filterboxd') {
          listItem.classList.add('selected');
        } else {
          listItem.classList.remove('selected');
        }
      });
      Array.from(settingsTabbedContent.children).forEach(tab => {
        if (!tab.id) return;
        const display = tab.id === userscriptTabId ? 'block' : 'none';
        tab.style.cssText = `display: ${display};`;
      });
      window.history.replaceState(null, '', `https://letterboxd.com${userscriptSettingsLink}`);
    };
    Array.from(settingsSubNav.children).forEach(listItem => {
      listItem.onclick = (event) => {
        const link = event.target;
        if (link.getAttribute('href') === userscriptSettingsLink) return;
        userscriptSubNabListItem.classList.remove('selected');
        userscriptTabDiv.style.display = 'none';
      };
    });
    const urlParams = new URLSearchParams(window.location.search);
    const tabSelected = urlParams.get('filterboxd') !== null;
    log(VERBOSE, 'tabSelected', tabSelected);
    // TODO: Fix unreliability
    if (tabSelected) window.onload = () => userscriptSubNabLink.click();
  }
  function maybeAddListItemToSidebar() {
    log(DEBUG, 'maybeAddListItemToSidebar()');
    if (document.querySelector(createId(SELECTORS.userpanel.userscriptListItemId))) return;
    const userpanel = document.querySelector(SELECTORS.userpanel.self);
    if (!userpanel) {
      log(INFO, 'Userpanel not found');
      return;
    }
    const secondLastListItem = userpanel.querySelector('li:nth-last-child(2)');
    if (!secondLastListItem ) {
      log(INFO, 'Second last list item not found');
      return;
    }
    let userscriptListItem = secondLastListItem.cloneNode(true);
    userscriptListItem.setAttribute('id', SELECTORS.userpanel.userscriptListItemId);
    const addToListLink = secondLastListItem.firstElementChild;
    const addThisFilmLink = userpanel.querySelector(SELECTORS.userpanel.addThisFilm);
    userscriptListItem = buildUserscriptLink(userscriptListItem, addToListLink, addThisFilmLink);
    secondLastListItem.parentNode.insertBefore(userscriptListItem, userpanel.querySelector('li:nth-last-of-type(1)'));
  }
  function removeFilterFromElement(element, levelsUp = 0) {
    log(DEBUG, 'removeFilterFromElement()');
    const replaceBehavior = GMC.get('filmBehaviorType') === 'Replace poster';
    log(VERBOSE, 'replaceBehavior', replaceBehavior);
    if (replaceBehavior) {
      const originalImgSrc = element.getAttribute('data-original-img-src');
      if (!originalImgSrc) return;
      element.querySelector('img').src = originalImgSrc;
      element.querySelector('img').srcset = originalImgSrc;
      element.removeAttribute('data-original-img-src');
      element.classList.add(SELECTORS.processedClass.remove);
    } else {
      let target = element;
      for (let i = 0; i < levelsUp; i++) {
        if (target.parentNode) {
          target = target.parentNode;
        } else {
          break;
        }
      }
      log(VERBOSE, 'target', target);
      target.classList.remove(SELECTORS.filter.filmClass);
      element.classList.add(SELECTORS.processedClass.remove);
    }
  }
  function removeFromFilmFilter(filmMetadata) {
    let filmFilter = getFilter('filmFilter');
    filmFilter = filmFilter.filter(filteredFilm => filteredFilm.id !== filmMetadata.id);
    setFilter('filmFilter', filmFilter);
  }
  function removeFilterFromFilm({ id, slug }) {
    log(DEBUG, 'removeFilterFromFilm()');
    const idMatch = `[data-film-id="${id}"]`;
    let removedSelector = `.${SELECTORS.processedClass.remove}`;
    // Activity page reviews
    document.querySelectorAll(`section.activity-row ${idMatch}`).forEach(posterElement => {
      removeFilterFromElement(posterElement, 3);
    });
    // Activity page likes
    document.querySelectorAll(`section.activity-row .activity-summary a[href*="${slug}"]:not(${removedSelector})`).forEach(posterElement => {
      removeFilterFromElement(posterElement, 3);
    });
    // New from friends
    document.querySelectorAll(`.poster-container ${idMatch}:not(${removedSelector})`).forEach(posterElement => {
      removeFilterFromElement(posterElement, 1);
    });
    // Reviews
    document.querySelectorAll(`.review-tile ${idMatch}:not(${removedSelector})`).forEach(posterElement => {
      removeFilterFromElement(posterElement, 3);
    });
    // Diary
    document.querySelectorAll(`.td-film-details [data-original-img-src]${idMatch}:not(${removedSelector})`).forEach(posterElement => {
      removeFilterFromElement(posterElement, 2);
    });
    // Popular with friends, competitions
    const remainingElements = document.querySelectorAll(
      `div:not(.popmenu):not(.actions-panel) ${idMatch}:not(aside [data-film-id="${id}"]):not(${removedSelector})`,
    );
    remainingElements.forEach(posterElement => {
      removeFilterFromElement(posterElement, 0);
    });
  }
  function saveBehaviorSettings(filterName, formRows) {
    const behaviorType = formRows[0].querySelector('select').value;
    log(DEBUG, 'behaviorType', behaviorType);
    GMC.set(`${filterName}BehaviorType`, behaviorType);
    updateBehaviorCSSVariables(filterName, behaviorType);
    if (behaviorType === 'Fade') {
      const behaviorFadeAmount = formRows[1].querySelector('select').value;
      log(DEBUG, 'behaviorFadeAmount', behaviorFadeAmount);
      GMC.set(`${filterName}BehaviorFadeAmount`, behaviorFadeAmount);
    } else if (behaviorType === 'Blur') {
      const behaviorBlurAmount = formRows[2].querySelector('select').value;
      log(DEBUG, 'behaviorBlurAmount', behaviorBlurAmount);
      GMC.set(`${filterName}BehaviorBlurAmount`, behaviorBlurAmount);
    } else if (behaviorType.includes('Replace')) {
      const behaviorReplaceValue = formRows[3].querySelector('input').value;
      log(DEBUG, 'behaviorReplaceValue', behaviorReplaceValue);
      GMC.set(`${filterName}BehaviorReplaceValue`, behaviorReplaceValue);
    } else if (behaviorType === 'Custom') {
      const behaviorCustomValue = formRows[4].querySelector('input').value;
      log(DEBUG, 'behaviorCustomValue', behaviorCustomValue);
      GMC.set(`${filterName}BehaviorCustomValue`, behaviorCustomValue);
    }
    GMC.save();
  }
  function setFilter(filterName, filterValue) {
    GMC.set(filterName, JSON.stringify(filterValue));
    return GMC.save();
  }
  function updateBehaviorCSSVariables(filterName, behaviorType) {
    log(DEBUG, 'updateBehaviorTypeVariable()');
    const fadeValue = behaviorType === 'Fade' ? 'block' : 'none';
    document.documentElement.style.setProperty(
      `--filterboxd-${filterName}-behavior-fade`,
      fadeValue,
    );
    const blurValue = behaviorType === 'Blur' ? 'block' : 'none';
    document.documentElement.style.setProperty(
      `--filterboxd-${filterName}-behavior-blur`,
      blurValue,
    );
    const replaceValue = behaviorType.includes('Replace') ? 'block' : 'none';
    document.documentElement.style.setProperty(
      `--filterboxd-${filterName}-behavior-replace`,
      replaceValue,
    );
    const customValue = behaviorType === 'Custom' ? 'block' : 'none';
    document.documentElement.style.setProperty(
      `--filterboxd-${filterName}-behavior-custom`,
      customValue,
    );
  }
  function updateLinkInPopMenu(titleIsHidden, link) {
    log(DEBUG, 'updateLinkInPopMenu()');
    link.setAttribute('data-title-hidden', titleIsHidden);
    const innerText = titleIsHidden ? 'Remove from filter' : 'Add to filter';
    link.innerText = innerText;
  }
  let OBSERVER = new MutationObserver(observeAndModify);
  let GMC = new GM_config({
    id: 'gmc-frame',
    events: {
      init: gmcInitialized,
    },
    fields: {
      filmBehaviorType: {
        type: 'select',
        options: FILM_BEHAVIORS,
        default: 'Fade',
      },
      filmBehaviorBlurAmount: {
        type: 'int',
        default: 3,
      },
      filmBehaviorCustomValue: {
        type: 'text',
        default: '',
      },
      filmBehaviorFadeAmount: {
        type: 'int',
        default: 10,
      },
      filmBehaviorReplaceValue: {
        type: 'text',
        default: 'https://a.ltrbxd.com/resized/film-poster/4/8/7/9/1/48791-bee-movie-0-230-0-345-crop.jpg?v=2b9ece5cba',
      },
      filmFilter: {
        type: 'text',
        default: JSON.stringify([]),
      },
      logLevel: {
        type: 'select',
        options: [
          'silent',
          'quiet',
          'debug',
          'verbose',
          'trace',
        ],
        default: 'quiet',
      },
      reviewBehaviorType: {
        type: 'select',
        options: REVIEW_BEHAVIORS,
        default: 'Fade',
      },
      reviewBehaviorBlurAmount: {
        type: 'int',
        default: 3,
      },
      reviewBehaviorCustomValue: {
        type: 'text',
        default: '',
      },
      reviewBehaviorFadeAmount: {
        type: 'int',
        default: 10,
      },
      reviewBehaviorReplaceValue: {
        type: 'text',
        default: 'According to all known laws of aviation, there is no way a bee should be able to fly.',
      },
      reviewFilter: {
        type: 'text',
        default: JSON.stringify({}),
      },
    },
  });
})();