Greasy Fork is available in English.

🐭️ MouseHunt Utils Beta

Testing version

このスクリプトは単体で利用できません。右のようなメタデータを含むスクリプトから、ライブラリとして読み込まれます: // @require https://update.greasyfork.org/scripts/464008/1261903/%F0%9F%90%AD%EF%B8%8F%20MouseHunt%20Utils%20Beta.js

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name         🐭️ MouseHunt Utils
// @author       bradp
// @version      1.10.5
// @description  MouseHunt Utils is a library of functions that can be used to make other MouseHunt userscripts easily.
// @license      MIT
// @namespace    bradp
// @match        https://www.mousehuntgame.com/*
// @icon         https://i.mouse.rip/mouse.png
// @grant        none
// ==/UserScript==

/* eslint-disable no-unused-vars */

/**
 * Add styles to the page.
 *
 * @author bradp
 * @since 1.0.0
 *
 * @example <caption>Basic usage</caption>
 * addStyles(`.my-class {
 *   color: red;
 * }`);
 *
 * @example <caption>With an identifier</caption>
 * addStyles(`.my-class {
 * display: none;
 * }`, 'my-identifier');
 *
 * @example <caption>With an identifier, but will only add the styles once</caption>
 * addStyles(`.my-other-class {
 * color: blue;
 * }`, 'my-identifier', true);
 *
 * @param {string}  styles     The styles to add.
 * @param {string}  identifier The identifier to use for the style element.
 * @param {boolean} once       Only add the styles once for the identifier.
 *
 * @return {Element} The style element.
 */
const addStyles = (styles, identifier = 'mh-utils-custom-styles', once = false) => {
  identifier = `mh-utils-${identifier}`;

  // Check to see if the existing element exists.
  const existingStyles = document.getElementById(identifier);

  // If so, append our new styles to the existing element.
  if (existingStyles) {
    if (once) {
      return existingStyles;
    }

    existingStyles.innerHTML += styles;
    return existingStyles;
  }

  // Otherwise, create a new element and append it to the head.
  const style = document.createElement('style');
  style.id = identifier;
  style.innerHTML = styles;
  document.head.appendChild(style);

  return style;
};

/**
 * Do something when ajax requests are completed.
 *
 * @author bradp
 * @since 1.0.0
 *
 * @example <caption>Basic usage</caption>
 * onRequest((response) => {
 *  console.log(response);
 * }, 'managers/ajax/turns/activeturn.php');
 *
 * @example <caption>Basic usage, but skip the success check</caption>
 * onRequest((response) => {
 * console.log(response);
 * }, 'managers/ajax/turns/activeturn.php', true);
 *
 * @example <caption>Basic usage, running for all ajax requests</caption>
 * onRequest((response) => {
 * console.log(response);
 * });
 *
 * @param {Function} callback    The callback to call when an ajax request is completed.
 * @param {string}   url         The url to match. If not provided, all ajax requests will be matched.
 * @param {boolean}  skipSuccess Skip the success check.
 */
const onRequest = (callback, url = null, skipSuccess = false) => {
  const req = XMLHttpRequest.prototype.open;
  XMLHttpRequest.prototype.open = function () {
    this.addEventListener('load', function () {
      if (this.responseText) {
        let response = {};
        try {
          response = JSON.parse(this.responseText);
        } catch (e) {
          return;
        }

        if (response.success || skipSuccess) {
          if (! url) {
            callback(response);
            return;
          }

          if (this.responseURL.indexOf(url) !== -1) {
            callback(response);
          }
        }
      }
    });
    req.apply(this, arguments);
  };
};

const onAjaxRequest = onRequest;

/**
 * Run the callbacks depending on visibility.
 *
 * @author bradp
 * @since 1.0.0
 *
 * @ignore
 *
 * @param {Object} settings   Settings object.
 * @param {Node}   parentNode The parent node.
 * @param {Object} callbacks  The callbacks to run.
 *
 * @return {Object} The settings.
 */
const runCallbacks = (settings, parentNode, callbacks) => {
  // Loop through the keys on our settings object.
  Object.keys(settings).forEach((key) => {
    // If the parentNode that's passed in contains the selector for the key.
    if (parentNode && parentNode.classList && parentNode.classList.contains(settings[ key ].selector)) {
      // Set as visible.
      settings[ key ].isVisible = true;

      // If there is a show callback, run it.
      if (callbacks[ key ] && callbacks[ key ].show) {
        callbacks[ key ].show();
      }
    } else if (settings[ key ].isVisible) {
      // Mark as not visible.
      settings[ key ].isVisible = false;

      // If there is a hide callback, run it.
      if (callbacks[ key ] && callbacks[ key ].hide) {
        callbacks[ key ].hide();
      }
    }
  });

  return settings;
};

/**
 * Do something when the overlay is shown or hidden.
 *
 * @param {Object}   callbacks
 * @param {Function} callbacks.show   The callback to call when the overlay is shown.
 * @param {Function} callbacks.hide   The callback to call when the overlay is hidden.
 * @param {Function} callbacks.change The callback to call when the overlay is changed.
 */
const onOverlayChange = (callbacks) => {
  // Track the different overlay states.
  let overlayData = {
    map: {
      isVisible: false,
      selector: 'treasureMapPopup'
    },
    item: {
      isVisible: false,
      selector: 'itemViewPopup'
    },
    mouse: {
      isVisible: false,
      selector: 'mouseViewPopup'
    },
    image: {
      isVisible: false,
      selector: 'largerImage'
    },
    convertible: {
      isVisible: false,
      selector: 'convertibleOpenViewPopup'
    },
    adventureBook: {
      isVisible: false,
      selector: 'adventureBookPopup'
    },
    marketplace: {
      isVisible: false,
      selector: 'marketplaceViewPopup'
    },
    gifts: {
      isVisible: false,
      selector: 'giftSelectorViewPopup'
    },
    support: {
      isVisible: false,
      selector: 'supportPageContactUsForm'
    },
    premiumShop: {
      isVisible: false,
      selector: 'MHCheckout'
    }
  };

  // Observe the overlayPopup element for changes.
  const observer = new MutationObserver(() => {
    if (callbacks.change) {
      callbacks.change();
    }

    // Grab the overlayPopup element and make sure it has classes on it.
    const overlayType = document.getElementById('overlayPopup');
    if (overlayType && overlayType.classList.length <= 0) {
      return;
    }

    // Grab the overlayBg and check if it is visible or not.
    const overlayBg = document.getElementById('overlayBg');
    if (overlayBg && overlayBg.classList.length > 0) {
      // If there's a show callback, run it.
      if (callbacks.show) {
        callbacks.show();
      }
    } else if (callbacks.hide) {
      // If there's a hide callback, run it.
      callbacks.hide();
    }

    // Run all the specific callbacks.
    overlayData = runCallbacks(overlayData, overlayType, callbacks);
  });

  // Observe the overlayPopup element for changes.
  const observeTarget = document.getElementById('overlayPopup');
  if (observeTarget) {
    observer.observe(observeTarget, {
      attributes: true,
      attributeFilter: ['class']
    });
  }
};

/**
 * TODO: update this docblock.
 *
 * @param {*} callback
 */
const onOverlayClose = (callback) => {
  eventRegistry.addEventListener('js_dialog_hide', callback);
};

/**
 * TODO: update this docblock.
 */
const getDialogMapping = () => {
  return {
    treasureMapPopup: 'map',
    itemViewPopup: 'item',
    mouseViewPopup: 'mouse',
    largerImage: 'image',
    convertibleOpenViewPopup: 'convertible',
    adventureBookPopup: 'adventureBook',
    marketplaceViewPopup: 'marketplace',
    giftSelectorViewPopup: 'gifts',
    supportPageContactUsForm: 'support',
    MHCheckout: 'premiumShop',
  };
};

/**
 * TODO: update this docblock.
 *
 * @param {*} callback
 * @param {*} overlay
 * @param {*} once
 */
const onDialogShow = (callback, overlay = null, once = false) => {
  eventRegistry.addEventListener('js_dialog_show', () => {
    if (! activejsDialog) {
      return;
    }

    // Get all the tokens and check the content.
    const tokens = activejsDialog.getAllTokens();

    // Make sure we have the 'content' key.
    // For item and mouse views, the entire event fires twice, once while loading and
    // once when the content is loaded. We only want to run this once, so we check if
    // the content is empty in a weird way.
    if (
      ! tokens ||
      ! tokens[ '{*content*}' ] ||
      ! tokens[ '{*content*}' ].value ||
      tokens[ '{*content*}' ].value === '' ||
      tokens[ '{*content*}' ].value.indexOf('data-item-type=""') > -1 || // Item view.
      tokens[ '{*content*}' ].value.indexOf('data-mouse-id=""') > -1 // Mouse view.
    ) {
      return;
    }

    // Grab the attributes of the dialog to determine the type.
    const atts = activejsDialog.getAttributes();
    const dialogType = atts.className
      .replace('jsDialogFixed', '')
      .replace('wide', '')
      .replace('default', '')
      .replaceAll('  ', ' ')
      .replaceAll(' ', '.')
      .trim();

    // Make sure this only ran once within the last 100ms for the same overlay.
    if (window.mhutils?.lastDialog?.overlay === dialogType && (Date.now() - window.mhutils.lastDialog.timestamp) < 250) {
      return;
    }

    const lastDialog = {
      overlay: dialogType,
      timestamp: Date.now(),
    };

    window.mhutils = window.mhutils ? { ...window.mhutils, ...lastDialog } : lastDialog;

    if (! overlay && 'function' === typeof callback) {
      return callback();
    }

    const dialogMapping = getDialogMapping();

    if ('function' === typeof callback && (overlay === dialogType || overlay === dialogMapping[ dialogType ])) {
      return callback();
    }
  }, null, once);
};

/**
 * TODO: update this docblock.
 *
 * @param {*} callback
 * @param {*} overlay
 * @param {*} once
 */
const onDialogHide = (callback, overlay = null, once = false) => {
  eventRegistry.addEventListener('js_dialog_hide', () => {
    const dialogType = window?.mhutils?.lastDialog?.overlay || null;
    window.mhutils.lastDialog = {};

    if (! overlay) {
      return callback();
    }

    const dialogMapping = getDialogMapping();
    if (overlay === dialogType || overlay === dialogMapping[ dialogType ]) {
      return callback();
    }
  }, null, once);
};

/**
 * Do something when the page or tab changes.
 *
 * @param {Object}   callbacks
 * @param {Function} callbacks.show   The callback to call when the page is navigated to.
 * @param {Function} callbacks.hide   The callback to call when the page is navigated away from.
 * @param {Function} callbacks.change The callback to call when the page is changed.
 */
