package funkin.play;

import flixel.FlxState;
import funkin.ui.story.StoryMenuState;
import funkin.data.freeplay.player.PlayerRegistry;
import flixel.addons.transition.FlxTransitionableState;
import flixel.FlxG;
import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
import flixel.math.FlxMath;
import flixel.text.FlxText;
import flixel.tweens.FlxEase;
import flixel.tweens.FlxTween;
import flixel.util.FlxColor;
import funkin.audio.FunkinSound;
import funkin.data.song.SongRegistry;
import funkin.ui.freeplay.FreeplayState;
import funkin.graphics.FunkinSprite;
import funkin.play.cutscene.VideoCutscene;
import funkin.ui.AtlasText;
import flixel.util.FlxTimer;
import funkin.ui.MusicBeatSubState;
import funkin.util.HapticUtil;
import funkin.ui.FullScreenScaleMode;
import funkin.ui.transition.stickers.StickerSubState;
import funkin.util.SwipeUtil;
import funkin.util.TouchUtil;
#if FEATURE_MOBILE_ADVERTISEMENTS
import funkin.mobile.util.AdMobUtil;
#end

/**
 * Parameters for initializing the PauseSubState.
 */
typedef PauseSubStateParams =
{
  /**
   * Which mode to start in. Dictates what entries are displayed.
   */
  ?mode:PauseMode,

  /**
   * Whether the game paused because the window lost focus.
   */
  ?lostFocus:Bool
};

/**
 * The menu displayed when the Play State is paused.
 */
class PauseSubState extends MusicBeatSubState
{
  // ===============
  // Constants
  // ===============

  /**
   * Pause menu entries for when the game is paused during a song.
   */
  static final PAUSE_MENU_ENTRIES_STANDARD:Array<PauseMenuEntry> = [
    {text: 'Resume', callback: resume},
    {text: 'Restart Song', callback: restartPlayState},
    {text: 'Change Difficulty', callback: switchMode.bind(_, Difficulty)},
    {text: 'Enable Practice Mode', callback: enablePracticeMode, filter: () -> !(PlayState.instance?.isPracticeMode ?? false)},
    {text: 'Exit to Menu', callback: quitToMenu},
  ];

  /**
   * Pause menu entries for when the game is paused in the Chart Editor preview.
   */
  static final PAUSE_MENU_ENTRIES_CHARTING:Array<PauseMenuEntry> = [
    {text: 'Resume', callback: resume},
    {text: 'Restart Song', callback: restartPlayState},
    {text: 'Return to Chart Editor', callback: quitToChartEditor},
  ];

  /**
   * Pause menu entries for when the user selects "Change Difficulty".
   */
  static final PAUSE_MENU_ENTRIES_DIFFICULTY:Array<PauseMenuEntry> = [
    {text: 'Back', callback: switchMode.bind(_, Standard)}
    // Other entries are added dynamically.
  ];

  /**
   * Pause menu entries for when the game is paused during a video cutscene.
   */
  static final PAUSE_MENU_ENTRIES_VIDEO_CUTSCENE:Array<PauseMenuEntry> = [
    {text: 'Resume', callback: resume},
    {text: 'Skip Cutscene', callback: skipVideoCutscene},
    {text: 'Restart Cutscene', callback: restartVideoCutscene},
    {text: 'Exit to Menu', callback: quitToMenu},
  ];

  /**
   * Pause menu entries for when the game is paused during a conversation.
   */
  static final PAUSE_MENU_ENTRIES_CONVERSATION:Array<PauseMenuEntry> = [
    {text: 'Resume', callback: resume},
    {text: 'Skip Dialogue', callback: skipConversation},
    {text: 'Restart Dialogue', callback: restartConversation},
    {text: 'Exit to Menu', callback: quitToMenu},
  ];

  /**
   * Duration for the music to fade in when the pause menu is opened.
   */
  static final MUSIC_FADE_IN_TIME:Float = 5;

  /**
   * The final volume for the music when the pause menu is opened.
   */
  static final MUSIC_FINAL_VOLUME:Float = 0.75;

  static final CHARTER_FADE_DELAY:Float = 15.0;

  static final CHARTER_FADE_DURATION:Float = 0.75;

  /**
   * Defines which pause music to use.
   */
  public static var musicSuffix:String = '';

  /**
   * Reset the pause configuration to the default.
   */
  public static function reset():Void
  {
    musicSuffix = '';
  }

  // ===============
  // Status Variables
  // ===============

  /**
   * Disallow input until transitions are complete!
   * This prevents the pause menu from immediately closing when opened, among other things.
   */
  public var allowInput:Bool = true;

  // If this is true, it means we are frame 1 of our substate.
  var justOpened:Bool = true;

  /**
   * The entries currently displayed in the pause menu.
   */
  var currentMenuEntries:Array<PauseMenuEntry>;

  /**
   * The index of `currentMenuEntries` that is currently selected.
   */
  var currentEntry:Int = 0;

