UI/GifExportOverlay.razor
@using System
@using System.Collections.Generic
@using System.Linq
@using Sandbox
@using Sandbox.UI
@inherits PanelComponent
@namespace BlockParty
@attribute [StyleSheet( "GifExportOverlay.razor.scss" )]

@* Persistent GIF-export overlay (lives on its own GameObject under GameManager, NOT a stage HUD, so it
   survives the stage rebuilds a backward replay seek triggers). It renders nothing until a preview is
   active. While active the live replay is shrunk into the top of the screen (GameManager.BeginGifPreview)
   and these controls sit below: a two-head timeline to pick the span, a readout, the output SIZE
   dropdown, and Export. Export runs incrementally in OnUpdate via a GifExporter, then shows the saved
   path — the s&box sandbox has no native save dialog, so the file lands in the game data folder. *@
<root>
	@if ( PreviewActive )
	{
		<div class="controls">
			@if ( _exporter is null )
			{
				<label class="title">EXPORT GIF</label>

				<GifTimeline Length=@Length StartTick=@_startTick EndTick=@_endTick OnChanged=@OnHeadsChanged />

				<div class="readout">
					<label class="range">@RangeText</label>
					<label class="stats">@StatsText</label>
					@if ( TooMany )
					{
						<label class="warning">@WarningText</label>
					}
				</div>

				<div class="settings">
					<label class="setting-name">SIZE</label>
					<PixelDropdown Options=@_scaleOptions Selected=@(Scale - 1) OnSelected=@OnScaleSelected OpenUp=@true></PixelDropdown>
				</div>

				<div class="buttons">
					<button class="cancel" onclick=@Close>CANCEL</button>
					<button class="confirm @(TooMany ? "disabled" : "")" onclick=@StartExport>EXPORT</button>
				</div>
			}
			else if ( _exporter.State == GifExporter.Phase.Done )
			{
				<label class="title">GIF SAVED</label>
				<label class="path">@_exporter.ResultPath</label>
				<div class="buttons">
					<button class="confirm" onclick=@CopyPath>@(_copied ? "COPIED!" : "COPY PATH")</button>
					<button class="confirm" onclick=@Close>DONE</button>
				</div>
			}
			else if ( _exporter.State == GifExporter.Phase.Failed )
			{
				<label class="title">EXPORT FAILED</label>
				<label class="error">@_exporter.Error</label>
				<div class="buttons">
					<button class="cancel" onclick=@Close>CLOSE</button>
				</div>
			}
			else
			{
				<label class="title">EXPORTING…</label>
				<div class="progress">
					<div class="progress-fill" style="width: @(Pct( _exporter.Progress ))%;"></div>
				</div>
				<label class="progress-label">@($"{(int)(_exporter.Progress * 100)}%")</label>
			}
		</div>
	}
</root>