const onPageChange = (callbacks) => {
  // Track our page tab states.
  let tabData = {
    blueprint: { isVisible: null, selector: 'showBlueprint' },
    tem: { isVisible: false, selector: 'showTrapEffectiveness' },
    trap: { isVisible: false, selector: 'editTrap' },
    camp: { isVisible: false, selector: 'PageCamp' },
    travel: { isVisible: false, selector: 'PageTravel' },
    inventory: { isVisible: false, selector: 'PageInventory' },
    shop: { isVisible: false, selector: 'PageShops' },
    mice: { isVisible: false, selector: 'PageAdversaries' },
    friends: { isVisible: false, selector: 'PageFriends' },
    sendSupplies: { isVisible: false, selector: 'PageSupplyTransfer' },
    team: { isVisible: false, selector: 'PageTeam' },
    tournament: { isVisible: false, selector: 'PageTournament' },
    news: { isVisible: false, selector: 'PageNews' },
    scoreboards: { isVisible: false, selector: 'PageScoreboards' },
    discord: { isVisible: false, selector: 'PageJoinDiscord' },
    preferences: { isVisible: false, selector: 'PagePreferences' },
    profile: { isVisible: false, selector: 'HunterProfile' },
  };

  // Observe the mousehuntContainer element for changes.
  const observer = new MutationObserver(() => {
    // If there's a change callback, run it.
    if (callbacks.change) {
      callbacks.change();
    }

    // Grab the container element and make sure it has classes on it.
    const mhContainer = document.getElementById('mousehuntContainer');
    if (mhContainer && mhContainer.classList.length > 0) {
      // Run the callbacks.
      tabData = runCallbacks(tabData, mhContainer, callbacks);
    }
  });

  // Observe the mousehuntContainer element for changes.
  const observeTarget = document.getElementById('mousehuntContainer');
  if (observeTarget) {
    observer.observe(observeTarget, {
      attributes: true,
      attributeFilter: ['class']
    });
  }
};

/**
 * Do something when the trap tab is changed.
 *
 * @param {Object} callbacks
 */
const onTrapChange = (callbacks) => {
  // Track our trap states.
  let trapData = {
    bait: {
      isVisible: false,
      selector: 'bait'
    },
    base: {
      isVisible: false,
      selector: 'base'
    },
    weapon: {
      isVisible: false,
      selector: 'weapon'
    },
    charm: {
      isVisible: false,
      selector: 'trinket'
    },
    skin: {
      isVisible: false,
      selector: 'skin'
    }
  };

  // Observe the trapTabContainer element for changes.
  const observer = new MutationObserver(() => {
    // Fire the change callback.
    if (callbacks.change) {
      callbacks.change();
    }

    // If we're not viewing a blueprint tab, bail.
    const mhContainer = document.getElementById('mousehuntContainer');
    if (mhContainer.classList.length <= 0 || ! mhContainer.classList.contains('showBlueprint')) {
      return;
    }

    // If we don't have the container, bail.
    const trapContainerParent = document.querySelector('.campPage-trap-blueprintContainer');
    if (! trapContainerParent || ! trapContainerParent.children || ! trapContainerParent.children.length > 0) {
      return;
    }

    // If we're not in the itembrowser, bail.
    const trapContainer = trapContainerParent.children[ 0 ];
    if (! trapContainer || trapContainer.classList.length <= 0 || ! trapContainer.classList.contains('campPage-trap-itemBrowser')) {
      return;
    }

    // Run the callbacks.
    trapData = runCallbacks(trapData, trapContainer, callbacks);
  });

  // Grab the campPage-trap-blueprintContainer element and make sure it has children on it.
  const observeTargetParent = document.querySelector('.campPage-trap-blueprintContainer');
  if (! observeTargetParent || ! observeTargetParent.children || ! observeTargetParent.children.length > 0) {
    return;
  }

  // Observe the first child of the campPage-trap-blueprintContainer element for changes.
  const observeTarget = observeTargetParent.children[ 0 ];
  if (observeTarget) {
    observer.observe(observeTarget, {
      attributes: true,
      attributeFilter: ['class']
    });
  }
};

/**
 * Add something to the event registry.
 *
 * @param {string}   event    The event name.
 * @param {Function} callback The callback to run when the event is fired.
 * @param {boolean}  remove   Whether or not to remove the event listener after it's fired.
 */
const onEvent = (event, callback, remove = false) => {
  eventRegistry.addEventListener(event, callback, null, remove);
};

/**
 * Do something when the user travels to a location.
 *
 * @param {string} location                  The location traveled to.
 * @param {Object} options                   The options
 * @param {string} options.shouldAddReminder Whether or not to add a reminder.
 * @param {string} options.title             The title of the reminder.
 * @param {string} options.text              The text of the reminder.
 * @param {string} options.button            The button text of the reminder.
 * @param {string} options.action            The action to take when the button is clicked.
 * @param {string} options.callback          The callback to run when the user is at the location.
 */
const onTravel = (location, options) => {
  eventRegistry.addEventListener('travel_complete', () => onTravelCallback(location, options));
};

/**
 * Do something when the user travels to a location.
 * This is a callback for the onTravel function.
 *
 * @param {string} location                  The location traveled to.
 * @param {Object} options                   The options
 * @param {string} options.shouldAddReminder Whether or not to add a reminder.
 * @param {string} options.title             The title of the reminder.
 * @param {string} options.text              The text of the reminder.
 * @param {string} options.button            The button text of the reminder.
 * @param {string} options.action            The action to take when the button is clicked.
 * @param {string} options.callback          The callback to run when the user is at the location.
 *
 */
const onTravelCallback = (location, options) => {
  if (location && location !== getCurrentLocation()) {
    return;
  }

  if (options?.shouldAddReminder) {
    showHornMessage({
      title: options.title || '',
      text: options.text || '',
      button: options.button || 'Dismiss',
      action: options.action || null,
    });
  }

  if (options.callback) {
    options.callback();
  }
};

/**
 * TODO: update this docblock.
 *
 * @param {string} targetPage         The target page.
 * @param {string} targetTab          The target tab.
 * @param {string} targetSubtab       The target subtab.
 * @param {string} forceCurrentPage   The current page.
 * @param {string} forceCurrentTab    The current tab.
 * @param {string} forceCurrentSubtab The current subtab.
 */
const matchesCurrentPage = (targetPage = null, targetTab = null, targetSubtab = null, forceCurrentPage = null, forceCurrentTab = null, forceCurrentSubtab = null) => {
  if (! targetPage) {
    return false;
  }

  // Only targetPage is being checked.
  const currentPage = forceCurrentPage || getCurrentPage();
  if (! targetTab) {
    return currentPage === targetPage;
  }

  // Only targetTab is being checked.
  const currentTab = forceCurrentTab || getCurrentTab();
  if (! targetSubtab) {
    return currentPage === targetPage && currentTab === targetTab;
  }

  // Only targetSubtab is being checked.
  const currentSubtab = forceCurrentSubtab || getCurrentSubtab();
  if (currentSubtab === currentTab) {
    return currentPage === targetPage && currentTab === targetTab;
  }

  return currentPage === targetPage && currentTab === targetTab && currentSubtab === targetSubtab;
};

/*
  onNavigation(() => console.log('mouse stats by location'),
    {
      page: 'adversaries',
      tab: 'your_stats',
      subtab: 'location'
    }
  );

  onNavigation(() => console.log('friend request page'),
    {
      page:'friends',
      tab: 'requests'
    }
  );

  onNavigation(() => console.log('hunter profile, but not when refreshing the page'),
    {
      page: 'hunterprofile',
      onLoad: true
    }
  );
  */

/**
 * TODO: update this docblock
 *
 * @param {Function} callback       The callback to run when the user navigates to the page.
 * @param {Object}   options        The options
 * @param {string}   options.page   The page to watch for.
 * @param {string}   options.tab    The tab to watch for.
 * @param {string}   options.subtab The subtab to watch for.
 * @param {boolean}  options.onLoad Whether or not to run the callback on load.
 */
const onNavigation = (callback, options = {}) => {
  const defaults = {
    page: false,
    tab: false,
    subtab: false,
    onLoad: false,
  };

  // merge the defaults with the options
  const { page, tab, subtab, onLoad } = Object.assign(defaults, options);

  // If we don't pass in a page, then we want to run the callback on every page.
  let bypassMatch = false;
  if (! page) {
    bypassMatch = true;
  }

  // We do this once on load in case we are starting on the page we want to watch for.
  if (onLoad) {
    if (bypassMatch || matchesCurrentPage(page, tab, subtab)) {
      callback();
    }
  }

  eventRegistry.addEventListener('set_page', (e) => {
    const tabs = e?.data?.tabs || {};

    const currentTab = Object.keys(tabs).find((key) => tabs[ key ].is_active_tab);
    const forceCurrentTab = currentTab?.type;

    if (! subtab) {
      if (matchesCurrentPage(page, tab, false, getCurrentPage(), forceCurrentTab)) {
        callback();
      }

      return;
    }

    if (currentTab?.subtabs && currentTab?.subtabs.length > 0) {
      const forceSubtab = currentTab.subtabs.find((searchTab) => searchTab.is_active_subtab).subtab_type;

      if (matchesCurrentPage(page, tab, subtab, getCurrentPage(), forceCurrentTab, forceSubtab)) {
        callback();
      }
    }
  });

  eventRegistry.addEventListener('set_tab', (e) => {
    const forceCurrentTab = e.page_arguments.tab;
    const forceCurrentSubtab = e.page_arguments.sub_tab;

    if (matchesCurrentPage(page, tab, subtab, getCurrentPage(), forceCurrentTab, forceCurrentSubtab)) {
      callback();
    }
  });
};

const onNavigate = onNavigation;

/**
 * Get the current page slug.
 *
 * @return {string} The page slug.
 */
const getCurrentPage = () => {
  return hg.utils.PageUtil.getCurrentPage().toLowerCase(); // eslint-disable-line no-undef
};

/**
 * Get the current page tab, defaulting to the current page if no tab is found.
 *
 * @return {string} The page tab.
 */
const getCurrentTab = () => {
  const tab = hg.utils.PageUtil.getCurrentPageTab().toLowerCase(); // eslint-disable-line no-undef
  if (tab.length <= 0) {
    return getCurrentPage();
  }

  return tab;
};

/**
 * Get the current page sub tab, defaulting to the current tab if no sub tab is found.
 *
 * @return {string} The page tab.
 */
const getCurrentSubtab = () => {
  const subtab = hg.utils.PageUtil.getCurrentPageSubTab();
  if (! subtab || subtab.length <= 0) {
    return getCurrentTab();
  }

  return subtab.toLowerCase();
};

// Backwards compatibility.
const getCurrentSubTab = getCurrentSubtab;

/**
 * Check if the overlay is visible.
 *
 * @return {boolean} True if the overlay is visible, false otherwise.
 */
const isOverlayVisible = () => {
  return activejsDialog && activejsDialog.isVisible();
};

/**
 * Get the current overlay.
 *
 * @return {string} The current overlay.
 */
const getCurrentOverlay = () => {
  const overlay = document.getElementById('overlayPopup');
  if (overlay && overlay.classList.length <= 0) {
    return null;
  }

  let overlayType = overlay.classList.value;
  overlayType = overlayType.replace('jsDialogFixed', '');
  overlayType = overlayType.replace('default', '');
  overlayType = overlayType.replace('wide', '');
  overlayType = overlayType.replace('ajax', '');
  overlayType = overlayType.replace('overlay', '');

  // Replace some overlay types with more readable names.
  overlayType = overlayType.replace('treasureMapPopup', 'map');
  overlayType = overlayType.replace('itemViewPopup', 'item');
  overlayType = overlayType.replace('mouseViewPopup', 'mouse');
  overlayType = overlayType.replace('largerImage', 'image');
  overlayType = overlayType.replace('convertibleOpenViewPopup', 'convertible');
  overlayType = overlayType.replace('adventureBookPopup', 'adventureBook');
  overlayType = overlayType.replace('marketplaceViewPopup', 'marketplace');
  overlayType = overlayType.replace('giftSelectorViewPopup', 'gifts');
  overlayType = overlayType.replace('supportPageContactUsForm', 'support');
  overlayType = overlayType.replace('MHCheckout', 'premiumShop');

  return overlayType.trim();
};