  /**
   * The mode that the pause menu is currently in.
   */
  var currentMode:PauseMode;

  /**
   * Whether the game paused because the window lost focus.
   */
  var lostFocus:Bool = false;

  // ===============
  // Graphics Variables
  // ===============

  #if mobile
  /**
   * The pause button for the game, only appears in Mobile targets. Shows up breifly to finish the pause animation.
   */
  var pauseButton:FunkinSprite;

  /**
   * The pause circle for the game, only appears in Mobile targets. Shows up breifly to finish the pause animation.
   */
  var pauseCircle:FunkinSprite;
  #end

  /**
   * The placeholder sprite displayed when an advertisement fails to load or display.
   */
  // var failedAdPlaceHolder:FunkinSprite;

  /**
   * The semi-transparent black background that appears when the game is paused.
   */
  var background:FunkinSprite;

  /**
   * The metadata displayed in the top right.
   */
  var metadata:FlxTypedSpriteGroup<FlxText>;

  /**
   * A text object that displays the current practice mode status.
   */
  var metadataPractice:FlxText;

  /**
   * A text object that displays the current death count.
   */
  var metadataDeaths:FlxText;

  /**
   * A text object which displays the current song's artist.
   * Fades to the charter after a period before fading back.
   */
  var metadataArtist:FlxText;

  /**
   * A text object that displays the current global offset.
   */
  var offsetText:FlxText;

  /**
   * A text object that displays information about the current global offset.
   */
  var offsetTextInfo:FlxText;

  /**
   * The actual text objects for the menu entries.
   */
  var menuEntryText:FlxTypedSpriteGroup<AtlasText>;

  /**
   * Callback that gets called once substate gets open.
   */
  var onPause:Void->Void;

  // ===============
  // Audio Variables
  // ===============
  var pauseMusic:FunkinSound;

  // ===============
  // Constructor
  // ===============

  public function new(?params:PauseSubStateParams, ?onPause:Void->Void)
  {
    super();
    this.currentMode = params?.mode ?? Standard;
    this.lostFocus = params?.lostFocus ?? false;
    this.onPause = onPause;
  }

  // ===============
  // Lifecycle Functions
  // ===============

  /**
   * Called when the state is first loaded.
   */
  public override function create():Void
  {
    // Add banner ad when game is state is first loaded.
    #if FEATURE_MOBILE_ADVERTISEMENTS
    // extension.admob.Admob.onEvent.add(onBannerEvent);

    AdMobUtil.addBanner(extension.admob.AdmobBannerSize.BANNER, extension.admob.AdmobBannerAlign.TOP_LEFT);
    #end

    if (onPause != null) onPause();

    super.create();

    startPauseMusic();

    if (lostFocus && Preferences.autoPause) pauseMusic.pause();

    buildBackground();

    buildMetadata();

    regenerateMenu();

    transitionIn();

    startCharterTimer();
  }

  /**
   * Called every frame.
   * @param elapsed The time elapsed since the last frame, in seconds.
   */
  public override function update(elapsed:Float):Void
  {
    super.update(elapsed);

    handleInputs();
  }

  /**
   * Called when the state is closed.
   */
  public override function destroy():Void
  {
    // #if FEATURE_MOBILE_ADVERTISEMENTS
    // extension.admob.Admob.onEvent.remove(onBannerEvent);
    // #end
    super.destroy();
    charterFadeTween.cancel();
    charterFadeTween = null;
    dataFadeTimer.cancel();
    dataFadeTimer = null;
    hapticTimer.cancel();
    hapticTimer = null;
    pauseMusic.stop();
    onPause = null;
  }

  // ===============
  // Initialization Functions
  // ===============

  /*#if FEATURE_MOBILE_ADVERTISEMENTS
    function onBannerEvent(event:extension.admob.AdmobEvent):Void
    {
      if (event.name.indexOf('BANNER') == -1) return;

      if (event.errorCode != null && event.errorDescription != null)
      {
        if (failedAdPlaceHolder == null || members.indexOf(failedAdPlaceHolder) == -1)
        {
          var scale:Float = Math.min(FlxG.stage.stageWidth / FlxG.width, FlxG.stage.stageHeight / FlxG.height);

          #if android
          scale = Math.max(scale, 1);
          #else
          scale = Math.min(scale, 1);
          #end

          failedAdPlaceHolder = new FunkinSprite(0, 0);
          failedAdPlaceHolder.makeSolidColor(Math.floor(320 * scale), Math.floor(50 * scale), FlxColor.RED);
          failedAdPlaceHolder.updateHitbox();
          failedAdPlaceHolder.screenCenter(X);
          failedAdPlaceHolder.scrollFactor.set(0, 0);
          add(failedAdPlaceHolder);
        }
      }
      else if (failedAdPlaceHolder != null && members.indexOf(failedAdPlaceHolder) != -1)
      {
        remove(failedAdPlaceHolder);
      }
    }
    #end */
  /**
   * Play the pause music.
   */
  function startPauseMusic():Void
  {
    var pauseMusicPath:String = Paths.music('breakfast$musicSuffix/breakfast$musicSuffix');
    pauseMusic = FunkinSound.load(pauseMusicPath, 0, true, true);

    if (pauseMusic == null)
    {
      FlxG.log.warn('Could not play pause music: ${pauseMusicPath} does not exist!');
    }

    // Start playing at a random point in the song.
    pauseMusic.play(false, FlxG.random.int(0, Std.int(pauseMusic.length / 2)));
    pauseMusic.fadeIn(MUSIC_FADE_IN_TIME, 0, MUSIC_FINAL_VOLUME);
  }

