UI/OptionsMenuController.cs
namespace BlockParty;

/// <summary>
/// Shared options-menu view-model. Drives the "Show Timer" toggle, Screenshake and Restart Button Delay sliders, the
/// volume rows (Overall / SFX / Music) and the GAMEPAD Vibration row, plus a BACK row, with full selector-cube parity to
/// the title menu — keyboard (Up/Down to move, Left/Right to nudge the focused slider or flip the
/// toggle, Confirm to flip the toggle / activate BACK, Back to close) AND mouse (hover to focus a
/// row, drag a slider, click the checkbox, click BACK). The same controller backs both the main
/// <see cref="OptionsStage"/> and the in-game options overlay so behaviour is identical.
///
/// Changing a value applies it immediately (volumes to <see cref="Audio"/>, vibration to
/// <see cref="Haptics"/>) and marks the settings dirty; the save is debounced so dragging/holding
/// doesn't hit the disk every frame, and <see cref="Close"/> flushes it on exit.
/// </summary>
public sealed class OptionsMenuController
{
	public enum Row { ShowTimer, Screenshake, RestartButtonDelay, Overall, Sfx, Music, Vibration, Back }
	public const int RowCount = (int)Row.Back + 1;
	public const int BackRow = (int)Row.Back;

	public int SelectedRow { get; private set; }
	public float SelectorPos => _selector.Pos;
	public int SelectorFrame => _selector.Frame;

	/// <summary>Invoked when BACK is activated (Confirm/click) or the Back action is pressed. The
	/// owner decides what closing means (return to title / resume the game).</summary>
	public System.Action OnBack { get; set; }

	private readonly MenuSelectorAnim _selector = new();
	private readonly NavRepeater _nav = new();   // W/S hold-to-repeat between rows, shared UI-wide

	// Slider movement per keypress, and the held-direction auto-repeat cadence (slider-space 0..100).
	private const float NUDGE_STEP = 2f;
	private const float REPEAT_DELAY = 0.35f;
	private const float KEY_REPEAT_RATE = 0.04f;
	private int _repeatDir;
	private float _repeatTimer;
	private float _sfxPreviewTimer;

	// Debounced save: flush a short time after the last change so a drag doesn't spam the disk.
	private const float SAVE_DEBOUNCE = 0.4f;
	private bool _saveDirty;
	private float _saveTimer;

	public void Tick( float dt )
	{
		if ( _sfxPreviewTimer > 0f )
			_sfxPreviewTimer -= dt;

		// W/S move between rows with hold-to-repeat (a held key keeps stepping).
		int step = _nav.Tick( dt, InputState.NavUp, InputState.NavDown );
		if ( step != 0 ) Navigate( step );

		HandleNudge( dt );

		if ( InputState.ConfirmJust )
		{
			if ( SelectedRow == BackRow ) Activate();
			else if ( SelectedRow == (int)Row.ShowTimer ) ToggleShowTimer();
		}
		if ( InputState.BackJust )
		{
			Audio.PlaySfx( SfxType.MenuStart );
			Haptics.MenuConfirm();
			Close();
		}

		_selector.Tick( dt, SelectedRow );

		if ( _saveDirty )
		{
			_saveTimer -= dt;
			if ( _saveTimer <= 0f )
			{
				Settings.Save();
				_saveDirty = false;
			}
		}
	}

	/// <summary>Vertical navigation between rows; wobble at the list ends.</summary>
	private void Navigate( int delta )
	{
		SelectedRow = MenuNav.Step( SelectedRow, delta, RowCount, _selector );
	}

	/// <summary>Left/Right nudge of the focused slider, with auto-repeat while held.</summary>
	private void HandleNudge( float dt )
	{
		if ( SelectedRow == BackRow ) { _repeatDir = 0; return; }

		// Toggle rows are boolean, not sliders: Left = off, Right = on (no hold-to-repeat).
		if ( SelectedRow == (int)Row.ShowTimer )
		{
			if ( InputState.LeftJust ) SetShowTimer( false );
			else if ( InputState.RightJust ) SetShowTimer( true );
			_repeatDir = 0;
			return;
		}

		int dir = 0;
		if ( InputState.LeftJust ) dir = -1;
		else if ( InputState.RightJust ) dir = 1;

		if ( dir != 0 )
		{
			Nudge( dir );
			// Master/SFX rows preview via their setters (PlaySfxPreview). The other sliders don't,
			// so give them an explicit blip on the initial press (the per-step haptic tick from Nudge
			// is itself the vibration-strength preview — turning it down weakens the ticks).
			if ( SelectedRow == (int)Row.Screenshake || SelectedRow == (int)Row.RestartButtonDelay || SelectedRow == (int)Row.Music || SelectedRow == (int)Row.Vibration )
				Audio.PlaySfx( SfxType.MenuBlip );
			_repeatDir = dir;
			_repeatTimer = REPEAT_DELAY;
			return;
		}

		bool held = (_repeatDir < 0 && InputState.Left) || (_repeatDir > 0 && InputState.Right);
		if ( _repeatDir != 0 && held )
		{
			_repeatTimer -= dt;
			if ( _repeatTimer <= 0f )
			{
				Nudge( _repeatDir );
				_repeatTimer = KEY_REPEAT_RATE;
			}
		}
		else
		{
			_repeatDir = 0;
		}
	}