/**
 * Get the current location.
 *
 * @return {string} The current location.
 */
const getCurrentLocation = () => {
  const location = user?.environment_type || '';
  return location.toLowerCase();
};

/**
 * Check if the user is logged in.
 *
 * @return {boolean} True if the user is logged in, false otherwise.
 */
const isLoggedIn = () => {
  return user.length > 0 && 'login' !== getCurrentPage();
};

/**
 * Get the saved settings.
 *
 * @param {string}  key          The key to get.
 * @param {boolean} defaultValue The default value.
 * @param {string}  identifier   The identifier for the settings.
 *
 * @return {Object} The saved settings.
 */
const getSetting = (key = null, defaultValue = null, identifier = 'mh-utils-settings') => {
  // Grab the local storage data.
  const settings = JSON.parse(localStorage.getItem(identifier)) || {};

  // If we didn't get a key passed in, we want all the settings.
  if (! key) {
    return settings;
  }

  // If the setting doesn't exist, return the default value.
  if (Object.prototype.hasOwnProperty.call(settings, key)) {
    return settings[ key ];
  }

  return defaultValue;
};

/**
 * Save a setting.
 *
 * @param {string}  key        The setting key.
 * @param {boolean} value      The setting value.
 * @param {string}  identifier The identifier for the settings.
 */
const saveSetting = (key, value, identifier = 'mh-utils-settings') => {
  // Grab all the settings, set the new one, and save them.
  const settings = getSetting(null, {}, identifier);
  settings[ key ] = value;

  localStorage.setItem(identifier, JSON.stringify(settings));
};

/**
 * Save a setting and toggle the class in the settings UI.
 *
 * @ignore
 *
 * @param {Node}    node  The setting node to animate.
 * @param {string}  key   The setting key.
 * @param {boolean} value The setting value.
 */
const saveSettingAndToggleClass = (node, key, value, identifier = 'mh-utils-settings') => {
  node.parentNode.parentNode.classList.add('busy');

  // Toggle the state of the checkbox.
  node.classList.toggle('active');

  // Save the setting.
  saveSetting(key, value, identifier);

  // Add the completed class & remove it in a second.
  node.parentNode.parentNode.classList.remove('busy');
  node.parentNode.parentNode.classList.add('completed');
  setTimeout(() => {
    node.parentNode.parentNode.classList.remove('completed');
  }, 1000);

  addSettingRefreshReminder();
};

/**
 * Make the settings tab.
 *
 * @param {string} identifier The identifier for the settings.
 * @param {string} name       The name of the settings tab.
 */
const addSettingsTab = (identifier = 'userscript-settings', name = 'Userscript Settings') => {
  addSettingsTabOnce(identifier, name);
  onPageChange({ preferences: { show: () => addSettingsTabOnce(identifier, name) } });

  return identifier;
};

/**
 * Make the settings tab once.
 *
 * @ignore
 *
 * @param {string} identifier The identifier for the settings.
 * @param {string} name       The name of the settings tab.
 */
const addSettingsTabOnce = (identifier = 'userscript-settings', name = 'Userscript Settings') => {
  if ('preferences' !== getCurrentPage()) {
    return;
  }

  const existingSettings = document.querySelector(`#${identifier}`);
  if (existingSettings) {
    return;
  }

  const tabsContainer = document.querySelector('.mousehuntHud-page-tabHeader-container');
  if (! tabsContainer) {
    return;
  }

  const tabsContentContainer = document.querySelector('.mousehuntHud-page-tabContentContainer');
  if (! tabsContentContainer) {
    return;
  }

  // make sure the identifier is unique and safe to use as a class.
  identifier = identifier.replace(/[^a-z0-9-_]/gi, '');

  const settingsTab = document.createElement('a');
  settingsTab.id = identifier;
  settingsTab.href = '#';
  settingsTab.classList.add('mousehuntHud-page-tabHeader', identifier);
  settingsTab.setAttribute('data-tab', identifier);
  settingsTab.setAttribute('onclick', 'hg.utils.PageUtil.onclickPageTabHandler(this); return false;');

  const settingsTabText = document.createElement('span');
  settingsTabText.innerText = name;

  settingsTab.appendChild(settingsTabText);
  tabsContainer.appendChild(settingsTab);

  const settingsTabContent = document.createElement('div');
  settingsTabContent.classList.add('mousehuntHud-page-tabContent', 'game_settings', identifier);
  settingsTabContent.setAttribute('data-tab', identifier);

  tabsContentContainer.appendChild(settingsTabContent);

  if (identifier === getCurrentTab()) {
    const tab = document.getElementById(identifier);
    if (tab) {
      tab.click();
    }
  }
};

/**
 * Add a setting to the preferences page, both on page load and when the page changes.
 *
 * @param {string}  name         The setting name.
 * @param {string}  key          The setting key.
 * @param {boolean} defaultValue The default value.
 * @param {string}  description  The setting description.
 * @param {Object}  section      The section settings.
 * @param {string}  tab          The tab to add the settings to.
 * @param {Object}  settings     The settings for the settings.
 */
const addSetting = (name, key, defaultValue = true, description = '', section = {}, tab = 'userscript-settings', settings = null) => {
  onPageChange({ preferences: { show: () => addSettingOnce(name, key, defaultValue, description, section, tab, settings) } });
  addSettingOnce(name, key, defaultValue, description, section, tab, settings);

  addSettingRefreshReminder();
  onPageChange({ preferences: { show: addSettingRefreshReminder } });
};

/**
 * Add a setting to the preferences page.
 *
 * @ignore
 *
 * @param {string}  name            The setting name.
 * @param {string}  key             The setting key.
 * @param {boolean} defaultValue    The default value.
 * @param {string}  description     The setting description.
 * @param {Object}  section         The section settings.
 * @param {string}  tab             The tab to add the settings to.
 * @param {Object}  settingSettings The settings for the settings.
 */
const addSettingOnce = (name, key, defaultValue = true, description = '', section = {}, tab = 'userscript-settings', settingSettings = null) => {
  // Make sure we have the container for our settings.
  const container = document.querySelector(`.mousehuntHud-page-tabContent.${tab}`);
  if (! container) {
    return;
  }

  section = {
    id: section.id || 'settings',
    name: section.name || 'Userscript Settings',
    description: section.description || '',
  };

  let tabId = 'mh-utils-settings';
  if (tab !== 'userscript-settings') {
    tabId = tab;
  }

  section.id = `${tabId}-${section.id.replace(/[^a-z0-9-_]/gi, '')}`;

  // If we don't have our custom settings section, then create it.
  let sectionExists = document.querySelector(`#${section.id}`);
  if (! sectionExists) {
    // Make the element, add the ID and class.
    const title = document.createElement('div');
    title.id = section.id;
    title.classList.add('PagePreferences__title');

    // Set the title of our section.
    const titleText = document.createElement('h3');
    titleText.classList.add('PagePreferences__titleText');
    titleText.textContent = section.name;

    // Append the title.
    title.appendChild(titleText);

    // Add a separator.
    const seperator = document.createElement('div');
    seperator.classList.add('PagePreferences__separator');

    // Append the separator.
    title.appendChild(seperator);

    // Append it.
    container.appendChild(title);

    sectionExists = document.querySelector(`#${section.id}`);

    if (section.description) {
      const settingSubHeader = makeElement('h4', ['settings-subheader', 'mh-utils-settings-subheader'], section.description);
      sectionExists.insertBefore(settingSubHeader, seperator);

      addStyles(`.mh-utils-settings-subheader {
        padding-top: 10px;
        padding-bottom: 10px;
        font-size: 10px;
        color: #848484;
      }`, 'mh-utils-settings-subheader', true);
    }
  }

  // If we already have a setting visible for our key, bail.
  const settingExists = document.getElementById(`${section.id}-${key}`);
  if (settingExists) {
    return;
  }

  // Create the markup for the setting row.
  const settings = document.createElement('div');
  settings.classList.add('PagePreferences__settingsList');
  settings.id = `${section.id}-${key}`;

  const settingRow = document.createElement('div');
  settingRow.classList.add('PagePreferences__setting');

  const settingRowLabel = document.createElement('div');
  settingRowLabel.classList.add('PagePreferences__settingLabel');

  const settingName = document.createElement('div');
  settingName.classList.add('PagePreferences__settingName');
  settingName.innerHTML = name;

  const defaultSettingText = document.createElement('div');
  defaultSettingText.classList.add('PagePreferences__settingDefault');

  if (settingSettings && (settingSettings.type === 'select' || settingSettings.type === 'multi-select')) {
    addStyles(`.PagePreferences .mousehuntHud-page-tabContent.game_settings.userscript-settings .settingRow .settingRow-action-inputContainer.select.busy:before,
    .PagePreferences .mousehuntHud-page-tabContent.game_settings.userscript-settings .settingRow .settingRow-action-inputContainer.select.completed:before,
    .PagePreferences .mousehuntHud-page-tabContent.game_settings.better-mh-settings .settingRow .settingRow-action-inputContainer.select.busy:before,
    .PagePreferences .mousehuntHud-page-tabContent.game_settings.better-mh-settings .settingRow .settingRow-action-inputContainer.select.completed:before {
      left: unset;
      right: -25px;
      top: 30px;
    }

    .PagePreferences .mousehuntHud-page-tabContent.game_settings .settingRow .name {
      height: unset;
      min-height: 20px;
    }

    .PagePreferences__settingAction.inputDropdownWrapper.busy:before,
    .PagePreferences__settingAction.inputDropdownWrapper.completed:before {
      left: unset;
      right: -40px;
    }

    .inputBoxContainer.multiSelect {
      max-width: 400px;
    }`, 'mh-utils-settings-select', true);

    defaultSettingText.textContent = defaultValue.map((item) => item.name).join(', ');
  } else {
    defaultSettingText.textContent = defaultValue ? 'Enabled' : 'Disabled';
  }

  defaultSettingText.textContent = `Default setting: ${defaultSettingText.textContent}`;

  const settingDescription = document.createElement('div');
  settingDescription.classList.add('PagePreferences__settingDescription');
  settingDescription.innerHTML = description;

  settingRowLabel.appendChild(settingName);
  settingRowLabel.appendChild(defaultSettingText);
  settingRowLabel.appendChild(settingDescription);

  const settingRowAction = document.createElement('div');
  settingRowAction.classList.add('PagePreferences__settingAction');

  const settingRowInput = document.createElement('div');
  settingRowInput.classList.add('settingRow-action-inputContainer');

  if (settingSettings && (settingSettings.type === 'select' || settingSettings.type === 'multi-select')) {
    // Create the dropdown.
    const settingRowInputDropdown = document.createElement('div');
    settingRowInputDropdown.classList.add('inputBoxContainer');

    if (settingSettings.type === 'multi-select') {
      settingRowInputDropdown.classList.add('multiSelect');
      settingRowInput.classList.add('multiSelect', 'select');
    }

    const amount = settingSettings.type === 'multi-select' ? settingSettings.number : 1;

    // make a multi-select dropdown.
    for (let i = 0; i < amount; i++) {
      const settingRowInputDropdownSelect = document.createElement('select');
      settingRowInputDropdownSelect.classList.add('inputBox');

      if (settingSettings.type === 'multi-select') {
        settingRowInputDropdownSelect.classList.add('multiSelect');
      }

      const currentSetting = getSetting(`${key}-${i}`, null, tab);
      let foundSelected = false;

      settingSettings.options.forEach((option) => {
        const settingRowInputDropdownSelectOption = document.createElement('option');
        settingRowInputDropdownSelectOption.value = option.value;
        settingRowInputDropdownSelectOption.textContent = option.name;

        if (currentSetting && currentSetting === option.value) {
          settingRowInputDropdownSelectOption.selected = true;
          foundSelected = true;
        } else if (! foundSelected && defaultValue && defaultValue[ i ] && defaultValue[ i ].value === option.value) {
          settingRowInputDropdownSelectOption.selected = true;
          foundSelected = true;
        }

        settingRowInputDropdownSelect.appendChild(settingRowInputDropdownSelectOption);
      });

      settingRowInputDropdown.appendChild(settingRowInputDropdownSelect);

      // Event listener for when the setting is clicked.
      settingRowInputDropdownSelect.onchange = (event) => {
        const parent = settingRowInputDropdownSelect.parentNode.parentNode.parentNode;
        parent.classList.add('inputDropdownWrapper');
        parent.classList.add('busy');

        // save the setting.
        saveSetting(`${key}-${i}`, event.target.value, tab);

        parent.classList.remove('busy');
        parent.classList.add('completed');
        setTimeout(() => {
          parent.classList.remove('completed');
        }, 1000);
      };

      settingRowInput.appendChild(settingRowInputDropdown);
      settingRowAction.appendChild(settingRowInput);
    }
  } else {
    const settingRowInputCheckbox = document.createElement('div');
    settingRowInputCheckbox.classList.add('mousehuntSettingSlider');

    // Depending on the current state of the setting, add the active class.
    const currentSetting = getSetting(key, null, tab);
    let isActive = false;
    if (currentSetting) {
      settingRowInputCheckbox.classList.add('active');
      isActive = true;
    } else if (null === currentSetting && defaultValue) {
      settingRowInputCheckbox.classList.add('active');
      isActive = true;
    }

    // Event listener for when the setting is clicked.
    settingRowInputCheckbox.onclick = (event) => {
      saveSettingAndToggleClass(event.target, key, ! isActive, tab);
    };

    // Add the input to the settings row.
    settingRowInput.appendChild(settingRowInputCheckbox);
    settingRowAction.appendChild(settingRowInput);
  }

  // Add the label and action to the settings row.
  settingRow.appendChild(settingRowLabel);
  settingRow.appendChild(settingRowAction);

  // Add the settings row to the settings container.
  settings.appendChild(settingRow);
  sectionExists.appendChild(settings);
};

