package funkin.ui.options;

import funkin.ui.MenuList.MenuTypedList;
import funkin.ui.TextMenuList.TextMenuItem;
import funkin.util.GRhythmUtil;
import funkin.mobile.ui.FunkinBackButton;
#if mobile
import funkin.mobile.ui.FunkinHitbox;
import funkin.mobile.ui.FunkinHitbox.FunkinHitboxControlSchemes;
import funkin.mobile.input.ControlsHandler;
import funkin.util.TouchUtil;
#end
import funkin.input.PreciseInputManager;
import funkin.audio.FunkinSound;
import funkin.play.notes.Strumline;
import funkin.play.notes.NoteSprite;
import funkin.graphics.FunkinCamera;
import funkin.graphics.FunkinSprite;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.notestyle.NoteStyleRegistry;
import funkin.play.notes.notestyle.NoteStyle;
import funkin.ui.options.items.NumberPreferenceItem;
import haxe.Int64;
import flixel.FlxSprite;
import flixel.text.FlxText;
import flixel.FlxObject;
import flixel.util.FlxColor;
import flixel.math.FlxMath;
import flixel.tweens.FlxEase;
import flixel.tweens.FlxTween;
import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;

/*
  ArrowData is a structure that holds the sprite and beat of an arrow.
  @param sprite The sprite of the arrow.
  @param beat The beat of the arrow.
 */
typedef ArrowData =
{
  var sprite:FunkinSprite;
  // var debugText:FlxText;
  var beat:Float;
  var direction:Int; // 0 = left, 1 = down, 2 = up, 3 = right
};

class OffsetMenu extends Page<OptionsState.OptionsMenuPageName>
{
  static final BPM:Int = 100;

  // Page<OptionsState.OptionsMenuPageName> stuff
  var offsetItem:NumberPreferenceItem;
  var items:TextMenuList;
  var preferenceItems:FlxTypedSpriteGroup<FlxSprite>;
  var backButton:FunkinBackButton;

  // Background
  var blackRect:FlxSprite;

  // Text for the jump-in message and count
  var jumpInText:FlxText;
  var countText:FlxText;

  // Elements for the offset calibration (receptor, arrows, strumline, etc)
  var arrows:Array<ArrowData> = [];
  var receptor:FunkinSprite;
  var testStrumline:Strumline;

  // Camera for the menu
  var menuCamera:FunkinCamera;
  // Variable to check if we're calibrating or testing
  var calibrating:Bool = false;

  // Variables for the offset calibration
  var appliedOffsetLerp:Float = 0;
  var savedOffset:Int = 0;
  var tempOffset:Int = 0;

  // Variables for transitioning between states
  var lerped:Float = 0;
  var shouldOffset:Int = 0;
  var offsetLerp:Float = 0;
  var scaleModifier:Float = 1;

  // Variables for keeping time and beat
  var localConductor:Conductor;
  var arrowBeat:Float = 0;

  // Variables for differences and consistency functionality
  var _gotMad:Bool = false;
  var differences:Array<Float> = [];

  var msPerBeat(get, never):Float;

  // The milliseconds per beat, calculated from the BPM.
  function get_msPerBeat():Float
  {
    return 60000 / BPM;
  }

  /**
   * Key press inputs which have been received but not yet processed.
   * These are encoded with an OS timestamp, so we can account for input latency.
  **/
  var inputPressQueue:Array<PreciseInputEvent> = [];

  /**
   * Key release inputs which have been received but not yet processed.
   * These are encoded with an OS timestamp, so we can account for input latency.
  **/
  var inputReleaseQueue:Array<PreciseInputEvent> = [];

  /*
    Creates an arrow at the specified beat.
    The arrow will be positioned below the screen and will move up to the receptor.
    @param beat The beat at which to create the arrow.
   */
  public function createArrow(beat:Float):Void
  {
    var arrow = new FunkinSprite(0, 0);
    arrow.loadGraphic(Paths.image('latencyArrow'));
    arrow.origin.set(0.5, 0.5);
    arrow.setPosition(FlxG.width / 2, FlxG.height + arrow.height); // Below the screen
    arrow.updateHitbox();
    arrow.cameras = [menuCamera];
    add(arrow);

    /*var debugText = new FlxText(0, 0);
      debugText.setFormat(Paths.font('vcr.ttf'), 16, FlxColor.WHITE, FlxTextAlign.CENTER);
      debugText.text = 'Beat: ' + beat;
      debugText.setBorderStyle(FlxTextBorderStyle.OUTLINE, FlxColor.BLACK);
          add(debugText); */

    arrows.push(
      {
        sprite: arrow,
        /*debugText: debugText,*/
        beat: beat,
        direction: 0
      });
  }