  /**
   * Called when the game loses focus. Used to temporarily pause the sound.
   */
  public override function onFocusLost():Void
  {
    super.onFocusLost();
    if (Preferences.autoPause) pauseMusic.pause();
  }

  /**
   * Called when the game loses focus. Used to temporarily pause the sound.
   */
  public override function onFocus():Void
  {
    super.onFocus();
    if (Preferences.autoPause) pauseMusic.resume();
  }

  /**
   * Render the semi-transparent black background.
   */
  function buildBackground():Void
  {
    // Using state.bgColor causes bugs!
    background = new FunkinSprite(0, 0);
    background.makeSolidColor(camera.width, camera.height, FlxColor.BLACK);
    background.alpha = 0.0;
    background.scrollFactor.set(0, 0);
    background.updateHitbox();
    add(background);

    #if mobile
    pauseButton = FunkinSprite.createSparrow(0, 0, "pauseButton");
    pauseButton.animation.addByIndices('idle', 'pause', [0], "", 24, false);
    pauseButton.animation.addByIndices('hold', 'pause', [5], "", 24, false);
    pauseButton.animation.addByIndices('confirm', 'pause', [
      6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32
    ], "", 24, false);
    pauseButton.scale.set(0.8, 0.8);
    pauseButton.updateHitbox();
    pauseButton.animation.play("confirm");
    pauseButton.setPosition((FlxG.width - pauseButton.width) - 35, 35);

    pauseCircle = FunkinSprite.create(0, 0, 'pauseCircle');
    pauseCircle.scale.set(0.84, 0.8);
    pauseCircle.updateHitbox();
    pauseCircle.x = ((pauseButton.x + (pauseButton.width / 2)) - (pauseCircle.width / 2));
    pauseCircle.y = ((pauseButton.y + (pauseButton.height / 2)) - (pauseCircle.height / 2));
    pauseCircle.alpha = 0.1;

    add(pauseCircle);
    add(pauseButton);
    #end
  }

  /**
   * Render the metadata in the top right.
   */
  function buildMetadata():Void
  {
    metadata = new FlxTypedSpriteGroup<FlxText>();
    metadata.scrollFactor.set(0, 0);
    add(metadata);

    var metadataSong:FlxText = new FlxText(20,
      #if mobile (PlayState.instance?.isPracticeMode ?? false) ? camera.height - 185 : camera.height - 155 #else 15 #end,
      camera.width - Math.max(40, funkin.ui.FullScreenScaleMode.gameNotchSize.x), 'Song Name');
    metadataSong.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT);
    if (PlayState.instance?.currentChart != null)
    {
      metadataSong.text = '${PlayState.instance.currentChart.songName}';
    }
    metadataSong.scrollFactor.set(0, 0);
    metadata.add(metadataSong);