/**
 * Add a refresh reminder to the settings page.
 *
 * @ignore
 */
const addSettingRefreshReminder = () => {
  const existing = document.querySelector('.mh-utils-settings-refresh-message');
  if (existing) {
    return;
  }

  addStyles(`.mh-utils-settings-refresh-message {
    position: fixed;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 5;
    padding: 1em;
    font-size: 1.5em;
    text-align: center;
    background-color: #d6f2d6;
    border-top: 1px solid #6cc36c;
    opacity: 1;
    transition: opacity 0.5s ease-in-out;
    pointer-events: none;
  }

  .mh-utils-settings-refresh-message-hidden {
    opacity: 0;
  }`, 'mh-utils-settings-refresh-message', true);

  const settingsToggles = document.querySelectorAll('.mousehuntSettingSlider');
  if (! settingsToggles) {
    return;
  }

  settingsToggles.forEach((toggle) => {
    if (toggle.getAttribute('data-has-refresh-reminder')) {
      return;
    }

    toggle.setAttribute('data-has-refresh-reminder', true);

    toggle.addEventListener('click', () => {
      const refreshMessage = document.querySelector('.mh-utils-settings-refresh-message');
      if (refreshMessage) {
        refreshMessage.classList.remove('mh-utils-settings-refresh-message-hidden');
      }

      setTimeout(() => {
        if (refreshMessage) {
          refreshMessage.classList.add('mh-utils-settings-refresh-message-hidden');
        }
      }, 5000);
    });
  });

  const existingRefreshMessage = document.querySelector('.mh-utils-settings-refresh-message');
  if (! existingRefreshMessage) {
    const body = document.querySelector('body');
    if (body) {
      makeElement('div', ['mh-utils-settings-refresh-message', 'mh-utils-settings-refresh-message-hidden'], 'Refresh the page to apply your changes.', body);
    }
  }
};

/**
 * POST a request to the server and return the response.
 *
 * @async
 * @param {string} url      The url to post to, not including the base url.
 * @param {Object} formData The form data to post.
 *
 * @return {Promise} The response.
 */
const doRequest = async (url, formData = {}) => {
  // If we don't have the needed params, bail.
  if ('undefined' === typeof lastReadJournalEntryId || 'undefined' === typeof user) {
    return;
  }

  // If our needed params are empty, bail.
  if (! lastReadJournalEntryId || ! user || ! user.unique_hash) { // eslint-disable-line no-undef
    return;
  }

  // Build the form for the request.
  const form = new FormData();
  form.append('sn', 'Hitgrab');
  form.append('hg_is_ajax', 1);
  form.append('last_read_journal_entry_id', lastReadJournalEntryId ? lastReadJournalEntryId : 0); // eslint-disable-line no-undef
  form.append('uh', user.unique_hash ? user.unique_hash : ''); // eslint-disable-line no-undef

  // Add in the passed in form data.
  for (const key in formData) {
    form.append(key, formData[ key ]);
  }

  // Convert the form to a URL encoded string for the body.
  const requestBody = new URLSearchParams(form).toString();

  // Send the request.
  const response = await fetch(
    callbackurl ? callbackurl + url : 'https://www.mousehuntgame.com/' + url, // eslint-disable-line no-undef
    {
      method: 'POST',
      body: requestBody,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
    }
  );

  // Wait for the response and return it.
  const data = await response.json();
  return data;
};

/**
 * Check if the legacy HUD is enabled.
 *
 * @return {boolean} Whether the legacy HUD is enabled.
 */
const isLegacyHUD = () => {
  return hg.utils.PageUtil.isLegacy();
};

/**
 * Check if an item is in the inventory.
 *
 * @async
 *
 * @param {string} item The item to check for.
 *
 * @return {boolean} Whether the item is in the inventory.
 */
const userHasItem = async (item) => {
  const hasItem = await getUserItems([item]);
  return hasItem.length > 0;
};

/**
 * Check if an item is in the inventory.
 *
 * @async
 *
 * @param {Array} items The item to check for.
 *
 * @return {Array} The item data.
 */
const getUserItems = async (items) => {
  return new Promise((resolve) => {
    hg.utils.UserInventory.getItems(items, (resp) => {
      resolve(resp);
    });
  });
};

/**
 * Get the user's setup details.
 *
 * @return {Object} The user's setup details.
 */
const getUserSetupDetails = () => {
  const userObj = user; // eslint-disable-line no-undef
  const setup = {
    type: userObj.trap_power_type_name,
    stats: {
      power: userObj.trap_power,
      powerBonus: userObj.trap_power_bonus,
      luck: userObj.trap_luck,
      attractionBonus: userObj.trap_attraction_bonus,
      cheeseEfect: userObj.trap_cheese_effect,
    },
    bait: {
      id: parseInt(userObj.bait_item_id),
      name: userObj.bait_name,
      quantity: parseInt(userObj.bait_quantity),
      power: 0,
      powerBonus: 0,
      luck: 0,
      attractionBonus: 0,
    },
    base: {
      id: parseInt(userObj.base_item_id),
      name: userObj.base_name,
      power: 0,
      powerBonus: 0,
      luck: 0,
      attractionBonus: 0,
    },
    charm: {
      id: parseInt(userObj.trinket_item_id),
      name: userObj.trinket_name,
      quantity: parseInt(userObj.trinket_quantity),
      power: 0,
      powerBonus: 0,
      luck: 0,
      attractionBonus: 0,
    },
    weapon: {
      id: parseInt(userObj.weapon_item_id),
      name: userObj.weapon_name,
      power: 0,
      powerBonus: 0,
      luck: 0,
      attractionBonus: 0,
    },
    aura: {
      lgs: {
        active: false,
        power: 0,
        powerBonus: 0,
        luck: 0,
      },
      lightning: {
        active: false,
        power: 0,
        powerBonus: 0,
        luck: 0,
      },
      chrome: {
        active: false,
        power: 0,
        powerBonus: 0,
        luck: 0,
      },
      slayer: {
        active: false,
        power: 0,
        powerBonus: 0,
        luck: 0,
      },
      festive: {
        active: false,
        power: 0,
        powerBonus: 0,
        luck: 0,
      },
      luckycodex: {
        active: false,
        power: 0,
        powerBonus: 0,
        luck: 0,
      },
      riftstalker: {
        active: false,
        power: 0,
        powerBonus: 0,
        luck: 0,
      },
    },
    location: {
      name: userObj.environment_name,
      id: userObj.environment_id,
      slug: userObj.environment_type,
    },
  };

  if ('camp' !== getCurrentPage()) {
    return setup;
  }

  const calculations = document.querySelectorAll('.campPage-trap-trapStat');
  if (! calculations) {
    return setup;
  }

  calculations.forEach((calculation) => {
    if (calculation.classList.length <= 1) {
      return;
    }

    const type = calculation.classList[ 1 ];
    const math = calculation.querySelectorAll('.math .campPage-trap-trapStat-mathRow');
    if (! math) {
      return;
    }

    math.forEach((row) => {
      if (row.classList.contains('label')) {
        return;
      }

      let value = row.querySelector('.campPage-trap-trapStat-mathRow-value');
      let name = row.querySelector('.campPage-trap-trapStat-mathRow-name');

      if (! value || ! name || ! name.innerText) {
        return;
      }

      name = name.innerText;
      value = value.innerText || '0';

      let tempType = type;
      let isBonus = false;
      if (value.includes('%')) {
        tempType = type + 'Bonus';
        isBonus = true;
      }

      // Because attraction_bonus is silly.
      tempType = tempType.replace('_bonusBonus', 'Bonus');

      value = value.replace('%', '');
      value = value.replace(',', '');
      value = parseInt(value * 100) / 100;

      if (tempType === 'attractionBonus') {
        value = value / 100;
      }

      // Check if the name matches either setup.weapon.name, setup.base.name, setup.charm.name, setup.bait.name and if so, update the setup object with the value
      if (setup.weapon.name === name) {
        setup.weapon[ tempType ] = value;
      } else if (setup.base.name === name) {
        setup.base[ tempType ] = value;
      } else if (setup.charm.name === name) {
        setup.charm[ tempType ] = value;
      } else if (setup.bait.name === name) {
        setup.bait[ tempType ] = value;
      } else if ('Your trap has no cheese effect bonus.' === name) {
        setup.cheeseEffect = 'No Effect';
      } else {
        let auraType = name.replace(' Aura', '');
        if (! auraType) {
          return;
        }

        auraType = auraType.toLowerCase();
        auraType = auraType.replaceAll(' ', '_');
        // remove any non alphanumeric characters
        auraType = auraType.replace(/[^a-z0-9_]/gi, '');
        auraType = auraType.replace('golden_luck_boost', 'lgs');
        auraType = auraType.replace('2023_lucky_codex', 'luckycodex');
        auraType = auraType.replace('_set_bonus_2_pieces', '');
        auraType = auraType.replace('_set_bonus_3_pieces', '');

        if (! setup.aura[ auraType ]) {
          setup.aura[ auraType ] = {
            active: true,
            type: auraType,
            power: 0,
            powerBonus: 0,
            luck: 0,
          };
        } else {
          setup.aura[ auraType ].active = true;
          setup.aura[ auraType ].type = auraType;
        }

        value = parseInt(value);

        if (isBonus) {
          value = value / 100;
        }

        setup.aura[ auraType ][ tempType ] = value;
      }
    });
  });

  return setup;
};

