package funkin.save;

import flixel.util.FlxSave;
import funkin.save.migrator.SaveDataMigrator;
import thx.semver.Version;
import funkin.input.Controls.Device;
import funkin.save.migrator.RawSaveData_v1_0_0;
import funkin.save.migrator.SaveDataMigrator;
import funkin.ui.debug.charting.ChartEditorState.ChartEditorLiveInputStyle;
import funkin.ui.debug.charting.ChartEditorState.ChartEditorTheme;
import thx.semver.Version;

@:nullSafety
@:forward(volume, mute)
abstract Save(RawSaveData)
{
  // Version 2.0.2 adds attributes to `optionsChartEditor`, that should return default values if they are null.
  public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.2";
  public static final SAVE_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x";

  // We load this version's saves from a new save path, to maintain SOME level of backwards compatibility.
  static final SAVE_PATH:String = 'FunkinCrew';
  static final SAVE_NAME:String = 'Funkin';

  static final SAVE_PATH_LEGACY:String = 'ninjamuffin99';
  static final SAVE_NAME_LEGACY:String = 'funkin';

  public static function load():Void
  {
    trace("[SAVE] Loading save...");

    // Bind save data.
    loadFromSlot(1);
  }

  public static function get():Save
  {
    return FlxG.save.data;
  }

  /**
   * Constructing a new Save will load the default values.
   */
  public function new()
  {
    this =
      {
        version: Save.SAVE_DATA_VERSION,

        volume: 1.0,
        mute: false,

        api:
          {
            newgrounds:
              {
                sessionId: null,
              }
          },
        scores:
          {
            // No saved scores.
            levels: [],
            songs: [],
          },
        options:
          {
            // Reasonable defaults.
            naughtyness: true,
            downscroll: false,
            flashingLights: true,
            zoomCamera: true,
            debugDisplay: false,
            autoPause: true,

            controls:
              {
                // Leave controls blank so defaults are loaded.
                p1:
                  {
                    keyboard: {},
                    gamepad: {},
                  },
                p2:
                  {
                    keyboard: {},
                    gamepad: {},
                  },
              },
          },

        mods:
          {
            // No mods enabled.
            enabledMods: [],
            modOptions: [],
          },

        optionsChartEditor:
          {
            // Reasonable defaults.
            previousFiles: [],
            noteQuant: 3,
            chartEditorLiveInputStyle: ChartEditorLiveInputStyle.None,
            theme: ChartEditorTheme.Light,
            playtestStartTime: false,
            downscroll: false,
            metronomeVolume: 1.0,
            hitsoundVolumePlayer: 1.0,
            hitsoundVolumeOpponent: 1.0,
            themeMusic: true
          },
      };
  }

  /**
   * NOTE: Modifications will not be saved without calling `Save.flush()`!
   */
  public var options(get, never):SaveDataOptions;

  function get_options():SaveDataOptions
  {
    return this.options;
  }

  /**
   * NOTE: Modifications will not be saved without calling `Save.flush()`!
   */
  public var modOptions(get, never):Map<String, Dynamic>;

  function get_modOptions():Map<String, Dynamic>
  {
    return this.mods.modOptions;
  }

  /**
   * The current session ID for the logged-in Newgrounds user, or null if the user is cringe.
   */
  public var ngSessionId(get, set):Null<String>;

  function get_ngSessionId():Null<String>
  {
    return this.api.newgrounds.sessionId;
  }

  function set_ngSessionId(value:Null<String>):Null<String>
  {
    this.api.newgrounds.sessionId = value;
    flush();
    return this.api.newgrounds.sessionId;
  }

  public var enabledModIds(get, set):Array<String>;

  function get_enabledModIds():Array<String>
  {
    return this.mods.enabledMods;
  }

  function set_enabledModIds(value:Array<String>):Array<String>
  {
    this.mods.enabledMods = value;
    flush();
    return this.mods.enabledMods;
  }

  public var chartEditorPreviousFiles(get, set):Array<String>;

  function get_chartEditorPreviousFiles():Array<String>
  {
    if (this.optionsChartEditor.previousFiles == null) this.optionsChartEditor.previousFiles = [];

    return this.optionsChartEditor.previousFiles;
  }

  function set_chartEditorPreviousFiles(value:Array<String>):Array<String>
  {
    // Set and apply.
    this.optionsChartEditor.previousFiles = value;
    flush();
    return this.optionsChartEditor.previousFiles;
  }

  public var chartEditorHasBackup(get, set):Bool;

  function get_chartEditorHasBackup():Bool
  {
    if (this.optionsChartEditor.hasBackup == null) this.optionsChartEditor.hasBackup = false;

    return this.optionsChartEditor.hasBackup;
  }

  function set_chartEditorHasBackup(value:Bool):Bool
  {
    // Set and apply.
    this.optionsChartEditor.hasBackup = value;
    flush();
    return this.optionsChartEditor.hasBackup;
  }

  public var chartEditorNoteQuant(get, set):Int;

  function get_chartEditorNoteQuant():Int
  {
    if (this.optionsChartEditor.noteQuant == null) this.optionsChartEditor.noteQuant = 3;

    return this.optionsChartEditor.noteQuant;
  }

  function set_chartEditorNoteQuant(value:Int):Int
  {
    // Set and apply.
    this.optionsChartEditor.noteQuant = value;
    flush();
    return this.optionsChartEditor.noteQuant;
  }

  public var chartEditorLiveInputStyle(get, set):ChartEditorLiveInputStyle;

  function get_chartEditorLiveInputStyle():ChartEditorLiveInputStyle
  {
    if (this.optionsChartEditor.chartEditorLiveInputStyle == null) this.optionsChartEditor.chartEditorLiveInputStyle = ChartEditorLiveInputStyle.None;

    return this.optionsChartEditor.chartEditorLiveInputStyle;
  }

  function set_chartEditorLiveInputStyle(value:ChartEditorLiveInputStyle):ChartEditorLiveInputStyle
  {
    // Set and apply.
    this.optionsChartEditor.chartEditorLiveInputStyle = value;
    flush();
    return this.optionsChartEditor.chartEditorLiveInputStyle;
  }

  public var chartEditorDownscroll(get, set):Bool;

  function get_chartEditorDownscroll():Bool
  {
    if (this.optionsChartEditor.downscroll == null) this.optionsChartEditor.downscroll = false;

    return this.optionsChartEditor.downscroll;
  }

  function set_chartEditorDownscroll(value:Bool):Bool
  {
    // Set and apply.
    this.optionsChartEditor.downscroll = value;
    flush();
    return this.optionsChartEditor.downscroll;
  }

  public var chartEditorPlaytestStartTime(get, set):Bool;

  function get_chartEditorPlaytestStartTime():Bool
  {
    if (this.optionsChartEditor.playtestStartTime == null) this.optionsChartEditor.playtestStartTime = false;

    return this.optionsChartEditor.playtestStartTime;
  }

  function set_chartEditorPlaytestStartTime(value:Bool):Bool
  {
    // Set and apply.
    this.optionsChartEditor.playtestStartTime = value;
    flush();
    return this.optionsChartEditor.playtestStartTime;
  }

  public var chartEditorTheme(get, set):ChartEditorTheme;

  function get_chartEditorTheme():ChartEditorTheme
  {
    if (this.optionsChartEditor.theme == null) this.optionsChartEditor.theme = ChartEditorTheme.Light;

    return this.optionsChartEditor.theme;
  }

  function set_chartEditorTheme(value:ChartEditorTheme):ChartEditorTheme
  {
    // Set and apply.
    this.optionsChartEditor.theme = value;
    flush();
    return this.optionsChartEditor.theme;
  }

  public var chartEditorMetronomeVolume(get, set):Float;

  function get_chartEditorMetronomeVolume():Float
  {
    if (this.optionsChartEditor.metronomeVolume == null) this.optionsChartEditor.metronomeVolume = 1.0;

    return this.optionsChartEditor.metronomeVolume;
  }

  function set_chartEditorMetronomeVolume(value:Float):Float
  {
    // Set and apply.
    this.optionsChartEditor.metronomeVolume = value;
    flush();
    return this.optionsChartEditor.metronomeVolume;
  }

  public var chartEditorHitsoundVolumePlayer(get, set):Float;

  function get_chartEditorHitsoundVolumePlayer():Float
  {
    if (this.optionsChartEditor.hitsoundVolumePlayer == null) this.optionsChartEditor.hitsoundVolumePlayer = 1.0;

    return this.optionsChartEditor.hitsoundVolumePlayer;
  }

  function set_chartEditorHitsoundVolumePlayer(value:Float):Float
  {
    // Set and apply.
    this.optionsChartEditor.hitsoundVolumePlayer = value;
    flush();
    return this.optionsChartEditor.hitsoundVolumePlayer;
  }

  public var chartEditorHitsoundVolumeOpponent(get, set):Float;

  function get_chartEditorHitsoundVolumeOpponent():Float
  {
    if (this.optionsChartEditor.hitsoundVolumeOpponent == null) this.optionsChartEditor.hitsoundVolumeOpponent = 1.0;

    return this.optionsChartEditor.hitsoundVolumeOpponent;
  }

  function set_chartEditorHitsoundVolumeOpponent(value:Float):Float
  {
    // Set and apply.
    this.optionsChartEditor.hitsoundVolumeOpponent = value;
    flush();
    return this.optionsChartEditor.hitsoundVolumeOpponent;
  }

  public var chartEditorThemeMusic(get, set):Bool;

  function get_chartEditorThemeMusic():Bool
  {
    if (this.optionsChartEditor.themeMusic == null) this.optionsChartEditor.themeMusic = true;

    return this.optionsChartEditor.themeMusic;
  }

  function set_chartEditorThemeMusic(value:Bool):Bool
  {
    // Set and apply.
    this.optionsChartEditor.themeMusic = value;
    flush();
    return this.optionsChartEditor.themeMusic;
  }

  public var chartEditorPlaybackSpeed(get, set):Float;

  function get_chartEditorPlaybackSpeed():Float
  {
    if (this.optionsChartEditor.playbackSpeed == null) this.optionsChartEditor.playbackSpeed = 1.0;

    return this.optionsChartEditor.playbackSpeed;
  }

  function set_chartEditorPlaybackSpeed(value:Float):Float
  {
    // Set and apply.
    this.optionsChartEditor.playbackSpeed = value;
    flush();
    return this.optionsChartEditor.playbackSpeed;
  }

  /**
   * Return the score the user achieved for a given level on a given difficulty.
   *
   * @param levelId The ID of the level/week.
   * @param difficultyId The difficulty to check.
   * @return A data structure containing score, judgement counts, and accuracy. Returns `null` if no score is saved.
   */
  public function getLevelScore(levelId:String, difficultyId:String = 'normal'):Null<SaveScoreData>
  {
    var level = this.scores.levels.get(levelId);
    if (level == null)
    {
      level = [];
      this.scores.levels.set(levelId, level);
    }

    return level.get(difficultyId);
  }

  /**
   * Apply the score the user achieved for a given level on a given difficulty.
   */
  public function setLevelScore(levelId:String, difficultyId:String, score:SaveScoreData):Void
  {
    var level = this.scores.levels.get(levelId);
    if (level == null)
    {
      level = [];
      this.scores.levels.set(levelId, level);
    }
    level.set(difficultyId, score);

    flush();
  }

  public function isLevelHighScore(levelId:String, difficultyId:String = 'normal', score:SaveScoreData):Bool
  {
    var level = this.scores.levels.get(levelId);
    if (level == null)
    {
      level = [];
      this.scores.levels.set(levelId, level);
    }

    var currentScore = level.get(difficultyId);
    if (currentScore == null)
    {
      return true;
    }

    return score.score > currentScore.score;
  }

  public function hasBeatenLevel(levelId:String, ?difficultyList:Array<String>):Bool
  {
    if (difficultyList == null)
    {
      difficultyList = ['easy', 'normal', 'hard'];
    }
    for (difficulty in difficultyList)
    {
      var score:Null<SaveScoreData> = getLevelScore(levelId, difficulty);
      // TODO: Do we need to check accuracy/score here?
      if (score != null)
      {
        return true;
      }
    }
    return false;
  }

  /**
   * Return the score the user achieved for a given song on a given difficulty.
   *
   * @param songId The ID of the song.
   * @param difficultyId The difficulty to check.
   * @return A data structure containing score, judgement counts, and accuracy. Returns `null` if no score is saved.
   */
  public function getSongScore(songId:String, difficultyId:String = 'normal'):Null<SaveScoreData>
  {
    var song = this.scores.songs.get(songId);
    if (song == null)
    {
      song = [];
      this.scores.songs.set(songId, song);
    }
    return song.get(difficultyId);
  }

  /**
   * Apply the score the user achieved for a given song on a given difficulty.
   */
  public function setSongScore(songId:String, difficultyId:String, score:SaveScoreData):Void
  {
    var song = this.scores.songs.get(songId);
    if (song == null)
    {
      song = [];
      this.scores.songs.set(songId, song);
    }
    song.set(difficultyId, score);

    flush();
  }

  /**
   * Is the provided score data better than the current high score for the given song?
   * @param songId The song ID to check.
   * @param difficultyId The difficulty to check.
   * @param score The score to check.
   * @return Whether the score is better than the current high score.
   */
  public function isSongHighScore(songId:String, difficultyId:String = 'normal', score:SaveScoreData):Bool
  {
    var song = this.scores.songs.get(songId);
    if (song == null)
    {
      song = [];
      this.scores.songs.set(songId, song);
    }

    var currentScore = song.get(difficultyId);
    if (currentScore == null)
    {
      return true;
    }

    return score.score > currentScore.score;
  }

  /**
   * Has the provided song been beaten on one of the listed difficulties?
   * @param songId The song ID to check.
   * @param difficultyList The difficulties to check. Defaults to `easy`, `normal`, and `hard`.
   * @return Whether the song has been beaten on any of the listed difficulties.
   */
  public function hasBeatenSong(songId:String, ?difficultyList:Array<String>):Bool
  {
    if (difficultyList == null)
    {
      difficultyList = ['easy', 'normal', 'hard'];
    }
    for (difficulty in difficultyList)
    {
      var score:Null<SaveScoreData> = getSongScore(songId, difficulty);
      // TODO: Do we need to check accuracy/score here?
      if (score != null)
      {
        return true;
      }
    }
    return false;
  }

  public function getControls(playerId:Int, inputType:Device):SaveControlsData
  {
    switch (inputType)
    {
      case Keys:
        return (playerId == 0) ? this.options.controls.p1.keyboard : this.options.controls.p2.keyboard;
      case Gamepad(_):
        return (playerId == 0) ? this.options.controls.p1.gamepad : this.options.controls.p2.gamepad;
    }
  }

  public function hasControls(playerId:Int, inputType:Device):Bool
  {
    var controls = getControls(playerId, inputType);
    var controlsFields = Reflect.fields(controls);
    return controlsFields.length > 0;
  }

  public function setControls(playerId:Int, inputType:Device, controls:SaveControlsData):Void
  {
    switch (inputType)
    {
      case Keys:
        if (playerId == 0)
        {
          this.options.controls.p1.keyboard = controls;
        }
        else
        {
          this.options.controls.p2.keyboard = controls;
        }
      case Gamepad(_):
        if (playerId == 0)
        {
          this.options.controls.p1.gamepad = controls;
        }
        else
        {
          this.options.controls.p2.gamepad = controls;
        }
    }

    flush();
  }

  public function isCharacterUnlocked(characterId:String):Bool
  {
    switch (characterId)
    {
      case 'bf':
        return true;
      case 'pico':
        return hasBeatenLevel('weekend1');
      default:
        trace('Unknown character ID: ' + characterId);
        return true;
    }
  }

  /**
   * Call this to make sure the save data is written to disk.
   */
  public function flush():Void
  {
    FlxG.save.flush();
  }

  /**
   * If you set slot to `2`, it will load an independe
   * @param slot
   */
  static function loadFromSlot(slot:Int):Void
  {
    trace("[SAVE] Loading save from slot " + slot + "...");

    FlxG.save.bind('$SAVE_NAME${slot}', SAVE_PATH);

    if (FlxG.save.isEmpty())
    {
      trace('[SAVE] Save data is empty, checking for legacy save data...');
      var legacySaveData = fetchLegacySaveData();
      if (legacySaveData != null)
      {
        trace('[SAVE] Found legacy save data, converting...');
        FlxG.save.mergeData(SaveDataMigrator.migrateFromLegacy(legacySaveData));
      }
    }
    else
    {
      trace('[SAVE] Loaded save data.');
      FlxG.save.mergeData(SaveDataMigrator.migrate(FlxG.save.data));
    }

    trace('[SAVE] Done loading save data.');
    trace(FlxG.save.data);
  }

  static function fetchLegacySaveData():Null<RawSaveData_v1_0_0>
  {
    trace("[SAVE] Checking for legacy save data...");
    var legacySave:FlxSave = new FlxSave();
    legacySave.bind(SAVE_NAME_LEGACY, SAVE_PATH_LEGACY);
    if (legacySave?.data == null)
    {
      trace("[SAVE] No legacy save data found.");
      return null;
    }
    else
    {
      trace("[SAVE] Legacy save data found.");
      trace(legacySave.data);
      return cast legacySave.data;
    }
  }
}