@code
{
	// Every tick is always captured and played at a fixed 2cs (~50fps) — the fastest delay browsers play
	// smoothly (see GifExporter.FrameDelayCs). No frame-skip / speed / loop controls: the GIF always loops.
	private int _startTick;
	private int _endTick;

	private GifExporter _exporter;
	private bool _wasActive;
	private bool _copied;

	// Estimated output size for the current span (-1 = unknown), sampled incrementally by a
	// GifSizeEstimator (one frame per update, like the exporter — sprite batches upload once per update).
	// Sampling seeks the replay around, so it's debounced: any head change cancels the running job, clears
	// the estimate and schedules a re-sample shortly after the heads go quiet. A finished estimator is kept
	// so a SIZE change can re-encode its cached frames instead of sampling again.
	private long _estimatedBytes = -1;
	private float _estimateDueAt = -1f;
	private GifSizeEstimator _estimator;
	private const float EstimateDebounce = 0.35f;

	// Output scale (1x..MaxScale of the 240px arena), persisted across sessions in Settings (whose setter
	// clamps). Dropdown labels are indexed scale-1 so the selected index maps straight to the setting.
	private static readonly List<string> _scaleOptions = Enumerable.Range( 1, GifExporter.MaxScale )
		.Select( s => $"{s}x • {GifExporter.OutputSizeFor( s )}px" ).ToList();
	private static int Scale => Settings.Current.GifScale;

	private bool PreviewActive => GameManager.Instance?.IsGifPreviewActive ?? false;
	private int Length => GameManager.Instance?.ReplayLength ?? 0;
	private int FrameCount => GifExporter.FrameCount( _startTick, _endTick, Length );
	private float Fps => 100f / GifExporter.FrameDelayCs;
	private bool TooMany => FrameCount > GifExporter.MaxFrames;

	// Each readout label is built as ONE interpolated string: an s&box label that mixes @expressions with
	// literal text mis-diffs when the panel re-renders (text nodes get dropped/duplicated on hover), so a
	// single expression per label keeps the render correct.
	private static string Fmt( float v ) => v.ToString( "0.#", System.Globalization.CultureInfo.InvariantCulture );
	private string RangeText => $"{FormatTime( _startTick / 60f )} – {FormatTime( _endTick / 60f )}";
	private string StatsText => $"{FrameCount} frames • {Fmt( Fps )} fps • {Fmt( DurationSeconds )}s{SizeSuffix}";
	private string SizeSuffix => _estimatedBytes < 0 ? "" : $" • ~{FormatSize( _estimatedBytes )}";

	private static string FormatSize( long bytes )
		=> bytes < 1024 * 1024 ? $"{bytes / 1024} KB" : $"{Fmt( bytes / (1024f * 1024f) )} MB";
	private string WarningText => $"Too many frames (max {GifExporter.MaxFrames}) — shorten the range.";

	// Total play length: captured frames at the fixed per-frame delay.
	private float DurationSeconds => FrameCount * GifExporter.FrameDelayCs / 100f;

	protected override void OnUpdate()
	{
		bool active = PreviewActive;

		// Opening edge: a fresh preview — (re)initialise the selection and clear any prior export.
		if ( active && !_wasActive )
			ResetForOpen();
		_wasActive = active;

		if ( !active )
			return;

		// Drive the capture incrementally while it runs (the render updates via BuildHash).
		if ( _exporter is not null && _exporter.State == GifExporter.Phase.Capturing )
			_exporter.Step();

		// Size estimate: drive a running job one frame per update, and once the heads have been quiet for
		// the debounce window, start a new one. (The preview briefly shows the sample frames flick past — a
		// quick scrub across the selected span.) A Done estimator stays around for SIZE retargets.
		if ( _exporter is null )
		{
			if ( _estimator is not null )
			{
				if ( _estimator.Running )
					_estimator.Step();

				if ( _estimator.State == GifSizeEstimator.Phase.Done && _estimatedBytes != _estimator.ResultBytes )
				{
					_estimatedBytes = _estimator.ResultBytes;
					StateHasChanged();
				}
				else if ( _estimator.State == GifSizeEstimator.Phase.Failed )
				{
					_estimator = null;
				}
			}
			else if ( _estimateDueAt >= 0f && RealTime.Now >= _estimateDueAt )
			{
				_estimateDueAt = -1f;
				if ( !TooMany )
					_estimator = new GifSizeEstimator( GameManager.Instance, _startTick, _endTick, Scale );
			}
		}
	}

	private void InvalidateEstimate()
	{
		_estimator?.Cancel();
		_estimator = null;
		_estimatedBytes = -1;
		_estimateDueAt = RealTime.Now + EstimateDebounce;
	}

	private void ResetForOpen()
	{
		_exporter = null;
		_copied = false;
		InvalidateEstimate();

		int last = Math.Max( 0, Length - 1 );

		// Restore the settings from the last export in THIS replay session, if any (GameManager clears them
		// when the replay ends or a different one starts). Otherwise fall back to defaults.
		if ( GameManager.Instance?.SavedGifConfig is GifExportConfig cfg )
		{
			_startTick = Math.Clamp( cfg.StartTick, 0, last );
			_endTick = Math.Clamp( cfg.EndTick, _startTick, last );
			return;
		}

		_startTick = 0;
		// Default to the whole replay, but not past the frame cap (every tick is captured, so a run longer
		// than MaxFrames ticks would otherwise disable Export). The user can drag further — that trips the
		// "too many frames" warning.
		_endTick = Math.Min( last, GifExporter.MaxFrames - 1 );
	}

	private void OnHeadsChanged( int start, int end, bool draggingEnd )
	{
		_startTick = start;
		_endTick = end;
		InvalidateEstimate();
		StateHasChanged();
	}

	// SIZE dropdown changed: persist it and re-estimate (the byte size scales with the frame area). The
	// estimator re-encodes its cached native frames at the new scale — no seeks, no preview scrub. With no
	// estimator (debounce pending / too many frames) the next one simply starts at the new scale.
	private void OnScaleSelected( int index )
	{
		Settings.Current.GifScale = index + 1;
		Settings.Save();
		_estimatedBytes = -1;
		_estimator?.Retarget( Scale );
		StateHasChanged();
	}

	private void StartExport()
	{
		var mgr = GameManager.Instance;
		if ( mgr is null || Length <= 0 || TooMany )
			return;

		Audio.PlaySfx( SfxType.MenuStart );
		// A running size-sample would fight the exporter's seeks — stop it (the exporter reframes anyway).
		_estimator?.Cancel();
		_estimator = null;
		int scale = Scale;
		string file = GifExporter.BuildFileName( mgr, _startTick, _endTick, scale );
		_exporter = new GifExporter( mgr, _startTick, _endTick, file, scale );
		// Remember the span for the rest of this replay session (see GameManager.SavedGifConfig).
		mgr.SavedGifConfig = new GifExportConfig( _startTick, _endTick );
		StateHasChanged();
	}

	private void CopyPath()
	{
		if ( string.IsNullOrEmpty( _exporter?.ResultPath ) )
			return;
		Audio.PlaySfx( SfxType.MenuBlip );
		Sandbox.UI.Clipboard.SetText( _exporter.ResultPath );
		_copied = true;
		StateHasChanged();
	}

	private void Close()
	{
		Audio.PlaySfx( SfxType.MenuBlip );
		_estimator?.Cancel();
		_estimator = null;
		GameManager.Instance?.EndGifPreview();
		StateHasChanged();
	}

	private static string Pct( float f )
		=> (Math.Clamp( f, 0f, 1f ) * 100f).ToString( "0.#", System.Globalization.CultureInfo.InvariantCulture );

	private static string FormatTime( float seconds )
	{
		int total = (int)seconds;
		return $"{total / 60}:{total % 60:00}";
	}

	protected override int BuildHash()
	{
		float progress = _exporter?.Progress ?? 0f;
		int state = (int)(_exporter?.State ?? GifExporter.Phase.Capturing) + (_exporter is null ? 100 : 0);
		return System.HashCode.Combine( PreviewActive, _startTick, _endTick, state, (int)(progress * 200f), _estimatedBytes, Scale ) ^ (_copied ? 91 : 0);
	}
}