/**
 *  Add a submenu item to a menu.
 *
 * @param {Object}   options          The options for the submenu item.
 * @param {string}   options.menu     The menu to add the submenu item to.
 * @param {string}   options.label    The label for the submenu item.
 * @param {string}   options.icon     The icon for the submenu item.
 * @param {string}   options.href     The href for the submenu item.
 * @param {string}   options.class    The class for the submenu item.
 * @param {Function} options.callback The callback for the submenu item.
 * @param {boolean}  options.external Whether the submenu item is external or not.
 */
const addSubmenuItem = (options) => {
  // Default to sensible values.
  const settings = Object.assign({}, {
    menu: 'kingdom',
    label: '',
    icon: 'https://www.mousehuntgame.com/images/ui/hud/menu/special.png',
    href: '',
    class: '',
    callback: null,
    external: false,
  }, options);

  // Grab the menu item we want to add the submenu to.
  const menuTarget = document.querySelector(`.mousehuntHud-menu .${settings.menu}`);
  if (! menuTarget) {
    return;
  }

  // If the menu already has a submenu, just add the item to it.
  if (! menuTarget.classList.contains('hasChildren')) {
    menuTarget.classList.add('hasChildren');
  }

  let hasSubmenu = true;
  let submenu = menuTarget.querySelector('ul');
  if (! submenu) {
    hasSubmenu = false;
    submenu = document.createElement('ul');
  }

  // Create the item.
  const item = document.createElement('li');
  item.classList.add('custom-submenu-item');
  const cleanLabel = settings.label.toLowerCase().replace(/[^a-z0-9]/g, '-');

  const exists = document.querySelector(`#custom-submenu-item-${cleanLabel}`);
  if (exists) {
    return;
  }

  item.id = `custom-submenu-item-${cleanLabel}`;
  if (settings.class) {
    item.classList.add(settings.class);
  }

  // Create the link.
  const link = document.createElement('a');
  link.href = settings.href || '#';

  if (settings.callback) {
    link.addEventListener('click', (e) => {
      e.preventDefault();
      settings.callback();
    });
  }

  // Create the icon.
  const icon = document.createElement('div');
  icon.classList.add('icon');
  icon.style = `background-image: url(${settings.icon});`;

  // Create the label.
  const name = document.createElement('div');
  name.classList.add('name');
  name.innerText = settings.label;

  // Add the icon and label to the link.
  link.appendChild(icon);
  link.appendChild(name);

  // If it's an external link, also add the icon for it.
  if (settings.external) {
    const externalLinkIcon = document.createElement('div');
    externalLinkIcon.classList.add('external_icon');
    link.appendChild(externalLinkIcon);

    // Set the target to _blank so it opens in a new tab.
    link.target = '_blank';
    link.rel = 'noopener noreferrer';
  }

  // Add the link to the item.
  item.appendChild(link);

  // Add the item to the submenu.
  submenu.appendChild(item);

  if (! hasSubmenu) {
    menuTarget.appendChild(submenu);
  }
};

/**
 * Add the mouse.rip link to the kingdom menu.
 *
 * @ignore
 */
const addMouseripLink = () => {
  addSubmenuItem({
    menu: 'kingdom',
    label: 'mouse.rip',
    icon: 'https://www.mousehuntgame.com/images/ui/hud/menu/prize_shoppe.png',
    href: 'https://mouse.rip',
    external: true,
  });
};

/**
 * Add an item to the top 'Hunters Online' menu.
 *
 * @param {Object}   options          The options for the menu item.
 * @param {string}   options.label    The label for the menu item.
 * @param {string}   options.href     The href for the menu item.
 * @param {string}   options.class    The class for the menu item.
 * @param {Function} options.callback The callback for the menu item.
 * @param {boolean}  options.external Whether the link is external or not.
 */
const addItemToGameInfoBar = (options) => {
  const settings = Object.assign({}, {
    label: '',
    href: '',
    class: '',
    callback: null,
    external: false,
  }, options);

  const safeLabel = settings.label.replace(/[^a-z0-9]/gi, '_').toLowerCase();
  const exists = document.querySelector(`#mh-custom-topmenu-${safeLabel}`);
  if (exists) {
    return;
  }

  addStyles(`.mousehuntHud-gameInfo .mousehuntHud-menu {
    position: relative;
    top: unset;
    left: unset;
    display: inline;
    width: unset;
    height: unset;
    padding-top: unset;
    padding-left: unset;
    background: unset;
  }
  `, 'mh-custom-topmenu', true);

  const menu = document.querySelector('.mousehuntHud-gameInfo');
  if (! menu) {
    return;
  }

  const item = document.createElement('a');
  item.id = `mh-custom-topmenu-${safeLabel}`;
  item.classList.add('mousehuntHud-gameInfo-item');
  item.classList.add('mousehuntHud-custom-menu-item');

  item.href = settings.href || '#';

  const name = document.createElement('div');
  name.classList.add('name');

  if (settings.label) {
    name.innerText = settings.label;
  }

  item.appendChild(name);

  if (settings.class) {
    item.classList.add(settings.class);
  }

  if (settings.href) {
    item.href = settings.href;
  }

  if (settings.callback) {
    item.addEventListener('click', settings.callback);
  }

  if (settings.external) {
    const externalLinkIconWrapper = document.createElement('div');
    externalLinkIconWrapper.classList.add('mousehuntHud-menu');

    const externalLinkIcon = document.createElement('div');
    externalLinkIcon.classList.add('external_icon');

    externalLinkIconWrapper.appendChild(externalLinkIcon);
    item.appendChild(externalLinkIconWrapper);
  }

  menu.insertBefore(item, menu.firstChild);
};

/**
 * Build a popup.
 *
 * Templates:
 *   ajax: no close button in lower right, 'prefix' instead of title. 'suffix' for close button area.
 *   default: {*title*} {*content*}
 *   error: in red, with error icon{*title*} {*content*}
 *   largerImage: full width image {*title*} {*image*}
 *   largerImageWithClass: smaller than larger image, with caption {*title*} {*image*} {*imageCaption*} {*imageClass*} (goes on the img tag)
 *   loading: Just says loading
 *   multipleItems: {*title*} {*content*} {*items*}
 *   singleItemLeft: {*title*} {*content*} {*items*}
 *   singleItemRight: {*title*} {*content*} {*items*}
 *
 * @param {Object}  options                The popup options.
 * @param {string}  options.title          The title of the popup.
 * @param {string}  options.content        The content of the popup.
 * @param {boolean} options.hasCloseButton Whether or not the popup has a close button.
 * @param {string}  options.template       The template to use for the popup.
 * @param {boolean} options.show           Whether or not to show the popup.
 * @param {string}  options.className      The class name to add to the popup.
 */
const createPopup = (options) => {
  // If we don't have jsDialog, bail.
  if ('undefined' === typeof jsDialog || ! jsDialog) { // eslint-disable-line no-undef
    return;
  }

  // Default to sensible values.
  const settings = Object.assign({}, {
    title: '',
    content: '',
    hasCloseButton: true,
    template: 'default',
    show: true,
    className: '',
  }, options);

  // Initiate the popup.
  const popup = new jsDialog(); // eslint-disable-line no-undef
  popup.setIsModal(! settings.hasCloseButton);

  // Set the template & add in the content.
  popup.setTemplate(settings.template);
  popup.addToken('{*title*}', settings.title);
  popup.addToken('{*content*}', settings.content);

  popup.setAttributes({
    className: settings.className,
  });

  // If we want to show the popup, show it.
  if (settings.show) {
    popup.show();
  }

  return popup;
};

/**
 * Create a popup with an image.
 *
 * @param {Object}  options       Popup options.
 * @param {string}  options.title The title of the popup.
 * @param {string}  options.image The image to show in the popup.
 * @param {boolean} options.show  Whether or not to show the popup.
 */
const createImagePopup = (options) => {
  // Default to sensible values.
  const settings = Object.assign({}, {
    title: '',
    image: '',
    show: true,
  }, options);

  // Create the popup.
  const popup = createPopup({
    title: settings.title,
    template: 'largerImage',
    show: false,
  });

  // Add the image to the popup.
  popup.addToken('{*image*}', settings.image);

  // If we want to show the popup, show it.
  if (settings.show) {
    popup.show();
  }

  return popup;
};

/**
 * Show a map-popup.
 *
 * @param {Object}  options            The popup options.
 * @param {string}  options.title      The title of the popup.
 * @param {string}  options.content    The content of the popup.
 * @param {string}  options.closeClass The class to add to the close button.
 * @param {string}  options.closeText  The text to add to the close button.
 * @param {boolean} options.show       Whether or not to show the popup.
 */
const createMapPopup = (options) => {
  // Check to make sure we can call the hg views.
  if (! (hg && hg.views && hg.views.TreasureMapDialogView)) { // eslint-disable-line no-undef
    return;
  }

  // Default to sensible values.
  const settings = Object.assign({}, {
    title: '',
    content: '',
    closeClass: 'acknowledge',
    closeText: 'ok',
    show: true,
  }, options);

  // Initiate the popup.
  const dialog = new hg.views.TreasureMapDialogView(); // eslint-disable-line no-undef

  // Set all the content and options.
  dialog.setTitle(options.title);
  dialog.setContent(options.content);
  dialog.setCssClass(options.closeClass);
  dialog.setContinueAction(options.closeText);

  // If we want to show & we can show, show it.
  if (settings.show && hg.controllers && hg.controllers.TreasureMapDialogController) { // eslint-disable-line no-undef
    hg.controllers.TreasureMapController.show(); // eslint-disable-line no-undef
    hg.controllers.TreasureMapController.showDialog(dialog); // eslint-disable-line no-undef
  }

  return dialog;
};

/**
 * Create a welcome popup.
 *
 * @param {Object} options                 The popup options.
 * @param {string} options.id              The ID of the popup.
 * @param {string} options.title           The title of the popup.
 * @param {string} options.content         The content of the popup.
 * @param {Array}  options.columns         The columns of the popup.
 * @param {string} options.columns.title   The title of the column.
 * @param {string} options.columns.content The content of the column.
 */