/**
 * An anonymous structure containingg all the user's save data.
 */
typedef RawSaveData =
{
  // Flixel save data.
  var volume:Float;
  var mute:Bool;

  /**
   * A semantic versioning string for the save data format.
   */
  @:jcustomparse(funkin.data.DataParse.semverVersion)
  @:jcustomwrite(funkin.data.DataWrite.semverVersion)
  var version:Version;

  var api:SaveApiData;

  /**
   * The user's saved scores.
   */
  var scores:SaveHighScoresData;

  /**
   * The user's preferences.
   */
  var options:SaveDataOptions;

  var mods:SaveDataMods;

  /**
   * The user's preferences specific to the Chart Editor.
   */
  var optionsChartEditor:SaveDataChartEditorOptions;
};

typedef SaveApiData =
{
  var newgrounds:SaveApiNewgroundsData;
}

typedef SaveApiNewgroundsData =
{
  var sessionId:Null<String>;
}

/**
 * An anoymous structure containing options about the user's high scores.
 */
typedef SaveHighScoresData =
{
  /**
   * Scores for each level (or week).
   */
  var levels:SaveScoreLevelsData;

  /**
   * Scores for individual songs.
   */
  var songs:SaveScoreSongsData;
};

typedef SaveDataMods =
{
  var enabledMods:Array<String>;
  var modOptions:Map<String, Dynamic>;
}

