UI/ReplayScrubBar.razor
@using System
@using Sandbox
@using Sandbox.UI
@inherits Panel
@namespace BlockParty
@attribute [StyleSheet( "ReplayScrubBar.razor.scss" )]

@* Scrubbable replay timeline. Reads as a thin progress bar (the fill tracks the committed playback
   position); click or drag the head to SEEK, which re-simulates the deterministic run to the chosen
   frame and holds it paused there.

   Direction matters (the sim is forward-only):
     - FORWARD drag  -> seek live, with per-step SFX (the scrubbing-audio feel).
     - BACKWARD drag -> deferred: the scene stays on the committed frame and only the head previews the
       target; on release we seek once (silently) to where the head was dropped.
     - click (mouse-down jump) -> seek immediately, silent (no burst of layered SFX).

   The forward/backward decision is made in exact frame units (not the quantised progress fraction) so
   a forward click can't round into a phantom backward commit on release.

   Self-styled because s&box stylesheets don't cascade into a child component's internals. *@
<root class="replay-scrub">
	<PixelTooltip @ref="ReactionTooltip"></PixelTooltip>
	<div class="bar">
		<div class="fill" style="width: @(Css( FillFraction ))%;"></div>
	</div>
	@foreach ( var marker in ReactionMarkers )
	{
		<div class="reaction-tick @(marker.Count > 1 ? "cluster" : "") @(marker.Count >= 8 ? "busy" : "")"
			style="left: @(Css( marker.Fraction ))%; height: @(marker.Height)px;"></div>
	}
	<div class="head" style="left: @(Css( HeadFraction ))%;"></div>
</root>