const createWelcomePopup = (options = {}) => {
  if (! (options && options.id && options.title && options.content)) {
    return;
  }

  if (! isLoggedIn()) {
    return;
  }

  const hasSeenWelcome = getSetting('has-seen-welcome', false, options.id);
  if (hasSeenWelcome) {
    return;
  }

  addStyles(`#overlayPopup.mh-welcome .jsDialog.top,
  #overlayPopup.mh-welcome .jsDialog.bottom,
  #overlayPopup.mh-welcome .jsDialog.background {
    padding: 0;
    margin: 0;
    background: none;
  }

  #overlayPopup.mh-welcome .jsDialogContainer .prefix,
  #overlayPopup.mh-welcome .jsDialogContainer .content {
    padding: 0;
  }

  #overlayPopup.mh-welcome #jsDialogClose,
  #overlayPopup.mh-welcome .jsDialogContainer .suffix {
    display: none;
  }

  #overlayPopup.mh-welcome .jsDialogContainer {
    padding: 0 20px;
    background-image: url(https://www.mousehuntgame.com/images/ui/newsposts/np_border.png);
    background-repeat: repeat-y;
    background-size: 100%;
  }

  #overlayPopup.mh-welcome .jsDialogContainer::before {
    position: absolute;
    top: -80px;
    right: 0;
    left: 0;
    height: 100px;
    content: '';
    background-image: url(https://www.mousehuntgame.com/images/ui/newsposts/np_header.png);
    background-repeat: no-repeat;
    background-size: 100%;
  }

  #overlayPopup.mh-welcome .jsDialogContainer::after {
    position: absolute;
    top: 100%;
    right: 0;
    left: 0;
    height: 126px;
    content: '';
    background-image: url(https://www.mousehuntgame.com/images/ui/newsposts/np_footer.png);
    background-repeat: no-repeat;
    background-size: 100%;
  }

  .mh-welcome .mh-title {
    position: relative;
    top: -90px;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 412px;
    height: 90px;
    margin: 20px auto 0;
    font-family: Georgia, serif;
    font-size: 26px;
    font-weight: 700;
    color: #7d3b0a;
    text-align: center;
    text-shadow: 1px 1px 1px #e9d5a2;
    background: url(https://www.mousehuntgame.com/images/ui/larry_gifts/ribbon.png?asset_cache_version=2) no-repeat;
  }

  .mh-welcome .mh-inner-wrapper {
    display: flex;
    padding: 5px 10px 25px;
    margin-top: -90px;
  }

  .mh-welcome .text {
    margin-left: 30px;
    line-height: 18px;
    text-align: left;
  }

  .mh-welcome .text p {
    font-size: 13px;
    line-height: 19px;
  }

  .mh-welcome .mh-inner-title {
    padding: 10px 0;
    font-size: 1.5em;
    font-weight: 700;
  }

  .mh-welcome .mh-button-wrapper {
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .mh-welcome .mh-button {
    padding: 10px 50px;
    font-size: 1.5em;
    color: #000;
    background: linear-gradient(to bottom, #fff600, #f4e830);
    border: 1px solid #000;
    border-radius: 5px;
    box-shadow: 0 0 10px 1px #d6d13b inset;
  }

  .mh-welcome .mh-intro-text {
    margin: 2em 1em;
    font-size: 15px;
    line-height: 25px;
  }

  .mh-welcome-columns {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 2em;
    margin: 1em;
    -ms-grid-columns: 1fr 2em 1fr;
  }

  .mh-welcome-column h2 {
    margin-bottom: 1em;
    font-size: 16px;
    color: #7d3b0a;
    border-bottom: 1px solid #cba36d;
  }

  .mh-welcome-column ul {
    margin-left: 3em;
    list-style: disc;
  }
  `, 'mh-welcome', true);

  const markup = `<div class="mh-welcome">
    <h1 class="mh-title">${options.title}</h1>
    <div class="mh-inner-wrapper">
      <div class="text">
        <div class="mh-intro-text">
          ${options.content}
          </div>
        <div class="mh-welcome-columns">
          ${options.columns.map((column) => `<div class="mh-welcome-column">
            <h2>${column.title}</h2>
            ${column.content}
          </div>`).join('')}
        </div>
      </div>
    </div>
    <div class="mh-button-wrapper">
      <a href="#" id="mh-welcome-${options.id}-continue" class="mh-button">Continue</a>
    </div>
  </div>`;

  // Initiate the popup.
  const welcomePopup = createPopup({
    hasCloseButton: false,
    template: 'ajax',
    content: markup,
    show: false,
  });

  // Set more of our tokens.
  welcomePopup.addToken('{*prefix*}', '');
  welcomePopup.addToken('{*suffix*}', '');

  // Set the attribute and show the popup.
  welcomePopup.setAttributes({ className: `mh-welcome mh-welcome-popup-${options.id}` });

  // If we want to show the popup, show it.
  welcomePopup.show();

  // Add the event listener to the continue button.
  const continueButton = document.getElementById(`mh-welcome-${options.id}-continue`);
  continueButton.addEventListener('click', () => {
    saveSetting('has-seen-welcome', true, options.id);
    welcomePopup.hide();
  });
};

/**
 * Create a popup with the larry's office style.
 *
 * @param {string} content Content to display in the popup.
 * @param {Array}  classes Classes to add to the popup.
 */
const createLarryPopup = (content, classes = []) => {
  const message = {
    content: { body: content },
    css_class: ['larryOffice', ...classes].join(' '),
    show_overlay: true,
    is_modal: true
  };

  hg.views.MessengerView.addMessage(message);
  hg.views.MessengerView.go();
};

/**
 * Add a popup similar to the larry's gift popup.
 *
 * createPaperPopup({
 *   title: 'Whoa! A popup!',
 *   content: {
 *     title: 'This is the title of the content',
 *     text: 'This is some text for the content Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quid ergo hoc loco intellegit honestum? Dicimus aliquem hilare vivere; Cui Tubuli nomen odio non est? Duo Reges: constructio interrete. Sed venio ad inconstantiae crimen, ne saepius dicas me aberrare; Aliena dixit in physicis nec ea ipsa, quae tibi probarentur;',
 *     image: 'https://api.mouse.rip/hunter/trap/8209591.png',
 *   },
 *   button: {
 *     text: 'A button',
 *     href: '#',
 *   },
 *   show: true,
 * });
 *
 * @param {Object}  options               The popup options.
 * @param {string}  options.title         The title of the popup.
 * @param {Object}  options.content       The content of the popup.
 * @param {string}  options.content.title The title of the popup.
 * @param {string}  options.content.text  The text of the popup.
 * @param {string}  options.content.image The image of the popup.
 * @param {Array}   options.button        The button of the popup.
 * @param {string}  options.button.text   The text of the button.
 * @param {string}  options.button.href   The url of the button.
 * @param {boolean} options.show          Whether to show the popup or not.
 */
const createPaperPopup = (options) => {
  // If we don't have jsDialog, bail.
  if ('undefined' === typeof jsDialog || ! jsDialog) { // eslint-disable-line no-undef
    return;
  }

  // Add the styles for our popup.
  addStyles(`#overlayPopup.mh-paper-popup-dialog-wrapper .jsDialog.top,
  #overlayPopup.mh-paper-popup-dialog-wrapper .jsDialog.bottom,
  #overlayPopup.mh-paper-popup-dialog-wrapper .jsDialog.background {
    padding: 0;
    margin: 0;
    background: none;
  }

  #overlayPopup.mh-paper-popup-dialog-wrapper .jsDialogContainer .prefix,
  #overlayPopup.mh-paper-popup-dialog-wrapper .jsDialogContainer .content {
    padding: 0;
  }

  #overlayPopup.mh-paper-popup-dialog-wrapper #jsDialogClose,
  #overlayPopup.mh-paper-popup-dialog-wrapper .jsDialogContainer .suffix {
    display: none;
  }

  #overlayPopup.mh-paper-popup-dialog-wrapper .jsDialogContainer {
    padding: 0 20px;
    background-image: url(https://www.mousehuntgame.com/images/ui/newsposts/np_border.png);
    background-repeat: repeat-y;
    background-size: 100%;
  }

  #overlayPopup.mh-paper-popup-dialog-wrapper .jsDialogContainer::before {
    position: absolute;
    top: -80px;
    right: 0;
    left: 0;
    height: 100px;
    content: '';
    background-image: url(https://www.mousehuntgame.com/images/ui/newsposts/np_header.png);
    background-repeat: no-repeat;
    background-size: 100%;
  }

  #overlayPopup.mh-paper-popup-dialog-wrapper .jsDialogContainer::after {
    position: absolute;
    top: 100%;
    right: 0;
    left: 0;
    height: 126px;
    content: '';
    background-image: url(https://www.mousehuntgame.com/images/ui/newsposts/np_footer.png);
    background-repeat: no-repeat;
    background-size: 100%;
  }

  .mh-paper-popup-dialog-wrapper .mh-title {
    position: relative;
    top: -40px;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 412px;
    height: 99px;
    margin: 20px auto 0;
    font-family: Georgia, serif;
    font-size: 34px;
    font-weight: 700;
    color: #7d3b0a;
    text-align: center;
    text-shadow: 1px 1px 1px #e9d5a2;
    background: url(https://www.mousehuntgame.com/images/ui/larry_gifts/ribbon.png?asset_cache_version=2) no-repeat;
  }

  .mh-paper-popup-dialog-wrapper .mh-inner-wrapper {
    display: flex;
    padding: 5px 10px 25px;
  }

  .mh-paper-popup-dialog-wrapper .mh-inner-image-wrapper {
    position: relative;
    padding: 10px;
    margin: 0 auto 10px;
    background: #f7e3af;
    border-radius: 10px;
    box-shadow: 0 3px 10px #bd7d3c;
  }

  .mh-paper-popup-dialog-wrapper .mh-inner-image {
    width: 200px;
    height: 200px;
    background-color: #f5edd7;
    border-radius: 5px;
    box-shadow: 0 0 100px #6c340b inset;
  }

  .mh-paper-popup-dialog-wrapper .mh-inner-text {
    margin-left: 30px;
    line-height: 18px;
    text-align: left;
  }

  .mh-paper-popup-dialog-wrapper .mh-inner-title {
    padding: 10px 0;
    font-size: 1.5em;
    font-weight: 700;
  }

  .mh-paper-popup-dialog-wrapper .mh-button-wrapper {
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .mh-paper-popup-dialog-wrapper .mh-button {
    padding: 10px 50px;
    font-size: 1.5em;
    color: #000;
    background: linear-gradient(to bottom, #fff600, #f4e830);
    border: 1px solid #000;
    border-radius: 5px;
    box-shadow: 0 0 10px 1px #d6d13b inset;
  }
  `);

  // Default to sensible values.
  const settings = Object.assign({}, {
    title: '',
    content: {
      title: '',
      text: '',
      image: '',
    },
    button: {
      text: '',
      href: '',
    },
    show: true,
    className: '',
  }, options);

  // Build the markup with our content.
  const markup = `<div class="mh-paper-popup-wrapper">
    <div class="mh-title">${settings.title}</div>
    <div class="mh-inner-wrapper">
      <div class="mh-inner-image-wrapper">
        <img class="mh-inner-image" src="${settings.content.image}" />
      </div>
      <div class="mh-inner-text">
        <div class="mh-inner-title">${settings.content.title}</div>
        <p>${settings.content.text}</p>
      </div>
    </div>
    <div class="mh-button-wrapper">
      <a href="${settings.button.href}" class="mh-button">${settings.button.text}</a>
    </div>
  </div>`;

  // Initiate the popup.
  const popup = createPopup({
    hasCloseButton: false,
    template: 'ajax',
    content: markup,
    show: false,
  });

  // Set more of our tokens.
  popup.addToken('{*prefix*}', '');
  popup.addToken('{*suffix*}', '');

  // Set the attribute and show the popup.
  popup.setAttributes({ className: `mh-paper-popup-dialog-wrapper ${settings.className}` });

  // If we want to show the popup, show it.
  if (settings.show) {
    popup.show();
  }

  return popup;
};

/**
 * Show a message in the horn dialog.
 *
 * Type can be one of these: bait_empty unknown_error bait_disarmed recent_turn recent_linked_turn puzzle
 *
 * @param {Object}   options           Options for the message.
 * @param {string}   options.title     Title of the message. Keep it under 50 characters.
 * @param {string}   options.text      Text of the message. Keep it under 90 characters.
 * @param {string}   options.button    Text of the button.
 * @param {Function} options.action    Callback for the button.
 * @param {number}   options.dismiss   Time to dismiss the message.
 * @param {string}   options.type      Type of the message.
 * @param {string}   options.classname Classname of the message.
 */