/**
 * Key is the level ID, value is the SaveScoreLevelData.
 */
typedef SaveScoreLevelsData = Map<String, SaveScoreDifficultiesData>;

/**
 * Key is the song ID, value is the data for each difficulty.
 */
typedef SaveScoreSongsData = Map<String, SaveScoreDifficultiesData>;

/**
 * Key is the difficulty ID, value is the score.
 */
typedef SaveScoreDifficultiesData = Map<String, SaveScoreData>;

/**
 * An individual score. Contains the score, accuracy, and count of each judgement hit.
 */
typedef SaveScoreData =
{
  /**
   * The score achieved.
   */
  var score:Int;

  /**
   * The count of each judgement hit.
   */
  var tallies:SaveScoreTallyData;

  /**
   * The accuracy percentage.
   */
  var accuracy:Float;
}

typedef SaveScoreTallyData =
{
  var sick:Int;
  var good:Int;
  var bad:Int;
  var shit:Int;
  var missed:Int;
  var combo:Int;
  var maxCombo:Int;
  var totalNotesHit:Int;
  var totalNotes:Int;
}

/**
 * An anonymous structure containing all the user's options and preferences for the main game.
 * Every time you add a new option, it needs to be added here.
 */
typedef SaveDataOptions =
{
  /**
   * Whether some particularly fowl language is displayed.
   * @default `true`
   */
  var naughtyness:Bool;

  /**
   * If enabled, the strumline is at the bottom of the screen rather than the top.
   * @default `false`
   */
  var downscroll:Bool;

  /**
   * If disabled, flashing lights in the main menu and other areas will be less intense.
   * @default `true`
   */
  var flashingLights:Bool;

  /**
   * If disabled, the camera bump synchronized to the beat.
   * @default `false`
   */
  var zoomCamera:Bool;

  /**
   * If enabled, an FPS and memory counter will be displayed even if this is not a debug build.
   * @default `false`
   */
  var debugDisplay:Bool;

  /**
   * If enabled, the game will automatically pause when tabbing out.
   * @default `true`
   */
  var autoPause:Bool;

  var controls:
    {
      var p1:
        {
          var keyboard:SaveControlsData;
          var gamepad:SaveControlsData;
        };
      var p2:
        {
          var keyboard:SaveControlsData;
          var gamepad:SaveControlsData;
        };
    };
};