	private void Nudge( int dir )
	{
		float step = dir * NUDGE_STEP;
		switch ( (Row)SelectedRow )
		{
			case Row.Screenshake: SetScreenshake( Settings.Current.Screenshake + step ); break;
			case Row.RestartButtonDelay: SetRestartButtonDelay( Settings.Current.RestartButtonDelay + dir * GameSettings.RestartButtonDelayStep ); break;
			case Row.Overall: SetMaster( Settings.Current.MasterVolume + step ); break;
			case Row.Sfx: SetSfx( Settings.Current.SfxVolume + step ); break;
			case Row.Music: SetMusic( Settings.Current.MusicVolume + step ); break;
			case Row.Vibration: SetVibration( Settings.Current.VibrationStrength + step ); break;
		}
		// A light tick per step so adjusting a slider on a controller has tactile feedback (both the
		// initial press and each auto-repeat step route through here; a mouse drag does not).
		Haptics.MenuBlip();
	}

	/// <summary>Confirm/click on the BACK row.</summary>
	public void Activate()
	{
		_selector.PlayWobble();
		Audio.PlaySfx( SfxType.MenuStart );
		Haptics.MenuConfirm();
		Close();
	}

	/// <summary>Mouse hover: focus a row (no-op if already focused).</summary>
	public void HoverRow( int row )
	{
		int next = Math.Clamp( row, 0, RowCount - 1 );
		if ( next == SelectedRow ) return;
		SelectedRow = next;
		_selector.PlayMove();
		Audio.PlaySfx( SfxType.MenuBlip );
	}

	// --- toggle (shared by keyboard flip + mouse click) -------------------------------------
	/// <summary>Set the "Show Timer" option. Always gives audible/haptic feedback; only persists
	/// when the value actually changes.</summary>
	public void SetShowTimer( bool value )
	{
		bool changed = Settings.Current.ShowTimer != value;
		Settings.Current.ShowTimer = value;
		Audio.PlaySfx( SfxType.MenuBlip );
		Haptics.MenuBlip();
		if ( changed ) MarkDirty();
	}

	public void ToggleShowTimer() => SetShowTimer( !Settings.Current.ShowTimer );

	// --- slider setters (shared by keyboard nudge + mouse drag) -----------------------------
	public void SetScreenshake( float value )
	{
		// Read live by GameStage's ApplyScreenshake; nothing to apply here beyond persisting.
		Settings.Current.Screenshake = Clamp100( value );
		MarkDirty();
	}

	public void SetRestartButtonDelay( float value )
	{
		Settings.Current.RestartButtonDelay = value;
		MarkDirty();
	}

	public void SetMaster( float value )
	{
		Settings.Current.MasterVolume = Clamp100( value );
		Audio.SetMasterVolume( Settings.Current.MasterVolume / 100f );
		PlaySfxPreview();
		MarkDirty();
	}

	public void SetSfx( float value )
	{
		Settings.Current.SfxVolume = Clamp100( value );
		Audio.SetSfxVolume( Settings.Current.SfxVolume / 100f );
		PlaySfxPreview();
		MarkDirty();
	}

	public void SetMusic( float value )
	{
		Settings.Current.MusicVolume = Clamp100( value );
		Audio.SetMusicVolume( Settings.Current.MusicVolume / 100f );
		MarkDirty();
	}

	public void SetVibration( float value )
	{
		Settings.Current.VibrationStrength = Clamp100( value );
		// Apply live so the per-step haptic tick fired by Nudge previews the new strength (no SFX
		// preview: this row's feedback is the vibration itself, not a sound).
		Haptics.SetStrength( Settings.Current.VibrationStrength / 100f );
		MarkDirty();
	}

	private void Close()
	{
		// Persist immediately on close so the latest values survive even if the debounce hasn't fired.
		if ( _saveDirty )
		{
			Settings.Save();
			_saveDirty = false;
		}
		OnBack?.Invoke();
	}

	private void MarkDirty()
	{
		_saveDirty = true;
		_saveTimer = SAVE_DEBOUNCE;
	}

	private void PlaySfxPreview()
	{
		if ( _sfxPreviewTimer > 0f ) return;
		Audio.PlaySfx( SfxType.MenuBlip );
		_sfxPreviewTimer = KEY_REPEAT_RATE;
	}

	private static float Clamp100( float v ) => Math.Clamp( v, 0f, 100f );
}