const showHornMessage = (options) => {
  const huntersHornView = document.querySelector('.huntersHornView__messageContainer');
  if (! huntersHornView) {
    return;
  }

  const settings = {
    title: options.title || 'Hunters Horn',
    text: options.text || 'This is a message from the Hunters Horn',
    button: options.button || 'OK',
    action: options.action || (() => { }),
    dismiss: options.dismiss || null,
    type: options.type || 'recent_linked_turn',
    classname: options.classname || '',
    image: options.image || null,
    imageLink: options.imageLink || null,
    imageCallback: options.imageCallback || null,
  };

  // do the other effects
  const backdrop = document.querySelector('.huntersHornView__backdrop');
  if (backdrop) {
    backdrop.classList.add('huntersHornView__backdrop--active');
  }

  const gameInfo = document.querySelector('.mousehuntHud-gameInfo');
  if (gameInfo) {
    gameInfo.classList.add('blur');
  }

  const messageWrapper = makeElement('div', ['huntersHornView__message huntersHornView__message--active', settings.classname]);
  const message = makeElement('div', ['huntersHornMessageView', `huntersHornMessageView--${settings.type}`]);
  makeElement('div', 'huntersHornMessageView__title', settings.title, message);
  const content = makeElement('div', 'huntersHornMessageView__content');
  if (settings.image) {
    const imgWrapper = makeElement('div', 'huntersHornMessageView__friend');
    const img = makeElement('a', 'huntersHornMessageView__friendProfilePic');
    if (settings.imageLink) {
      img.href = settings.imageLink;
    } else if (settings.imageCallback) {
      img.addEventListener('click', settings.imageCallback);
    } else {
      img.href = '#';
    }

    img.style.backgroundImage = `url(${settings.image})`;

    imgWrapper.appendChild(img);
    content.appendChild(imgWrapper);
  }
  makeElement('div', 'huntersHornMessageView__text', settings.text, content);
  const buttonSpacer = makeElement('div', 'huntersHornMessageView__buttonSpacer');
  const button = makeElement('button', 'huntersHornMessageView__action');
  const buttonLabel = makeElement('div', 'huntersHornMessageView__actionLabel');
  makeElement('span', 'huntersHornMessageView__actionText', settings.button, buttonLabel);

  button.appendChild(buttonLabel);

  button.addEventListener('click', () => {
    if (settings.action) {
      settings.action();
    }

    messageWrapper.innerHTML = '';
    backdrop.classList.remove('huntersHornView__backdrop--active');
    gameInfo.classList.remove('blur');
  });

  buttonSpacer.appendChild(button);
  content.appendChild(buttonSpacer);

  message.appendChild(content);

  if (settings.dismiss) {
    const countdown = makeElement('button', ['huntersHornMessageView__countdown']);
    makeElement('div', 'huntersHornMessageView__countdownButtonImage', '', countdown);

    const svgMarkup = `<svg class="huntersHornMessageView__countdownSVG">
        <circle r="46%" cx="50%" cy="50%" class="huntersHornMessageView__countdownCircleTrack"></circle>
        <circle r="46%" cx="50%" cy="50%" class="huntersHornMessageView__countdownCircle" style="animation-duration: ${settings.dismiss}ms;"></circle>
    </svg>`;
    countdown.innerHTML += svgMarkup;
    message.appendChild(countdown);
  }

  messageWrapper.appendChild(message);

  // remove any existing messages
  const existingMessages = huntersHornView.querySelector('.huntersHornView__message');
  if (existingMessages) {
    existingMessages.remove();
  }

  huntersHornView.appendChild(messageWrapper);

  if (settings.dismiss) {
    setTimeout(() => {
      const countdown = messageWrapper.querySelector('.huntersHornMessageView__countdown');
      if (countdown) {
        countdown.classList.add('huntersHornMessageView__countdown--complete');
      }
      messageWrapper.innerHTML = '';
      backdrop.classList.remove('huntersHornView__backdrop--active');
      gameInfo.classList.remove('blur');
    }, settings.dismiss);
  }
};

const toggleHornDom = (verb = 'remove') => {
  const els = [
    {
      selector: '.huntersHornView__horn',
      class: 'huntersHornView__horn--active',
    },
    {
      selector: '.huntersHornView__backdrop',
      class: 'huntersHornView__backdrop--active',
    },
    {
      selector: '.huntersHornView__message',
      class: 'huntersHornView__message--active',
    },
    {
      selector: '.mousehuntHud-environmentName',
      class: 'blur'
    },
    {
      selector: '.mousehuntHud-gameInfo',
      class: 'blur'
    },
    {
      selector: '.huntersHornView__horn',
      class: 'huntersHornView__horn--hide'
    },
    {
      selector: '.huntersHornView__backdrop',
      class: 'huntersHornView__backdrop--active'
    },
    {
      selector: '.huntersHornView__message',
      class: 'huntersHornView__message--active'
    },
  ];

  els.forEach((el) => {
    const dom = document.querySelector(el.selector);
    if (dom) {
      dom.classList[ verb ](el.class);
    }
  }
  );

  return document.querySelector('.huntersHornView__message');
};

/**
 * TODO: document this
 *
 * @param {*} message
 */
const showHuntersHornMessage = (message) => {
  const defaultValues = {
    callback: null,
    countdown: null,
    actionText: null,
  };

  message = Object.assign(defaultValues, message);

  // if the callback was passed in, we need to wrap it in a function that will dismiss the message
  if (message.callback) {
    const originalCallback = message.callback;
    message.callback = () => {
      originalCallback();
      dismissHuntersHornMessage();
    };
  } else {
    message.callback = dismissHuntersHornMessage;
  }

  const messageDom = toggleHornDom('add');
  const messageView = new hg.views.HuntersHornMessageView(message);
  messageDom.innerHTML = '';
  messageDom.appendChild(messageView.render()[ 0 ]);
};

/**
 * TODO: document this
 */
const dismissHuntersHornMessage = () => {
  toggleHornDom('remove');
};

/**
 * Make an element draggable. Saves the position to local storage.
 *
 * @param {string}  dragTarget   The selector for the element that should be dragged.
 * @param {string}  dragHandle   The selector for the element that should be used to drag the element.
 * @param {number}  defaultX     The default X position.
 * @param {number}  defaultY     The default Y position.
 * @param {string}  storageKey   The key to use for local storage.
 * @param {boolean} savePosition Whether or not to save the position to local storage.
 */
const makeElementDraggable = (dragTarget, dragHandle, defaultX = null, defaultY = null, storageKey = null, savePosition = true) => {
  const modal = document.querySelector(dragTarget);
  if (! modal) {
    return;
  }

  const handle = document.querySelector(dragHandle);
  if (! handle) {
    return;
  }

  /**
   * Make sure the coordinates are within the bounds of the window.
   *
   * @param {string} type  The type of coordinate to check.
   * @param {number} value The value of the coordinate.
   *
   * @return {number} The value of the coordinate, or the max/min value if it's out of bounds.
   */
  const keepWithinLimits = (type, value) => {
    if ('top' === type) {
      return value < -20 ? -20 : value;
    }

    if (value < (handle.offsetWidth * -1) + 20) {
      return (handle.offsetWidth * -1) + 20;
    }

    if (value > document.body.clientWidth - 20) {
      return document.body.clientWidth - 20;
    }

    return value;
  };

  /**
   * When the mouse is clicked, add the class and event listeners.
   *
   * @param {Object} e The event object.
   */
  const onMouseDown = (e) => {
    e.preventDefault();
    setTimeout(() => {
      // Get the current mouse position.
      x1 = e.clientX;
      y1 = e.clientY;

      // Add the class to the element.
      modal.classList.add('mh-is-dragging');

      // Add the onDrag and finishDrag events.
      document.onmousemove = onDrag;
      document.onmouseup = finishDrag;
    }, 50);
  };

  /**
   * When the drag is finished, remove the dragging class and event listeners, and save the position.
   */
  const finishDrag = () => {
    document.onmouseup = null;
    document.onmousemove = null;

    // Remove the class from the element.
    modal.classList.remove('mh-is-dragging');

    if (storageKey) {
      localStorage.setItem(storageKey, JSON.stringify({ x: modal.offsetLeft, y: modal.offsetTop }));
    }
  };

  /**
   * When the mouse is moved, update the element's position.
   *
   * @param {Object} e The event object.
   */
  const onDrag = (e) => {
    e.preventDefault();

    // Calculate the new cursor position.
    x2 = x1 - e.clientX;
    y2 = y1 - e.clientY;

    x1 = e.clientX;
    y1 = e.clientY;

    const newLeft = keepWithinLimits('left', modal.offsetLeft - x2);
    const newTop = keepWithinLimits('top', modal.offsetTop - y2);

    // Set the element's new position.
    modal.style.left = `${newLeft}px`;
    modal.style.top = `${newTop}px`;
  };

  // Set the default position.
  let startX = defaultX || 0;
  let startY = defaultY || 0;

  // If the storageKey was passed in, get the position from local storage.
  if (! storageKey) {
    storageKey = `mh-draggable-${dragTarget}-${dragHandle}`;
  }

  if (savePosition) {
    const storedPosition = localStorage.getItem(storageKey);
    if (storedPosition) {
      const position = JSON.parse(storedPosition);

      // Make sure the position is within the bounds of the window.
      startX = keepWithinLimits('left', position.x);
      startY = keepWithinLimits('top', position.y);
    }
  }

  // Set the element's position.
  modal.style.left = `${startX}px`;
  modal.style.top = `${startY}px`;

  // Set up our variables to track the mouse position.
  let x1 = 0,
    y1 = 0,
    x2 = 0,
    y2 = 0;

  // Add the event listener to the handle.
  handle.onmousedown = onMouseDown;
};

const makeDraggableModal = (opts) => {
  const {
    id,
    title,
    content,
    defaultX,
    defaultY,
    storageKey,
    savePosition,
  } = opts;

  // set the defaults for the options
  opts = Object.assign({
    id: 'mh-utils-modal',
    title: '',
    content: '',
    defaultX: null,
    defaultY: null,
    storageKey: 'mh-utils-modal',
    savePosition: true,
  }, opts);

  // Remove the existing modal.
  const existing = document.getElementById(`mh-utils-modal-${id}`);
  if (existing) {
    existing.remove();
  }

  // Create the modal.
  const modalWrapper = makeElement('div', 'mh-utils-modal-wrapper');
  modalWrapper.id = `mh-utils-modal-${id}`;

  const modal = makeElement('div', 'mh-utils-modal');
  const header = makeElement('div', 'mh-utils-modal-header');
  makeElement('h1', 'mh-utils-modal-title', title, header);

  // Create a close button icon.
  const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  closeIcon.classList.add('mh-utils-modal-close');
  closeIcon.setAttribute('viewBox', '0 0 24 24');
  closeIcon.setAttribute('width', '18');
  closeIcon.setAttribute('height', '18');
  closeIcon.setAttribute('fill', 'none');
  closeIcon.setAttribute('stroke', 'currentColor');
  closeIcon.setAttribute('stroke-width', '1.5');

  // Create the path.
  const closePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  closePath.setAttribute('d', 'M18 6L6 18M6 6l12 12');
  closeIcon.appendChild(closePath);

  // Close the modal when the icon is clicked.
  closeIcon.addEventListener('click', () => {
    modalWrapper.remove();
  });

  // Append the button.
  header.appendChild(closeIcon);

  // Add the header to the modal.
  modal.appendChild(header);

  // Make the mouse stats table.
  const mouseBody =
   document.createElement('div');
  mouseBody.classList.add('mh-utils-modal-body');

  modal.appendChild(content);

  // Add the modal to the wrapper.
  modalWrapper.appendChild(modal);

  // Add the wrapper to the body.
  document.body.appendChild(modalWrapper);

  // Make the modal draggable.
  makeElementDraggable(
    `mh-utils-modal-${id}`,
    'mh-utils-modal',
    'mh-utils-modal-header',
    defaultX,
    defaultY,
    storageKey,
    savePosition
  );
};