/**
 * An anonymous structure containing a specific player's bound keys.
 * Each key is an action name and each value is an array of keycodes.
 *
 * If a keybind is `null`, it needs to be reinitialized to the default.
 * If a keybind is `[]`, it is UNBOUND by the user and should not be rebound.
 */
typedef SaveControlsData =
{
  /**
   * Keybind for navigating in the menu.
   * @default `Up Arrow`
   */
  var ?UI_UP:Array<Int>;

  /**
   * Keybind for navigating in the menu.
   * @default `Left Arrow`
   */
  var ?UI_LEFT:Array<Int>;

  /**
   * Keybind for navigating in the menu.
   * @default `Right Arrow`
   */
  var ?UI_RIGHT:Array<Int>;

  /**
   * Keybind for navigating in the menu.
   * @default `Down Arrow`
   */
  var ?UI_DOWN:Array<Int>;

  /**
   * Keybind for hitting notes.
   * @default `A` and `Left Arrow`
   */
  var ?NOTE_LEFT:Array<Int>;

  /**
   * Keybind for hitting notes.
   * @default `W` and `Up Arrow`
   */
  var ?NOTE_UP:Array<Int>;

  /**
   * Keybind for hitting notes.
   * @default `S` and `Down Arrow`
   */
  var ?NOTE_DOWN:Array<Int>;

  /**
   * Keybind for hitting notes.
   * @default `D` and `Right Arrow`
   */
  var ?NOTE_RIGHT:Array<Int>;

  /**
   * Keybind for continue/OK in menus.
   * @default `Enter` and `Space`
   */
  var ?ACCEPT:Array<Int>;

  /**
   * Keybind for back/cancel in menus.
   * @default `Escape`
   */
  var ?BACK:Array<Int>;

  /**
   * Keybind for pausing the game.
   * @default `Escape`
   */
  var ?PAUSE:Array<Int>;

  /**
   * Keybind for advancing cutscenes.
   * @default `Z` and `Space` and `Enter`
   */
  var ?CUTSCENE_ADVANCE:Array<Int>;

  /**
   * Keybind for skipping a cutscene.
   * @default `Escape`
   */
  var ?CUTSCENE_SKIP:Array<Int>;

  /**
   * Keybind for increasing volume.
   * @default `Plus`
   */
  var ?VOLUME_UP:Array<Int>;

  /**
   * Keybind for decreasing volume.
   * @default `Minus`
   */
  var ?VOLUME_DOWN:Array<Int>;

  /**
   * Keybind for muting/unmuting volume.
   * @default `Zero`
   */
  var ?VOLUME_MUTE:Array<Int>;

  /**
   * Keybind for restarting a song.
   * @default `R`
   */
  var ?RESET:Array<Int>;
}