@code
{
	// True between mouse-down and mouse-up; while set, the head previews _previewFraction.
	bool _dragging;
	// Where the head is shown during a drag (the cursor position along the bar, [0,1]).
	float _previewFraction;
	// Set while the drag is sitting behind the committed frame (a backward seek deferred to release).
	bool _pendingBackward;
	// Keep a marker click snapped through hand jitter. Once a real horizontal drag
	// starts, it stays a drag even if the pointer returns to the press position.
	bool _holdingMarkerClick;
	float _markerPressX;

	float Progress => GameManager.Instance?.ReplayProgress ?? 0f;
	int CurrentFrame => GameManager.Instance?.ReplayFrame ?? 0;
	int Length => GameManager.Instance?.ReplayLength ?? 0;
	PixelTooltip ReactionTooltip { get; set; }
	ReplayReactions _markerSource;
	int _markerRevision = -1, _markerLength, _markerSlices;
	System.Collections.Generic.IReadOnlyList<ReplayReactionTimeline.Marker> _markers = Array.Empty<ReplayReactionTimeline.Marker>();
	int MarkerSlices => Math.Clamp( (int)((Box.Right - Box.Left) * ScaleFromScreen / 8f), 1, 128 );
	System.Collections.Generic.IReadOnlyList<ReplayReactionTimeline.Marker> ReactionMarkers
	{
		get
		{
			var source = GameManager.Instance?.CurrentReplayReactions;
			int revision = source?.Revision ?? -1;
			int slices = MarkerSlices;
			if ( source != _markerSource || revision != _markerRevision || Length != _markerLength || slices != _markerSlices )
			{
				_markerSource = source; _markerRevision = revision; _markerLength = Length; _markerSlices = slices;
				_markers = source?.HasSnapshot == true
					? ReplayReactionTimeline.Build( source.OrderedEntries, Length, slices )
					: Array.Empty<ReplayReactionTimeline.Marker>();
			}
			return _markers;
		}
	}

	static string ReactionTime( int frame )
	{
		int tenths = (int)MathF.Round( frame * 10f / Arena.TICK_RATE );
		return FormattableString.Invariant( $"{tenths / 600}:{tenths % 600 / 10:00}.{tenths % 10}" );
	}

	void UpdateReactionTooltip()
	{
		if ( HasHovered && !_dragging )
		{
			float fraction = ScreenPosToFraction( Mouse.Position );
			if ( MarkerAt( fraction ) is { } marker )
			{
				string time = ReactionTime( marker.FirstFrame );
				if ( marker.LastFrame != marker.FirstFrame ) time += $" – {ReactionTime( marker.LastFrame )}";
				ReactionTooltip?.Show( $"{marker.Count} {(marker.Count == 1 ? "reaction" : "reactions")} - {time}" );
				return;
			}
		}
		ReactionTooltip?.Hide();
	}

	ReplayReactionTimeline.Marker? MarkerAt( float fraction ) => ReplayReactionTimeline.Nearest(
		ReactionMarkers, fraction, 4f / MathF.Max( 1f, (Box.Right - Box.Left) * ScaleFromScreen ) );

	// Fill always shows the committed playback position. The head follows the cursor while dragging
	// (so a deferred backward drag visibly previews its target), otherwise it sits on the fill.
	float FillFraction => Progress;
	float HeadFraction => _dragging ? _previewFraction : Progress;

	// Plain decimal, invariant culture — a tiny position otherwise stringifies in scientific notation
	// (e.g. 3e-08), which the stylesheet parser rejects, and a comma-decimal locale would emit "0,5".
	static string Css( float fraction )
		=> (Math.Clamp( fraction, 0f, 1f ) * 100f).ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture );

	// Screen pixel x -> normalised position along the bar [0,1]. Same math as PixelSlider; the bar
	// spans the panel's full width so the panel's own box maps directly onto the timeline.
	float ScreenPosToFraction( Vector2 pos )
		=> MathX.LerpInverse( pos.x, Box.Left, Box.Right, true );

	// Map a bar fraction to a frame, capped at Length-1: scrubbing must never land on the very last
	// frame, since reaching it re-sims into the run's game-over (THE END + the determinism self-check).
	// The viewer reaches the genuine end by letting playback resume and play it out.
	int FractionToFrame( float f )
	{
		int frame = (int)MathF.Round( Math.Clamp( f, 0f, 1f ) * Length );
		return Math.Clamp( frame, 0, Math.Max( 0, Length - 1 ) );
	}

	// Seek toward the cursor: forward (target at/ahead of the committed frame) seeks live now; backward
	// is deferred to release so dragging the head left doesn't re-sim the scene every move.
	void SeekToCursor( bool withSfx, bool snapToMarker = false )
	{
		var mgr = GameManager.Instance;
		if ( mgr is null ) return;

		_previewFraction = ScreenPosToFraction( Mouse.Position );
		int target = FractionToFrame( _previewFraction );
		if ( snapToMarker && MarkerAt( _previewFraction ) is { } marker )
		{
			_holdingMarkerClick = true;
			target = marker.SeekFrame( Length );
			_previewFraction = Length > 0 ? target / (float)Length : 0f;
		}
		if ( target >= mgr.ReplayFrame )
		{
			mgr.SeekReplayToFrame( target, playSfx: withSfx );
			_pendingBackward = false;
		}
		else
		{
			_pendingBackward = true;
		}
	}

	protected override void OnMouseDown( MousePanelEvent e )
	{
		base.OnMouseDown( e );
		if ( e.MouseButton != MouseButtons.Left ) return; // left-only, like CustomPatternGrid
		var mgr = GameManager.Instance;
		if ( mgr is null ) return;

		_dragging = true;
		_pendingBackward = false;
		_holdingMarkerClick = false;
		_markerPressX = Mouse.Position.x;
		mgr.BeginReplayScrub();
		SeekToCursor( withSfx: false, snapToMarker: true ); // clicks snap to reactions; dragging stays continuous
		e.StopPropagation();
	}

	// Drag: only while the LEFT press that armed _dragging still holds this panel active. Move
	// events are buttonless (engine creates them with "none"), so the button check lives in
	// OnMouseDown and _dragging carries it — a right press also sets Active and would drag otherwise.
	protected override void OnMouseMove( MousePanelEvent e )
	{
		base.OnMouseMove( e );
		if ( !_dragging || !HasActive ) return;
		if ( _holdingMarkerClick && MathF.Abs( Mouse.Position.x - _markerPressX ) * ScaleFromScreen < 4f )
		{
			e.StopPropagation();
			return;
		}
		_holdingMarkerClick = false;
		SeekToCursor( withSfx: true ); // forward drag-moves keep their per-step audio
		e.StopPropagation();
	}

	protected override void OnMouseUp( MousePanelEvent e )
	{
		base.OnMouseUp( e );
		if ( e.MouseButton != MouseButtons.Left || !_dragging ) return;
		EndDrag();
		e.StopPropagation();
	}

	// Release fallback for when OnMouseUp isn't delivered (button released away from this small bar).
	// The HUD keeps a full-screen catcher up during the scrub (GameManager.IsScrubbingTimeline), so the
	// release is always processed somewhere and the active pseudo-class clears — once it has, and we were
	// dragging, the drag is over wherever the cursor ended up.
	public override void Tick()
	{
		base.Tick();
		if ( _dragging && !HasActive )
			EndDrag();
		UpdateReactionTooltip();
	}

	void EndDrag()
	{
		_dragging = false;
		_holdingMarkerClick = false;

		var mgr = GameManager.Instance;
		if ( mgr is null ) return;

		// Commit a deferred backward seek to wherever the head was dropped (silent — one jump).
		if ( _pendingBackward )
			mgr.SeekReplayToFrame( FractionToFrame( _previewFraction ), playSfx: false );

		_pendingBackward = false;
		mgr.EndReplayScrub();
	}

	// Repaint as playback advances and as the head is dragged, quantised so it doesn't rebuild every
	// single frame (matches the host HUD's progress bucketing).
	protected override int BuildHash()
		=> System.HashCode.Combine( (int)(Progress * 500f), _dragging, (int)(_previewFraction * 500f),
			GameManager.Instance?.CurrentReplayReactions, GameManager.Instance?.CurrentReplayReactions?.Revision ?? -1,
			Length, MarkerSlices );
}