/**
 * Creates an element with the given tag, classname, text, and appends it to the given element.
 *
 * @param {string}      tag      The tag of the element to create.
 * @param {string}      classes  The classes of the element to create.
 * @param {string}      text     The text of the element to create.
 * @param {HTMLElement} appendTo The element to append the created element to.
 *
 * @return {HTMLElement} The created element.
 */
const makeElement = (tag, classes = '', text = '', appendTo = null) => {
  const element = document.createElement(tag);

  // if classes is an array, join it with a space.
  if (Array.isArray(classes)) {
    classes = classes.join(' ');
  }

  element.className = classes;
  element.innerHTML = text;

  if (appendTo) {
    appendTo.appendChild(element);
    return appendTo;
  }

  return element;
};

/**
 * Return an anchor element with the given text and href.
 *
 * @param {string}  text          Text to use for link.
 * @param {string}  href          URL to link to.
 * @param {boolean} tiny          Use the tiny button style.
 * @param {Array}   extraClasses  Extra classes to add to the link.
 * @param {boolean} encodeAsSpace Encode spaces as %20 instead of _.
 *
 * @return {string} HTML for link.
 */
const makeButton = (text, href, tiny = true, extraClasses = [], encodeAsSpace = false) => {
  href = href.replace(/\s/g, '_');

  if (encodeAsSpace) {
    href = href.replace(/_/g, '%20');
  } else {
    href = href.replace(/\s/g, '_');
  }

  href = href.replace(/\$/g, '_');

  return `<a href="${href}" class="mousehuntActionButton ${tiny ? 'tiny' : ''} ${extraClasses.join(' ')}"><span>${text}</span></a>`;
};

/**
 * Creates a popup with two choices.
 *
 * createChoicePopup({
 *   title: 'Choose your first trap',
 *   choices: [
 *     {
 *       id: 'treasurer_mouse',
 *       name: 'Treasurer',
 *       image: 'https://www.mousehuntgame.com/images/mice/medium/bb55034f6691eb5e3423927e507b5ec9.jpg?cv=2',
 *       meta: 'Mouse',
 *       text: 'This is a mouse',
 *       button: 'Select',
 *       callback: () => {
 *         console.log('treasurer selected');
 *       }
 *     },
 *     {
 *       id: 'high_roller_mouse',
 *       name: 'High Roller',
 *       image: 'https://www.mousehuntgame.com/images/mice/medium/3f71c32f9d8da2b2727fc8fd288f7974.jpg?cv=2',
 *       meta: 'Mouse',
 *       text: 'This is a mouse',
 *       button: 'Select',
 *       callback: () => {
 *         console.log('high roller selected');
 *       }
 *     },
 *   ],
 * });
 *
 * @param {Object} options                  The options for the popup.
 * @param {string} options.title            The title of the popup.
 * @param {Array}  options.choices          The choices for the popup.
 * @param {string} options.choices[].id     The ID of the choice.
 * @param {string} options.choices[].name   The name of the choice.
 * @param {string} options.choices[].image  The image of the choice.
 * @param {string} options.choices[].meta   The smaller text under the name.
 * @param {string} options.choices[].text   The description of the choice.
 * @param {string} options.choices[].button The text of the button.
 * @param {string} options.choices[].action The action to take when the button is clicked.
 */
const createChoicePopup = (options) => {
  let choices = '';
  const numChoices = options.choices.length;
  let currentChoice = 0;

  options.choices.forEach((choice) => {
    choices += `<a href="#" id=${choice.id}" class="weaponContainer">
    <div class="weapon">
      <div class="trapImage" style="background-image: url(${choice.image});"></div>
      <div class="trapDetails">
        <div class="trapName">${choice.name}</div>
        <div class="trapDamageType">${choice.meta}</div>
        <div class="trapDescription">${choice.text}</div>
        <div class="trapButton" id="${choice.id}-action">${choice.button || 'Select'}</div>
      </div>
    </div>
    </a>`;

    currentChoice++;
    if (currentChoice < numChoices) {
      choices += '<div class="spacer"></div>';
    }
  });

  const content = `<div class="trapIntro">
    <div id="OnboardArrow" class="larryCircle">
      <div class="woodgrain">
        <div class="whiteboard">${options.title}</div>
      </div>
      <div class="characterContainer">
        <div class="character"></div>
      </div>
    </div>
  </div>
  <div>
    ${choices}
  </div>`;

  hg.views.MessengerView.addMessage({
    content: { body: content },
    css_class: 'chooseTrap',
    show_overlay: true,
    is_modal: true
  });
  hg.views.MessengerView.go();

  options.choices.forEach((choice) => {
    const target = document.querySelector(`#${choice.id}-action`);
    if (target) {
      target.addEventListener('click', () => {
        hg.views.MessengerView.hide();
        if (choice.action) {
          choice.action();
        }
      });
    }
  });
};

/**
 * Creates a favorite button that can toggle.
 *
 * @async
 *
 * @example <caption>Creating a favorite button</caption>
 * createFavoriteButton({
 *   id: 'testing_favorite',
 *   target: infobar,
 *   size: 'small',
 *   defaultState: false,
 * });
 *
 * @param {Object} options              The options for the button.
 * @param {string} options.selector     The selector for the button.
 * @param {string} options.size         Whether or not to use the small version of the button.
 * @param {string} options.active       Whether or not the button should be active by default.
 * @param {string} options.onChange     The function to run when the button is toggled.
 * @param {string} options.onActivate   The function to run when the button is activated.
 * @param {string} options.onDeactivate The function to run when the button is deactivated.
 */
const createFavoriteButton = async (options) => {
  addStyles(`.custom-favorite-button {
    top: 0;
    right: 0;
    display: inline-block;
    width: 35px;
    height: 35px;
    vertical-align: middle;
    background: url(https://www.mousehuntgame.com/images/ui/camp/trap/star_empty.png?asset_cache_version=2) 50% 50% no-repeat;
    background-size: 90%;
    border-radius: 50%;
  }

  .custom-favorite-button-small {
    width: 20px;
    height: 20px;
  }

  .custom-favorite-button:hover {
    background-color: #fff;
    outline: 2px solid #ccc;
    background-image: url(https://www.mousehuntgame.com/images/ui/camp/trap/star_favorite.png?asset_cache_version=2);
  }

  .custom-favorite-button.active {
    background-image: url(https://www.mousehuntgame.com/images/ui/camp/trap/star_favorite.png?asset_cache_version=2);
  }

  .custom-favorite-button.busy {
    background-image: url(https://www.mousehuntgame.com/images/ui/loaders/small_spinner.gif?asset_cache_version=2);
  }
  `, 'custom-favorite-button', true);

  const {
    id = null,
    target = null,
    size = 'small',
    state = false,
    isSetting = true,
    defaultState = false,
    onChange = null,
    onActivate = null,
    onDeactivate = null,
  } = options;

  const star = document.createElement('a');

  star.classList.add('custom-favorite-button');
  if (size === 'small') {
    star.classList.add('custom-favorite-button-small');
  }

  star.setAttribute('data-item-id', id);
  star.setAttribute('href', '#');

  star.style.display = 'inline-block';

  let currentSetting = false;
  if (isSetting) {
    currentSetting = getSetting(id, defaultState);
  } else {
    currentSetting = state;
  }

  if (currentSetting) {
    star.classList.add('active');
  } else {
    star.classList.add('inactive');
  }

  star.addEventListener('click', async (e) => {
    star.classList.add('busy');
    e.preventDefault();
    e.stopPropagation();
    const currentStar = e.target;
    const currentState = ! currentStar.classList.contains('active');

    if (onChange !== null) {
      await onChange(currentState);
    } else if (isSetting) {
      saveSetting(id, currentState);
    }

    currentStar.classList.remove('inactive');
    currentStar.classList.remove('active');

    if (currentState) {
      currentStar.classList.add('active');
      if (onActivate !== null) {
        await onActivate(currentState);
      }
    } else {
      currentStar.classList.add('inactive');
      if (onDeactivate !== null) {
        await onDeactivate(currentState);
      }
    }

    currentStar.classList.remove('busy');
  });

  if (target) {
    target.appendChild(star);
  }

  return star;
};

/**
 * Wait for a specified amount of time.
 *
 * @param {number} ms The number of milliseconds to wait.
 */
const wait = (ms) => {
  return new Promise((resolve) => setTimeout(resolve, ms));
};

/**
 * Log to the console.
 *
 * @param {string|Object} message The message to log.
 * @param {Object}        args    The arguments to pass to the console.
 */
const clog = (message, ...args) => {
  // If a string is passed in, log it in line with our prefix.
  if ('string' === typeof message) {
    console.log(`%c[MH Utils] %c${message}`, 'color: #ff0000; font-weight: bold;', 'color: #000000;'); // eslint-disable-line no-console
    console.log(...args); // eslint-disable-line no-console
  } else {
    // Otherwise, log it separately.
    console.log('%c[MH Utils]', 'color: #ff0000; font-weight: bold;'); // eslint-disable-line no-console
    console.log(message); // eslint-disable-line no-console
  }
};

/**
 * Log to the console if debug mode is enabled.
 *
 * @param {string|Object} message The message to log.
 * @param {Object}        args    The arguments to pass to the console.
 */
const debug = (message, ...args) => {
  if (getSetting('debug-mode', false)) {
    clog(message, ...args);
  }
};

/**
 * Add a setting to enable debug mode.
 */
const enableDebugMode = () => {
  const debugSettings = {
    debugModeEnabled: true,
    debug: getSetting('debug-mode', false)
  };

  window.mhutils = window.mhutils ? { ...window.mhutils, ...debugSettings } : debugSettings;

  addSetting('Debug Mode', 'debug-mode', false, 'Enable debug mode', {}, 'game_settings');
};

/**
 * Helper to run a callback when loaded, on ajax request, on overlay close, and on travel.
 *
 * @param {Function} action The callback to run.
 */
const run = async (action) => {
  action();
  onAjaxRequest(action);
  onOverlayClose(action);
  onTravel(null, { callback: action });
};

/**
 * Check if dark mode is enabled.
 *
 * @return {boolean} True if dark mode is enabled, false otherwise.
 */
const isDarkMode = () => {
  return !! getComputedStyle(document.documentElement).getPropertyValue('--mhdm-white');
};

/**
 * Adds classes to the body to enable styling based on the location or if dark mode is enabled.
 */
const addBodyClasses = () => {
  const addLocationBodyClass = () => {
    const addClass = () => {
      const location = getCurrentLocation();
      document.body.classList.add(`mh-location-${location}`);
    };

    addClass();
    onTravel(null, { callback: addClass });
  };

  const addDarkModeBodyClass = () => {
    if (isDarkMode()) {
      document.body.classList.add('mh-dark-mode');
    }
  };

  addLocationBodyClass();
  addDarkModeBodyClass();
};

/**
 * Wait for the app to initialize, then add classes to the body.
 */
setTimeout(() => {
  addBodyClasses();
  eventRegistry.addEventListener('app_init', addBodyClasses);
}, 250);