/**
 * An anonymous structure containing all the user's options and preferences, specific to the Chart Editor.
 */
typedef SaveDataChartEditorOptions =
{
  /**
   * Whether the Chart Editor created a backup the last time it closed.
   * Prompt the user to load it, then set this back to `false`.
   * @default `false`
   */
  var ?hasBackup:Bool;

  /**
   * Previous files opened in the Chart Editor.
   * @default `[]`
   */
  var ?previousFiles:Array<String>;

  /**
   * Note snapping level in the Chart Editor.
   * @default `3`
   */
  var ?noteQuant:Int;

  /**
   * Live input style in the Chart Editor.
   * @default `ChartEditorLiveInputStyle.None`
   */
  var ?chartEditorLiveInputStyle:ChartEditorLiveInputStyle;

  /**
   * Theme in the Chart Editor.
   * @default `ChartEditorTheme.Light`
   */
  var ?theme:ChartEditorTheme;

  /**
   * Downscroll in the Chart Editor.
   * @default `false`
   */
  var ?downscroll:Bool;

  /**
   * Metronome volume in the Chart Editor.
   * @default `1.0`
   */
  var ?metronomeVolume:Float;

  /**
   * Hitsound volume (player) in the Chart Editor.
   * @default `1.0`
   */
  var ?hitsoundVolumePlayer:Float;

  /**
   * Hitsound volume (opponent) in the Chart Editor.
   * @default `1.0`
   */
  var ?hitsoundVolumeOpponent:Float;

  /**
   * If true, playtest songs from the current position in the Chart Editor.
   * @default `false`
   */
  var ?playtestStartTime:Bool;

  /**
   * Theme music in the Chart Editor.
   * @default `true`
   */
  var ?themeMusic:Bool;

  /**
   * Instrumental volume in the Chart Editor.
   * @default `1.0`
   */
  var ?instVolume:Float;

  /**
   * Voices volume in the Chart Editor.
   * @default `1.0`
   */
  var ?voicesVolume:Float;

  /**
   * Playback speed in the Chart Editor.
   * @default `1.0`
   */
  var ?playbackSpeed:Float;
};