  /*
    Creates a directed arrow at the specified beat and direction.
    The direction can be 0 (left), 1 (down), 2 (up), or 3 (right).
    @param beat The beat at which to create the arrow.
    @param direction The direction of the arrow.
   */
  public function createDirectedArrow(beat:Float, direction:Int):Void
  {
    var arrow = new FunkinSprite(0, 0);
    arrow.loadGraphic(Paths.image('latencyArrow'));
    arrow.origin.set(0.5, 0.5);
    arrow.setPosition(FlxG.width / 2, FlxG.height + arrow.height); // Below the screen
    arrow.updateHitbox();
    add(arrow);

    arrows.push({sprite: arrow, beat: beat, direction: direction});
  }

  /*
    Gets the arrow at the specified beat.
    @param beat The beat at which to get the arrow.
    @return The ArrowData object containing the sprite and beat, or null if no arrow is found.
   */
  public function getArrowAtBeat(beat:Float):ArrowData
  {
    for (arrow in arrows)
    {
      if (arrow.beat == beat) return arrow;
    }
    return null;
  }

  /*
    Gets the closest arrow to the specified beat.
    This is used to find the arrow that is closest to the current time.
    @param beat The beat at which to find the closest arrow.
    @return The ArrowData object containing the sprite and beat of the closest arrow.
   */
  public function getClosestArrowAtBeat(beat:Float):ArrowData
  {
    var closest:ArrowData = null;
    var closestDiff:Float = 1000000; // A large number to start with

    for (arrow in arrows)
    {
      var diff:Float = arrow.beat - beat;
      // trace('Checking arrow at beat: ' + arrow.beat + ' (diff: ' + diff + ')');
      if (diff < closestDiff)
      {
        closestDiff = diff;
        closest = arrow;
      }
    }

    // trace('Closest arrow at beat: ' + (closest != null ? closest.beat : 0) + ' (diff: ' + closestDiff + ')');

    return closest;
  }

  public function new()
  {
    super();

    localConductor = new Conductor();
    localConductor.forceBPM(100);

    menuCamera = new FunkinCamera('prefMenu');
    FlxG.cameras.add(menuCamera, false);
    menuCamera.bgColor = 0x0;

    camera = menuCamera;

    blackRect = new FlxSprite(0, 0);
    blackRect.makeGraphic(FlxG.width, FlxG.height, FlxColor.BLACK);
    blackRect.alpha = 0;
    blackRect.scrollFactor.set(0, 0);
    add(blackRect);

    /*debugBeatText = new FlxText(0, 0);
      debugBeatText.setFormat(Paths.font('vcr.ttf'), 16, FlxColor.WHITE, FlxTextAlign.LEFT);
      debugBeatText.setBorderStyle(FlxTextBorderStyle.OUTLINE, FlxColor.BLACK);
      debugBeatText.setPosition(10, 10);
      debugBeatText.scrollFactor.set(0, 0);
      add(debugBeatText);

          debugBeatText.alpha = 0; */

    receptor = new FunkinSprite(0, 0);
    receptor.loadGraphic(Paths.image('latencyReceptor'));
    receptor.origin.set(0.5, 0.5);
    add(receptor);

    var noteStyle:NoteStyle = NoteStyleRegistry.instance.fetchDefault();

    testStrumline = new Strumline(noteStyle, true);
    // center
    testStrumline.setPosition(FlxG.width / 2, FlxG.height / 2);
    testStrumline.x -= testStrumline.width / 2;
    testStrumline.scrollFactor.set(0, 0);
    add(testStrumline);

    testStrumline.cameras = [menuCamera];

    testStrumline.conductorInUse = localConductor;
    testStrumline.zIndex = 1001;
    for (strum in testStrumline)
    {
      strum.alpha = 0;
    }

    receptor.alpha = 0;
    receptor.centerOffsets();
    receptor.scale.set(0, 0);
    receptor.centerOrigin();
    receptor.updateHitbox();

    jumpInText = new FlxText(0, 0);
    jumpInText.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.CENTER);
    jumpInText.setBorderStyle(FlxTextBorderStyle.OUTLINE, FlxColor.BLACK, 4);
    add(jumpInText);