    metadataArtist = new FlxText(20, metadataSong.y + 32, camera.width - Math.max(40, funkin.ui.FullScreenScaleMode.gameNotchSize.x),
      'Artist: ${Constants.DEFAULT_ARTIST}');
    metadataArtist.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT);
    if (PlayState.instance?.currentChart != null)
    {
      metadataArtist.text = 'Artist: ${PlayState.instance.currentChart.songArtist}';
    }
    metadataArtist.scrollFactor.set(0, 0);
    metadata.add(metadataArtist);

    var metadataDifficulty:FlxText = new FlxText(20, metadataArtist.y + 32, camera.width - Math.max(40, funkin.ui.FullScreenScaleMode.gameNotchSize.x),
      'Difficulty: ');
    metadataDifficulty.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT);
    if (PlayState.instance?.currentDifficulty != null)
    {
      metadataDifficulty.text += PlayState.instance.currentDifficulty.replace('-', ' ').toTitleCase();
    }
    metadataDifficulty.scrollFactor.set(0, 0);
    metadata.add(metadataDifficulty);

    metadataDeaths = new FlxText(20, metadataDifficulty.y + 32, camera.width - Math.max(40, funkin.ui.FullScreenScaleMode.gameNotchSize.x),
      '${PlayState.instance?.deathCounter} Blue Balls');
    metadataDeaths.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT);
    metadataDeaths.scrollFactor.set(0, 0);
    metadata.add(metadataDeaths);

    metadataPractice = new FlxText(20, metadataDeaths.y + 32, camera.width - Math.max(40, funkin.ui.FullScreenScaleMode.gameNotchSize.x), 'PRACTICE MODE');
    metadataPractice.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT);
    metadataPractice.visible = PlayState.instance?.isPracticeMode ?? false;
    metadataPractice.scrollFactor.set(0, 0);
    metadata.add(metadataPractice);

    // Right side
    offsetText = new FlxText(20, metadataSong.y - 12, (camera.width + 10) - Math.max(40, funkin.ui.FullScreenScaleMode.gameNotchSize.x),
      'Global Offset: ${Preferences.globalOffset ?? 0}ms');
    offsetText.setFormat(Paths.font('vcr.ttf'), 16, FlxColor.WHITE, FlxTextAlign.RIGHT);
    offsetText.scrollFactor.set(0, 0);

    offsetTextInfo = new FlxText(20, offsetText.y + 16, (camera.width + 10) - Math.max(40, funkin.ui.FullScreenScaleMode.gameNotchSize.x),
      'Hold SHIFT-UP/DOWN,\nto change the offset.');
    offsetTextInfo.setFormat(Paths.font('vcr.ttf'), 16, FlxColor.WHITE, FlxTextAlign.RIGHT);
    offsetTextInfo.scrollFactor.set(0, 0);

    offsetText.y = FlxG.height - (offsetText.height + offsetText.height + 40);
    offsetTextInfo.y = offsetText.y + offsetText.height + 4;

    #if !mobile
    metadata.add(offsetText);
    metadata.add(offsetTextInfo);
    #end

    metadataArtist.alpha = 0;
    metadataPractice.alpha = 0;
    metadataSong.alpha = 0;
    metadataDifficulty.alpha = 0;
    metadataDeaths.alpha = 0;
    offsetText.alpha = 0;
    offsetTextInfo.alpha = 0;

    updateMetadataText();
  }

  var charterFadeTween:Null<FlxTween> = null;

  function startCharterTimer():Void
  {
    charterFadeTween = FlxTween.tween(metadataArtist, {alpha: 0.0}, CHARTER_FADE_DURATION,
      {
        startDelay: CHARTER_FADE_DELAY,
        ease: FlxEase.quartOut,
        onComplete: (_) -> {
          if (PlayState.instance?.currentChart != null)
          {
            metadataArtist.text = 'Charter: ${PlayState.instance.currentChart.charter ?? 'Unknown'}';
          }
          else
          {
            metadataArtist.text = 'Charter: ${Constants.DEFAULT_CHARTER}';
          }

          FlxTween.tween(metadataArtist, {alpha: 1.0}, CHARTER_FADE_DURATION,
            {
              ease: FlxEase.quartOut,
              onComplete: (_) -> {
                startArtistTimer();
              }
            });
        }
      });
  }

  function startArtistTimer():Void
  {
    charterFadeTween = FlxTween.tween(metadataArtist, {alpha: 0.0}, CHARTER_FADE_DURATION,
      {
        startDelay: CHARTER_FADE_DELAY,
        ease: FlxEase.quartOut,
        onComplete: (_) -> {
          if (PlayState.instance?.currentChart != null)
          {
            metadataArtist.text = 'Artist: ${PlayState.instance.currentChart.songArtist}';
          }
          else
          {
            metadataArtist.text = 'Artist: ${Constants.DEFAULT_ARTIST}';
          }

          FlxTween.tween(metadataArtist, {alpha: 1.0}, CHARTER_FADE_DURATION,
            {
              ease: FlxEase.quartOut,
              onComplete: (_) -> {
                startCharterTimer();
              }
            });
        }
      });
  }

  var dataFadeTimer = new FlxTimer();
  var hapticTimer = new FlxTimer();

  /**
   * Perform additional animations to transition the pause menu in when it is first displayed.
   */
  function transitionIn():Void
  {
    FlxTween.tween(background, {alpha: 0.6}, 0.8, {ease: FlxEase.quartOut});

    #if mobile
    HapticUtil.vibrate(0, 0.05, 0.5);

    pauseButton.animation.play("confirm");
    pauseCircle.scale.set(0.84 * 1.4, 0.8 * 1.4);
    pauseCircle.alpha = 0.4;
    FlxTween.tween(pauseCircle.scale, {x: 0.84 * 0.8, y: 0.8 * 0.8}, 0.4, {ease: FlxEase.backInOut});
    FlxTween.tween(pauseCircle, {alpha: 0}, 0.6, {ease: FlxEase.quartOut});

    hapticTimer.start(0.2, function(_) {
      HapticUtil.vibrate(0, 0.01, 0.5);
    });

    dataFadeTimer.start(0.3, function(_) {
      transitionMetadataIn();
      FlxTween.tween(pauseButton, {alpha: 0}, 0.6, {ease: FlxEase.quartOut});
    });
    #else
    transitionMetadataIn();
    #end
  }

  function transitionMetadataIn():Void
  {
    // Animate each element a little bit downwards.
    var delay:Float = 0.1;
    for (child in metadata.members)
    {
      FlxTween.tween(child, {alpha: 1, y: #if mobile child.y - 5 #else child.y + 5 #end}, 1.8, {ease: FlxEase.quartOut, startDelay: delay});
      delay += 0.1;
    }
  }

  // ===============
  // Input Handling
  // ===============
  var fastOffset:Bool = false;
  var lastOffsetPress:Float = 0;

  /**
   * Process user inputs every frame.
   */
  function handleInputs():Void
  {
    if (!allowInput) return;

    // early return here if we are modifying our offsets stuff w/ shift + up/down
    if (handleModifyingOffsets()) return;

    handleDebugInputs();

    if (controls.UI_UP_P)
    {
      changeSelection(-1);
    }
    if (controls.UI_DOWN_P)
    {
      changeSelection(1);
    }

    // we only want justOpened to be true for 1 single frame, when we first get into the pause menu substate
    // we early return here so we don't need to check `if (!justOpened)` everywhere
    if (justOpened)
    {
      justOpened = false;
      return;
    }

    handleTouchInputs();

    if (controls.ACCEPT_P && currentMenuEntries.length > 0)
    {
      currentMenuEntries[currentEntry].callback(this);
    }
    else if (controls.PAUSE_P)
    {
      resume(this);
    }
  }

  function handleTouchInputs():Void
  {
    #if FEATURE_TOUCH_CONTROLS
    if (!SwipeUtil.justSwipedAny && currentMenuEntries.length > 0)
    {
      for (i in 0...menuEntryText.members.length)
      {
        if (!TouchUtil.pressAction(menuEntryText.members[i], camera, false)) continue;

        if (i == currentEntry)
        {
          currentMenuEntries[currentEntry].callback(this);
          HapticUtil.vibrate(0, 0.05, 1);
          break;
        }

        changeSelection(i - currentEntry);
        HapticUtil.vibrate(0, 0.01, 0.5);

        break;
      }
    }
    #end
  }

  /**
   * used to both modify/change offsets, but also to early return so we don't interfere with other inputs while doing so
   * TODO: refactor to use state design pattern to handle inputs, see MainMenuState
   * @return Bool true if we are currently modifying our offsets (by holding shift and pressing UP or DOWN)
   */
  function handleModifyingOffsets():Bool
  {
    #if !mobile
    var offset:Int = Preferences.globalOffset ?? 0;
    if (FlxG.keys.pressed.SHIFT && (controls.UI_UP || controls.UI_DOWN))
    {
      lastOffsetPress += FlxG.elapsed;
      if (!fastOffset)
      {
        // If the last offset press was more than 0.5 seconds ago, reset the fast offset.
        if (lastOffsetPress > 0.5)
        {
          fastOffset = true;
          lastOffsetPress = 0;
        }

        if (controls.UI_UP_P || controls.UI_DOWN_P)
        {
          offset += (controls.UI_UP_P || controls.UI_UP) ? 1 : -1;

          offsetText.text = 'Global Offset: ${offset}ms';
        }
      }
      else
      {
        offset += (controls.UI_UP_P || controls.UI_UP) ? 1 : -1;

        offsetText.text = 'Global Offset: ${offset}ms';
      }

      if (offset > 1500) offset = 1500;
      if (offset < -1500) offset = -1500;

      Preferences.globalOffset = offset;

      return true;
    }
    else
    {
      // Reset the fast offset if the user is not holding SHIFT.
      fastOffset = false;
      lastOffsetPress = 0;
    }
    #end
    return false;
  }

  function handleDebugInputs():Void
  {
    #if FEATURE_DEBUG_FUNCTIONS
    // to pause the game and get screenshots easy, press H on pause menu!
    if (FlxG.keys.justPressed.H)
    {
      var visible = !metadata.visible;
      metadata.visible = visible;
      menuEntryText.visible = visible;
      background.visible = visible;
      this.bgColor = visible ? 0x99000000 : 0x00000000; // 60% or fully transparent black
    }
    #end
  }

  /**
   * Move the current selection up or down.
   * @param change The amount to change the selection by, with sign indicating direction.
   */
  function changeSelection(change:Int = 0):Void
  {
    var prevEntry:Int = currentEntry;
    currentEntry += change;

    if (#if FEATURE_TOUCH_CONTROLS !funkin.mobile.input.ControlsHandler.usingExternalInputDevice #else false #end)
    {
      if (currentEntry < 0) currentEntry = 0;
      if (currentEntry >= currentMenuEntries.length) currentEntry = currentMenuEntries.length - 1;
    }
    else
    {
      if (currentEntry < 0) currentEntry = currentMenuEntries.length - 1;
      if (currentEntry >= currentMenuEntries.length) currentEntry = 0;
    }

    if (currentEntry != prevEntry) FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4);

    for (entryIndex in 0...currentMenuEntries.length)
    {
      var isCurrent:Bool = entryIndex == currentEntry;

      var entry:PauseMenuEntry = currentMenuEntries[entryIndex];
      var text:AtlasText = entry.sprite;

      // Set the transparency.
      text.alpha = isCurrent ? 1.0 : 0.6;

      #if mobile
      // Set the position.
      if (isCurrent && currentEntry != prevEntry)
      {
        FlxTween.globalManager.cancelTweensOf(text);
        text.x = 165;
        FlxTween.tween(text, {x: 150}, 0.2, {ease: FlxEase.backInOut});
      }
      #else
      var targetX = FlxMath.remapToRange((entryIndex - currentEntry), 0, 1, 0, 1.3) * 20 + Math.max(90, funkin.ui.FullScreenScaleMode.gameNotchSize.x);
      var targetY = FlxMath.remapToRange((entryIndex - currentEntry), 0, 1, 0, 1.3) * 120 + (camera.height * 0.48);
      FlxTween.globalManager.cancelTweensOf(text);
      FlxTween.tween(text, {x: targetX, y: targetY}, 0.33, {ease: FlxEase.quartOut});
      #end
    }
  }

  // ===============
  // Menu Functions
  // ===============

  /**
   * Clear the current menu entries and regenerate them based on the current mode.
   * @param targetMode Optionally specify a mode to switch to before regenerating the menu.
   */
  function regenerateMenu(?targetMode:PauseMode):Void
  {
    // If targetMode is null, keep the current mode.
    if (targetMode == null) targetMode = this.currentMode;

    var previousMode:PauseMode = this.currentMode;
    this.currentMode = targetMode;

    resetSelection();
    chooseMenuEntries();
    clearAndAddMenuEntries();
    updateMetadataText();
    changeSelection();
  }

  /**
   * Reset the current selection to the first entry.
   */
  function resetSelection():Void
  {
    this.currentEntry = 0;
  }

  /**
   * Select which menu entries to display based on the current mode.
   */
  function chooseMenuEntries():Void
  {
    // Choose the correct menu entries.
    // NOTE: We clone the arrays to prevent modifications to the arrays from affecting the original.
    switch (this.currentMode)
    {
      case PauseMode.Standard:
        currentMenuEntries = PAUSE_MENU_ENTRIES_STANDARD.clone();
      case PauseMode.Charting:
        currentMenuEntries = PAUSE_MENU_ENTRIES_CHARTING.clone();
      case PauseMode.Difficulty:
        // Prepend the difficulties.
        var entries:Array<PauseMenuEntry> = [];
        if (PlayState.instance.currentChart != null)
        {
          var difficultiesInVariation = PlayState.instance.currentSong.listDifficulties(PlayState.instance.currentChart.variation, true);
          trace('DIFFICULTIES: ${difficultiesInVariation}');
          for (difficulty in difficultiesInVariation)
          {
            entries.push({text: difficulty.toTitleCase(), callback: (state) -> changeDifficulty(state, difficulty)});
          }
        }

        // Add the back button.
        currentMenuEntries = entries.concat(PAUSE_MENU_ENTRIES_DIFFICULTY.clone());
      case PauseMode.Conversation:
        currentMenuEntries = PAUSE_MENU_ENTRIES_CONVERSATION.clone();
      case PauseMode.Cutscene:
        currentMenuEntries = PAUSE_MENU_ENTRIES_VIDEO_CUTSCENE.clone();
    }
  }

  /**
   * Clear the `menuEntryText` group and render the current menu entries to it.
   * We first create the `menuEntryText` group if it doesn't already exist.
   */
  function clearAndAddMenuEntries():Void
  {
    if (menuEntryText == null)
    {
      menuEntryText = new FlxTypedSpriteGroup<AtlasText>();
      menuEntryText.scrollFactor.set(0, 0);
      add(menuEntryText);
    }
    menuEntryText.clear();

    // Render out the entries depending on the mode.
    var entryIndex:Int = 0;
    var toRemove = [];
    for (entry in currentMenuEntries)
    {
      if (entry == null || (entry.filter != null && !entry.filter()))
      {
        // Remove entries that should be hidden.
        toRemove.push(entry);
      }
      else
      {
        // Handle visible entries.
        #if mobile
        // var yPos:Float = (150 * entryIndex) + 100;

        // var yPos:Float = (140 * entryIndex) + 150;
        var yPos:Float = (105 * entryIndex) + 150;

        var text:AtlasText = new AtlasText(110, yPos, entry.text, AtlasFont.BOLD);
        text.scrollFactor.set(0, 0);
        text.alpha = 0;
        for (letter in text)
        {
          letter.width *= 1.2;
          letter.height *= 1.4;
        }
        menuEntryText.add(text);

        FlxTween.tween(text, {x: 150}, 0.4 * (entryIndex + 1), {ease: FlxEase.expoOut});

        entry.sprite = text;
        #else
        var yPos:Float = 70 * entryIndex + 30;
        var text:AtlasText = new AtlasText(0, yPos, entry.text, AtlasFont.BOLD);
        text.scrollFactor.set(0, 0);
        text.alpha = 0;
        for (letter in text)
        {
          letter.width *= 2;
          letter.height *= 2;
        }
        menuEntryText.add(text);

        entry.sprite = text;
        #end

        entryIndex++;
      }
    }
    for (entry in toRemove)
    {
      currentMenuEntries.remove(entry);
    }
  }

  // ===============
  // Metadata Functions
  // ===============

  /**
   * Update the values for the metadata text in the top right.
   */
  function updateMetadataText():Void
  {
    metadataPractice.visible = PlayState.instance?.isPracticeMode ?? false;

    #if mobile
    if (metadata.members[0].y != camera.height - 185 && metadataPractice.visible)
    {
      for (text in metadata)
      {
        text.y -= 30;
      }
    }
    #end

    switch (this.currentMode)
    {
      case Standard | Difficulty:
        metadataDeaths.text = '${PlayState.instance?.deathCounter} Blue Balls';
      case Charting:
        metadataDeaths.text = 'Chart Editor Preview';
      case Conversation:
        metadataDeaths.text = 'Dialogue Paused';
      case Cutscene:
        metadataDeaths.text = 'Video Paused';
    }
  }

  // ===============
  // Menu Callbacks
  // ===============

  /**
   * Close the pause menu and resume the game.
   * @param state The current PauseSubState.
   */
  static function resume(state:PauseSubState):Void
  {
    // Resume a paused video if it exists.
    VideoCutscene.resumeVideo();
    #if FEATURE_MOBILE_ADVERTISEMENTS
    AdMobUtil.removeBanner();
    #end
    state.close();
  }

  /**
   * Switch the pause menu to the indicated mode.
   * Create a callback from this using `.bind(_, targetMode)`.
   * @param state The current PauseSubState.
   * @param targetMode The mode to switch to.
   */
  static function switchMode(state:PauseSubState, targetMode:PauseMode):Void
  {
    state.regenerateMenu(targetMode);
  }

  /**
   * Switch the game's difficulty to the indicated difficulty, then resume the game.
   * @param state The current PauseSubState.
   * @param difficulty The difficulty to switch to.
   */
  static function changeDifficulty(state:PauseSubState, difficulty:String):Void
  {
    PlayState.instance.currentSong = SongRegistry.instance.fetchEntry(PlayState.instance.currentSong.id.toLowerCase(),
      {variation: PlayState.instance.currentChart.variation});

    // Reset campaign score when changing difficulty
    // So if you switch difficulty on the last song of a week you get a really low overall score.
    PlayStatePlaylist.campaignScore = 0;
    PlayStatePlaylist.campaignDifficulty = difficulty;
    PlayState.instance.previousDifficulty = PlayState.instance.currentDifficulty;
    PlayState.instance.currentDifficulty = PlayStatePlaylist.campaignDifficulty;

    FreeplayState.rememberedDifficulty = difficulty;

    PlayState.instance.needsReset = true;

    #if FEATURE_MOBILE_ADVERTISEMENTS
    if (AdMobUtil.PLAYING_COUNTER < AdMobUtil.MAX_BEFORE_AD) AdMobUtil.PLAYING_COUNTER++;

    if (AdMobUtil.PLAYING_COUNTER >= AdMobUtil.MAX_BEFORE_AD)
    {
      state.allowInput = false;

      AdMobUtil.loadInterstitial(function():Void {
        AdMobUtil.PLAYING_COUNTER = 0;

        AdMobUtil.removeBanner();

        state.allowInput = true;

        state.close();
      });
    }
    else
    {
      AdMobUtil.removeBanner();

      state.close();
    }
    #else
    state.close();
    #end
  }

  /**
   * Restart the current level, then resume the game.
   * @param state The current PauseSubState.
   */
  static function restartPlayState(state:PauseSubState):Void
  {
    PlayState.instance.needsReset = true;

    #if FEATURE_MOBILE_ADVERTISEMENTS
    if (AdMobUtil.PLAYING_COUNTER < AdMobUtil.MAX_BEFORE_AD) AdMobUtil.PLAYING_COUNTER++;

    if (AdMobUtil.PLAYING_COUNTER >= AdMobUtil.MAX_BEFORE_AD)
    {
      state.allowInput = false;

      AdMobUtil.loadInterstitial(function():Void {
        AdMobUtil.PLAYING_COUNTER = 0;

        AdMobUtil.removeBanner();

        state.allowInput = true;

        state.close();
      });
    }
    else
    {
      AdMobUtil.removeBanner();

      state.close();
    }
    #else
    state.close();
    #end
  }

  /**
   * Force the game into practice mode, then update the pause menu.
   * @param state The current PauseSubState.
   */
  static function enablePracticeMode(state:PauseSubState):Void
  {
    if (PlayState.instance == null) return;

    PlayState.instance.isPracticeMode = true;
    state.regenerateMenu();
  }

  /**
   * Restart the paused video cutscene, then resume the game.
   * @param state The current PauseSubState.
   */
  static function restartVideoCutscene(state:PauseSubState):Void
  {
    VideoCutscene.restartVideo();
    #if FEATURE_MOBILE_ADVERTISEMENTS
    AdMobUtil.removeBanner();
    #end
    state.close();
  }

  /**
   * Skip the paused video cutscene, then resume the game.
   * @param state The current PauseSubState.
   */
  static function skipVideoCutscene(state:PauseSubState):Void
  {
    VideoCutscene.finishVideo();
    #if FEATURE_MOBILE_ADVERTISEMENTS
    AdMobUtil.removeBanner();
    #end
    state.close();
  }

  /**
   * Restart the paused conversation, then resume the game.
   * @param state The current PauseSubState.
   */
  static function restartConversation(state:PauseSubState):Void
  {
    if (PlayState.instance?.currentConversation == null) return;

    PlayState.instance.currentConversation.resetConversation();
    #if FEATURE_MOBILE_ADVERTISEMENTS
    AdMobUtil.removeBanner();
    #end
    state.close();
  }

  /**
   * Skip the paused conversation, then resume the game.
   * @param state The current PauseSubState.
   */
  static function skipConversation(state:PauseSubState):Void
  {
    if (PlayState.instance?.currentConversation == null) return;

    PlayState.instance.currentConversation.skipConversation();
    #if FEATURE_MOBILE_ADVERTISEMENTS
    AdMobUtil.removeBanner();
    #end
    state.close();
  }

  /**
   * Quit the game and return to the main menu.
   * @param state The current PauseSubState.
   */
  static function quitToMenu(state:PauseSubState):Void
  {
    state.allowInput = false;

    PlayState.instance.deathCounter = 0;

    FlxTransitionableState.skipNextTransIn = true;
    FlxTransitionableState.skipNextTransOut = true;

    var targetState:funkin.ui.transition.stickers.StickerSubState->FlxState = (PlayStatePlaylist.isStoryMode) ? (sticker) ->
      new StoryMenuState(sticker) : (sticker) -> FreeplayState.build(sticker);

    // Do this AFTER because this resets the value of isStoryMode!
    if (PlayStatePlaylist.isStoryMode)
    {
      PlayStatePlaylist.reset();
    }

    var stickerPackId:Null<String> = PlayState.instance.currentChart.stickerPack;

    if (stickerPackId == null)
    {
      var playerCharacterId = PlayerRegistry.instance.getCharacterOwnerId(PlayState.instance.currentChart.characters.player);
      var playerCharacter = PlayerRegistry.instance.fetchEntry(playerCharacterId ?? Constants.DEFAULT_CHARACTER);

      if (playerCharacter != null)
      {
        stickerPackId = playerCharacter.getStickerPackID();
      }
    }

    #if FEATURE_MOBILE_ADVERTISEMENTS
    AdMobUtil.removeBanner();
    #end

    state.openSubState(new funkin.ui.transition.stickers.StickerSubState({targetState: targetState, stickerPack: stickerPackId}));
  }

  /**
   * Quit the game and return to the chart editor.
   * @param state The current PauseSubState.
   */
  @:access(funkin.play.PlayState)
  static function quitToChartEditor(state:PauseSubState):Void
  {
    #if FEATURE_MOBILE_ADVERTISEMENTS
    AdMobUtil.removeBanner();
    #end
    // This should come first because the sounds list gets cleared!
    PlayState.instance?.forEachPausedSound(s -> s.destroy());
    state.close();
    FlxG.sound.music?.pause(); // Don't reset song position!
    PlayState.instance?.vocals?.pause();
    PlayState.instance?.close(); // This only works because PlayState is a substate!
  }
}

/**
 * Which set of options the pause menu should display.
 */
enum PauseMode
{
  /**
   * The menu displayed when the player pauses the game during a song.
   */
  Standard;

  /**
   * The menu displayed when the player pauses the game during a song while in charting mode.
   */
  Charting;

  /**
   * The menu displayed when the player moves to change the game's difficulty.
   */
  Difficulty;

  /**
   * The menu displayed when the player pauses the game during a conversation.
   */
  Conversation;

  /**
   * The menu displayed when the player pauses the game during a video cutscene.
   */
  Cutscene;
}

/**
 * Represents a single entry in the pause menu.
 */
typedef PauseMenuEntry =
{
  /**
   * The text to display for this entry.
   * TODO: Implement localization.
   */
  var text:String;

  /**
   * The callback to execute when the user selects this entry.
   */
  var callback:PauseSubState->Void;

  /**
   * If this returns true, the entry will be displayed. If it returns false, the entry will be hidden.
   */
  var ?filter:Void->Bool;

  // Instance-specific properties

  /**
   * The text object currently displaying this entry.
   */
  var ?sprite:AtlasText;
};