    receptor.cameras = [menuCamera];
    jumpInText.cameras = [menuCamera];

    // below receptor

    countText = new FlxText(0, 0);
    countText.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.CENTER);
    countText.setBorderStyle(FlxTextBorderStyle.OUTLINE, FlxColor.BLACK, 4);
    add(countText);

    jumpInText.alpha = 0;
    jumpInText.setPosition(FlxG.width / 2, 150);
    jumpInText.scrollFactor.set(0, 0);

    countText.text = '';
    countText.alpha = 0;
    countText.setPosition(FlxG.width / 2, 600);
    countText.scrollFactor.set(0, 0);

    countText.cameras = [menuCamera];

    add(items = new TextMenuList());
    add(preferenceItems = new FlxTypedSpriteGroup<FlxSprite>());

    offsetItem = createPrefItemNumber('Offset (Global)', 'Offset (Global)', function(value:Float) {
      Preferences.globalOffset = Std.int(value);
    }, null, Preferences.globalOffset, -1500, 1500, 1.0, 2, 5);
    createButtonItem('Reset Offset', function() {
      Preferences.globalOffset = 0;
      offsetItem.currentValue = Preferences.globalOffset;
    });
    createButtonItem('Offset Calibration', function() {
      // Reset calibration state and start another one.

      testStrumline.alpha = 0;

      testStrumline.clean();
      testStrumline.noteData = [];
      testStrumline.nextNoteIndex = 0;
      @:privateAccess
      if (OptionsState.instance.optionsCodex.currentPage != this) return;

      jumpInText.text = 'Press any key to the beat!\nThe arrow will start to sync to the receptor.';
      #if mobile
      jumpInText.text = 'Tap to the beat!\nThe arrow will start to sync to the receptor.';
      #end

      jumpInText.y = 100;

      countText.text = 'Current Offset: 0ms';

      calibrating = true;
      MenuTypedList.pauseInput = true;
      OptionsState.instance.drumsBG.pause();
      OptionsState.instance.drumsBG.time = FlxG.sound.music.time;
      OptionsState.instance.drumsBG.resume();
      OptionsState.instance.drumsBG.fadeIn(1, 0, 1);
      canExit = false;
      differences = [];
      offsetLerp = 0;
      savedOffset = Preferences.globalOffset;
      Preferences.globalOffset = 0; // We save the offset and set it to 0 so the player can recalibrate.
      shouldOffset = 1;
      tempOffset = 0;
      appliedOffsetLerp = 0;

      arrowBeat = Math.floor(localConductor.currentBeatTime) + 4;
      receptor.angle = 0;

      _gotMad = false;
    });
    createButtonItem('Test', function() {
      // Reset testing state and start another one.
      // We do not reset the offset here, so the player can test their current offset.
      @:privateAccess
      if (OptionsState.instance.optionsCodex.currentPage != this) return;

      shouldOffset = 1;
      testStrumline.clean();
      testStrumline.noteData = [];
      testStrumline.nextNoteIndex = 0;

      OptionsState.instance.drumsBG.pause();
      OptionsState.instance.drumsBG.time = FlxG.sound.music.time;
      OptionsState.instance.drumsBG.resume();
      localConductor.update(FlxG.sound.music.time, true);

      var floored = Math.floor(localConductor.currentBeatTime);
      arrowBeat = floored - (floored % 4);
      arrowBeat += 4;
      _lastDirection = 0;

      var diffBeats = Math.floor(arrowBeat - localConductor.currentBeatTime);

      trace('Prestart arrowBeat: ' + arrowBeat);

      if (diffBeats < 4) arrowBeat += (arrowBeat % 4) + 4; // Ensure we have at least 4 beats to test.

      trace('Testing strumline at beat: ' + arrowBeat + ' diff: ' + diffBeats);

      jumpInText.text = 'Hit the notes as they come in!';
      #if mobile
      if (OptionsState.instance.hitbox != null) OptionsState.instance.hitbox.visible = true;
      if (!ControlsHandler.usingExternalInputDevice)
      {
        final amplification:Float = (FlxG.width / FlxG.height) / (FlxG.initialWidth / FlxG.initialHeight);
        final playerStrumlineScale:Float = ((FlxG.height / FlxG.width) * 1.95) * amplification;
        final playerNoteSpacing:Float = ((FlxG.height / FlxG.width) * 2.8) * amplification;

        testStrumline.strumlineScale.set(playerStrumlineScale, playerStrumlineScale);
        testStrumline.setNoteSpacing(playerNoteSpacing);
        testStrumline.width *= 2;

        var height = testStrumline.strumlineNotes.members[0].height;

        testStrumline.x = (FlxG.width - testStrumline.width) / 2 + Constants.STRUMLINE_X_OFFSET;
        testStrumline.y = (FlxG.height - height) * 0.95 - Constants.STRUMLINE_Y_OFFSET;
        testStrumline.y -= 10;
      }
      else
      {
        if (testStrumline != null)
        {
          testStrumline.destroy();
          remove(testStrumline);
        }

        testStrumline = new Strumline(noteStyle, true);
        // center
        testStrumline.setPosition(FlxG.width / 2, FlxG.height / 2);
        testStrumline.x -= testStrumline.width / 2;
        testStrumline.scrollFactor.set(0, 0);
        add(testStrumline);
      }
      #end
      MenuTypedList.pauseInput = true;
      OptionsState.instance.drumsBG.fadeIn(1, 0, 1);
      canExit = false;
      differences = [];

      jumpInText.y = 350;

      #if mobile
      if (ControlsHandler.usingExternalInputDevice)
      {
      #end
        var height = testStrumline.strumlineNotes.members[0].height;
        testStrumline.y = Preferences.downscroll ? FlxG.height - (height + 45) - Constants.STRUMLINE_Y_OFFSET : (height / 2) - Constants.STRUMLINE_Y_OFFSET;
        if (Preferences.downscroll) jumpInText.y = FlxG.height - 425;
        testStrumline.isDownscroll = Preferences.downscroll;
      #if mobile
      }
      else
      {
        jumpInText.y = FlxG.height - 425;
      }
      #end
    });
    PreciseInputManager.instance.onInputPressed.add(onKeyPress);
    PreciseInputManager.instance.onInputReleased.add(onKeyRelease);

    // camFollow = new FlxObject(FlxG.width / 2, 0, 140, 70);
    // if (items != null) camFollow.y = items.selectedItem.y;

    // menuCamera.follow(camFollow, null, 0.085);
    // var margin = 160;
    // menuCamera.deadzone.set(0, margin, menuCamera.width, menuCamera.height - margin * 2);
    // menuCamera.minScrollY = 0;

    // items.onChange.add(function(selected) {
    //  camFollow.y = selected.y;
    // });
    backButton = new FunkinBackButton(FlxG.width - 230, FlxG.height - 200, FlxColor.WHITE, handleMobileExit);
    #if FEATURE_TOUCH_CONTROLS // We do this here because we want to animate the back button (on Mobile), but we don't want it on Desktop.
    add(backButton);
    #end
  }

  /**
     * Callback executed when one of the note keys is pressed.
     */
  function onKeyPress(event:PreciseInputEvent):Void
  {
    // Do the minimal possible work here.
    inputPressQueue.push(event);
  }

  /**
     * Callback executed when one of the note keys is released.
     */
  function onKeyRelease(event:PreciseInputEvent):Void
  {
    // Do the minimal possible work here.
    inputReleaseQueue.push(event);
  }

  // Exits the calibration and resets the offset.
  public function exitCalibration(cancel:Bool):Void
  {
    backButton.enabled = false;
    shouldOffset = -1;
    #if mobile
    if (OptionsState.instance.hitbox != null) OptionsState.instance.hitbox.visible = false;
    #end
    tempOffset = 0;
    if (cancel)
    {
      if (calibrating) Preferences.globalOffset = savedOffset;
      #if !mobile
      // mobile would play this twice
      FunkinSound.playOnce(Paths.sound('cancelMenu'));
      #end
    }
    else
      FunkinSound.playOnce(Paths.sound('confirmMenu'));
    offsetItem.currentValue = Preferences.globalOffset;
    OptionsState.instance.drumsBG.fadeOut(1, 0);
  }

  // Handles the exit for mobile devices.
  public function handleMobileExit():Void
  {
    if (shouldOffset == 1) exitCalibration(true);
    else if (shouldOffset == 0) exit();
  }

  // Returns the average of the differences in milliseconds.
  // Average is the sum of all differences divided by the number of differences.
  public function getAverage():Float
  {
    if (differences.length == 0) return 0;

    var avg:Float = 0;
    for (i in 0...differences.length)
    {
      avg += differences[i];
    }
    avg /= differences.length;

    return avg;
  }

  // Returns the consistency of the differences.
  // Consistency is the average of the squared differences from the mean.
  public function getConsistency():Float
  {
    if (differences.length == 0) return 0;

    var avg:Float = getAverage();

    var variance:Float = 0;
    for (i in 0...differences.length)
    {
      variance += Math.pow(differences[i] - avg, 2);
    }
    variance /= differences.length;

    return Math.sqrt(variance);
  }

  var _offsetLerpTime:Float = 0;
  var _lastOffset:Float = 0;
  var _lastDirection:Int = 0;

  /* Adds a difference in milliseconds to the list.
      If there are more than 4 differences, it calculates the average and sets the global offset.
      This is used for calibrating the offset based on user input.
      @param ms The difference in milliseconds to add.
      @see Preferences.globalOffset
     */
  public function addDifference(ms:Float):Void
  {
    differences.push(ms);

    if (differences.length > 2 && differences.length % 2 != 0 && calibrating)
    {
      var avg:Float = getAverage();
      tempOffset = Std.int(avg);
      _lastOffset = appliedOffsetLerp;
      _offsetLerpTime = 0;
    }
  }

  var _lastBeat:Float = 0;
  var _lastTime:Float = 0;

  override function update(elapsed:Float):Void
  {
    super.update(elapsed);
    localConductor.update(localConductor.songPosition + elapsed * 1000, false);

    var b:Float = localConductor.currentBeatTime;

    // Restart logic
    if (FlxG.sound.music.time < _lastTime)
    {
      localConductor.update(FlxG.sound.music.time, !calibrating);
      b = localConductor.currentBeatTime;

      // Update arrows to be the correct distance away from the receptor.
      var lastArrowBeat:Float = 0;
      for (i in 0...arrows.length)
      {
        var arrow:ArrowData = arrows[i];
        var beatDiff:Float = arrow.beat - _lastBeat;

        arrow.beat = b + beatDiff;
        lastArrowBeat = arrow.beat;
      }
      if (calibrating)
      {
        arrowBeat = lastArrowBeat;
      }
      else
        arrowBeat = 4;

      testStrumline.clean();
      testStrumline.noteData = [];
      testStrumline.nextNoteIndex = 0;
      trace('Restarting conductor');

      _lastTime = FlxG.sound.music.time;
      return;
    }

    _lastBeat = b;

    // Resync logic
    var diff:Float = Math.abs((FlxG.sound.music.time + localConductor.combinedOffset) - localConductor.songPosition);
    var diffBg:Float = Math.abs(FlxG.sound.music.time - OptionsState.instance.drumsBG.time);
    if (diff > 50 || diffBg > 50)
    {
      trace('Resyncing conductor: ' + (diff > diffBg ? diff : diffBg) + 'ms difference');

      // If the difference is greater than 50ms, we resync the conductor.
      localConductor.update(FlxG.sound.music.time, true);
      OptionsState.instance.drumsBG.pause();
      OptionsState.instance.drumsBG.time = FlxG.sound.music.time;
      OptionsState.instance.drumsBG.resume();
      b = localConductor.currentBeatTime;
      _lastBeat = b;
    }

    _lastTime = FlxG.sound.music.time;

    // Back logic
    if (controls.BACK_P && shouldOffset == 1)
    {
      exitCalibration(true);
      return;
    }

    // Calibration logic
    if (shouldOffset == 1 && calibrating)
    {
      // Lerp our offset
      if (_offsetLerpTime < 1) _offsetLerpTime += elapsed * 2;
      else
        _offsetLerpTime = 1;

      appliedOffsetLerp = FlxMath.lerp(_lastOffset, tempOffset, _offsetLerpTime);

      countText.text = 'Current Offset: ' + Std.int(appliedOffsetLerp) + 'ms';

      var toRemove:Array<ArrowData> = [];
      var _lastArrowBeat:Float = 0;
      // Update arrows
      for (i in 0...arrows.length)
      {
        var arrow:ArrowData = arrows[i];

        var beatOffset:Float = appliedOffsetLerp / msPerBeat;

        var ms:Float = arrow.beat * msPerBeat;
        var offset:Float = GRhythmUtil.getNoteY(ms + appliedOffsetLerp, 2, false, localConductor);
        arrow.sprite.y = receptor.y + offset - (arrow.sprite.height / 2);
        arrow.sprite.x = receptor.x - (arrow.sprite.width / 2);

        // arrow.debugText.text = 'Beat: ' + arrow.beat;
        // arrow.debugText.setPosition(arrow.sprite.x + arrow.sprite.width, arrow.sprite.y - 20);

        if ((arrow.beat + beatOffset) < b - 0.25)
        {
          arrow.sprite.alpha -= elapsed * 5;
        }

        if (arrow.beat == _lastArrowBeat || arrow.sprite.alpha <= 0)
        {
          toRemove.push(arrow);
          arrow.sprite.kill();
          continue;
        }
        _lastArrowBeat = arrow.beat;
      }

      // Remove arrows that are marked for removal.
      for (arrow in toRemove)
      {
        // trace("Removing arrow at beat: " + arrow.beat);
        arrows.remove(arrow);
      }

      while (b >= arrowBeat - 1)
      {
        // trace("Spawning arrow at beat: " + arrowBeat);
        // Create a new arrow at the next beat division.
        arrowBeat = (arrowBeat - (arrowBeat % 2)) + 2;
        var nextBeat:Float = arrowBeat;
        createArrow(nextBeat);
      }

      // Hit a note (calibration)
      if (FlxG.keys.justPressed.ANY #if FEATURE_TOUCH_CONTROLS || TouchUtil.justPressed #end)
      {
        var arrow:ArrowData = getClosestArrowAtBeat(b);

        var closestBeat:Float = Math.round(b);
        var diff:Float = closestBeat - b;
        var ms:Float = diff * msPerBeat;

        if (arrow != null) // eric sees this and goes "OMG NULL REF!!!!"
        {
          var beatOffset:Float = appliedOffsetLerp / msPerBeat;

          var arrowDiff:Float = (arrow.beat + beatOffset) - b;

          if (Math.abs(arrowDiff) < 0.25)
          {
            arrow.sprite.alpha = 0;
            arrow.sprite.kill();
            // arrow.debugText.kill();
            arrows.remove(arrow);
          }
        }

        var consistency:Float = getConsistency();

        if (consistency > 80 && differences.length > 4)
        {
          jumpInText.text = 'Try to be a little more consistent with your timing!';
          differences = [];
          tempOffset = 0;
          appliedOffsetLerp = 0;
          _gotMad = true;
          return;
        }

        addDifference(ms);

        if (differences.length >= 16)
        {
          jumpInText.text = 'Calibration complete!';
          Preferences.globalOffset = tempOffset;
          exitCalibration(false);
          return;
        }

        if (!_gotMad)
        {
          if (Math.abs(ms + tempOffset) < 45) jumpInText.text = 'Great job, keep going!';
          else
            jumpInText.text = 'Nice job, keep going!';
        }

        jumpInText.text += '\n' + differences.length + '/16';

        _gotMad = false;

        scaleModifier = 0.75;
      }
    }
    // Testing logic
    else if (shouldOffset == 1)
    {
      // If we are not calibrating, we are just testing the strumline.

      processInputQueue();

      // trace(b + ' - ' + arrowBeat);

      while (b >= arrowBeat - 2 && b < 124)
      {
        // trace("Spawning arrow at beat: " + arrowBeat);
        // Create a new arrow at the next beat division.
        arrowBeat = arrowBeat + 1;
        var data:SongNoteData = new SongNoteData(arrowBeat * msPerBeat, _lastDirection, 0, null, null);
        testStrumline.addNoteData(data, false);

        // Create a jump (double note) every 8 beats to visually indicate first beat - requested by Hundrec
        if (Math.floor(arrowBeat % 8) == 0)
        {
          var data:SongNoteData = new SongNoteData(arrowBeat * msPerBeat, 2, 0, null, null);
          testStrumline.addNoteData(data, false);
        }

        _lastDirection = (_lastDirection + 1) % 4; // Cycle through directions 0-3
      }
      if (b >= 124 && _lastDirection != 0) _lastDirection = 0; // reset direction on loop
    }
    // Remove arrows and what not for when we are exiting calibration/testing
    else
    {
      var toRemove:Array<ArrowData> = [];
      for (i in 0...arrows.length)
      {
        var arrow:ArrowData = arrows[i];
        arrow.sprite.alpha -= elapsed * 5;
        if (arrow.sprite.alpha <= 0)
        {
          arrow.sprite.kill();
          // arrow.debugText.kill();
          toRemove.push(arrow);
        }
      }

      // Remove arrows that are marked for removal.
      for (arrow in toRemove)
      {
        arrows.remove(arrow);
      }
    }
    // Transitioning logic (animations and what not)
    if (lerped < 1) lerped += elapsed / 2;
    else if (lerped > 1) lerped = 1;

    if (shouldOffset == 1)
    {
      offsetLerp += elapsed / 2;
      if (offsetLerp >= 1) offsetLerp = 1;
    }
    else if (shouldOffset == -1)
    {
      offsetLerp -= elapsed / 3;
      if (offsetLerp <= 0) // We're exiting the calibration OR testing state
      {
        backButton.enabled = true;
        canExit = true;
        calibrating = false;
        MenuTypedList.pauseInput = false;
        offsetLerp = 0;
        shouldOffset = 0;
      }
    }

    blackRect.alpha = FlxMath.lerp(0, 0.5, FlxEase.cubeInOut(lerped));

    var yLerp = FlxMath.lerp(-480, 100, FlxEase.cubeInOut(lerped));
    var xLerp = FlxMath.lerp(0, FlxG.width, FlxEase.cubeInOut(offsetLerp));

    // center
    var recW = receptor.width;
    var recH = receptor.height;
    jumpInText.x = FlxG.width / 2 - (jumpInText.width / 2);
    countText.x = FlxG.width / 2 - (countText.width / 2);

    receptor.x = FlxG.width / 2 - (recW / 2);
    receptor.y = FlxG.height / 2 - (recH / 2);

    jumpInText.alpha = FlxMath.lerp(0, 1, FlxEase.cubeInOut(offsetLerp));

    if (calibrating)
    {
      receptor.alpha = FlxMath.lerp(0, 1, FlxEase.cubeInOut(offsetLerp));
      // debugBeatText.alpha = FlxMath.lerp(0, 1, FlxEase.cubeInOut(offsetLerp));
      countText.alpha = FlxMath.lerp(0, 1, FlxEase.cubeInOut(offsetLerp));
    }
    else
    {
      testStrumline.alpha = FlxMath.lerp(0, 1, FlxEase.cubeInOut(offsetLerp));
      backButton.y = FlxMath.lerp(FlxG.height - 200, 50, FlxEase.cubeInOut(offsetLerp));
    }

    if (scaleModifier < 1)
    {
      // trace("scaleModifier: " + scaleModifier);
      scaleModifier += elapsed / 2;
      if (scaleModifier >= 1) scaleModifier = 1;
    }

    receptor.scale.x = FlxMath.lerp(0, 1, FlxEase.cubeInOut(offsetLerp)) * scaleModifier;
    receptor.scale.y = FlxMath.lerp(0, 1, FlxEase.cubeInOut(offsetLerp)) * scaleModifier;

    // Update alpha and note window (canHit)
    for (note in testStrumline.notes.members)
    {
      if (note == null) continue;

      GRhythmUtil.processWindow(note, true, localConductor);
      note.alpha = FlxMath.lerp(0, 1, FlxEase.cubeInOut(offsetLerp));
    }

    /*debugBeatText.x = receptor.x + receptor.width * 2;
        debugBeatText.y = receptor.y - 20;

            debugBeatText.text = 'Beat: ' + b; */

    // receptor.angle += angleVel * elapsed;

    var ind = 0;
    // Indent the selected item.
    items.forEach(function(daItem:TextMenuItem) {
      // Initializing thy text width (if thou text present)
      var thyTextWidth:Int = 0;
      switch (Type.typeof(daItem))
      {
        case TClass(NumberPreferenceItem):
          var numPref:NumberPreferenceItem = cast(daItem, NumberPreferenceItem);
          thyTextWidth = numPref.lefthandText.getWidth();

          numPref.lefthandText.x = xLerp + (FlxG.width / 2) - ((thyTextWidth + daItem.atlasText.getWidth() + 20) / 2);
          numPref.lefthandText.y = yLerp + ((120 * ind) + 30);
          daItem.x = numPref.lefthandText.x + thyTextWidth + 20;
        default:
          daItem.x = xLerp + (FlxG.width / 2) - daItem.atlasText.getWidth() / 2;
      }

      daItem.y = yLerp + ((120 * ind) + 30);
      ind++;
    });
  }

  function hitNote(note:NoteSprite, input:PreciseInputEvent):Void
  {
    var inputLatencyNs:Int64 = PreciseInputManager.getCurrentTimestamp() - input.timestamp;
    var inputLatencyMs:Float = inputLatencyNs.toFloat() / Constants.NS_PER_MS;

    var diff:Float = note.noteData.time - localConductor.songPosition;

    // trace('Input latency: ' + inputLatencyMs + 'ms (diff: ' + diff + 'ms)');

    var totalDiff:Float = diff;
    if (totalDiff < 0) totalDiff = diff + inputLatencyMs;
    else
      totalDiff = diff - inputLatencyMs;

    var noteDiff:Int = Std.int(totalDiff);

    addDifference(noteDiff);

    if (noteDiff == 0)
    {
      // \n to signify a line break (because the original text has 3 lines)
      jumpInText.text = 'Perfect!\n';
    }
    else
    {
      jumpInText.text = noteDiff > 0 ? 'Early!\n' + noteDiff + 'ms' : 'Late!\n' + noteDiff + 'ms';
    }

    jumpInText.text += '\nAvg: ' + Std.int(getAverage()) + 'ms';

    testStrumline.hitNote(note);
  }

  /**
     * PreciseInputEvents are put into a queue between update() calls,
     * and then processed here.
     */
  function processInputQueue():Void
  {
    if (inputPressQueue.length + inputReleaseQueue.length == 0 || shouldOffset != 1) return;

    var notesInRange:Array<NoteSprite> = testStrumline.getNotesMayHit();

    var notesByDirection:Array<Array<NoteSprite>> = [[], [], [], []];

    for (note in notesInRange)
      notesByDirection[note.direction].push(note);

    while (inputPressQueue.length > 0)
    {
      var input:PreciseInputEvent = inputPressQueue.shift();

      testStrumline.pressKey(input.noteDirection, input.keyCode);

      var notesInDirection:Array<NoteSprite> = notesByDirection[input.noteDirection];

      // trace('Processing input: ' + input.noteDirection + ' with ' + notesInDirection.length + ' notes in range.');

      if (notesInDirection.length == 0)
      {
        testStrumline.playPress(input.noteDirection);
      }
      else
      {
        // Choose the first note, deprioritizing low priority notes.
        var targetNote:Null<NoteSprite> = notesInDirection.find((note) -> !note.lowPriority);
        if (targetNote == null) targetNote = notesInDirection[0];
        if (targetNote == null) continue;

        hitNote(targetNote, input);
        notesInDirection.remove(targetNote);

        // Play the strumline animation.
        testStrumline.playConfirm(input.noteDirection);
      }
    }

    while (inputReleaseQueue.length > 0)
    {
      var input:PreciseInputEvent = inputReleaseQueue.shift();

      // Play the strumline animation.
      testStrumline.playStatic(input.noteDirection);

      testStrumline.releaseKey(input.noteDirection, input.keyCode);
    }

    testStrumline.noteVibrations.tryNoteVibration();
  }

  // Creates a button item with a callback.
  function createButtonItem(name:String, callback:Void->Void):Void
  {
    var item = items.createItem(funkin.ui.FullScreenScaleMode.gameNotchSize.x, (120 * items.length) + 30, name, BOLD, callback);
    items.addItem(name, item);
  }

  // Creates a preference item with a number input.
  function createPrefItemNumber(prefName:String, prefDesc:String, onChange:Float->Void, ?valueFormatter:Float->String, defaultValue:Int, min:Int, max:Int,
      step:Float = 0.1, precision:Int, dragStepMultiplier:Float = 1):NumberPreferenceItem
  {
    var item = new NumberPreferenceItem(funkin.ui.FullScreenScaleMode.gameNotchSize.x, (120 * items.length) + 30, prefName, defaultValue, min, max, step,
      precision, onChange, valueFormatter, dragStepMultiplier);
    items.addItem(prefName, item);
    preferenceItems.add(item.lefthandText);
    return item;
  }

  override public function destroy()
  {
    MenuTypedList.pauseInput = false;
    exitCalibration(true);
    super.destroy();
  }
}
