Game/GifSizeEstimator.cs
using System;
using System.Collections.Generic;

namespace BlockParty;

/// <summary>
/// Estimates the byte size a GIF export of a replay segment would produce, by sampling frames through the
/// real encoder: the first frame is encoded at full cost, then a few short runs of consecutive ticks spread
/// across the span measure the average delta-frame cost, which is extrapolated to the full frame count.
/// Runs (not single pairs) because per-tick delta cost swings hard — a single sampled pair can land on a
/// hit-stop freeze and measure ~nothing. 5 sites × 4 deltas stayed within ±8% of the real size on test clips.
///
/// Incremental for the same reason <see cref="GifExporter"/> is: sprite batches upload once per main-loop
/// update, so each sampled tick needs its own update (seek now, capture next Step). Drive it with
/// <see cref="Step"/> once per UI tick; ~25 updates total. The shrunk preview briefly shows the sample
/// frames flick past (the screen renders each seeked tick) — it reads as a quick scrub across the selected
/// span. The replay is seeked back to where it was when sampling completes or the job is
/// <see cref="Cancel"/>led; the camera is only touched inside a capture, never across updates.
///
/// The native 240px sample frames are kept (~25 × 230KB), so <see cref="Retarget"/> to a different output
/// scale re-encodes them one per Step with no seeks and no preview scrub.
/// </summary>
public sealed class GifSizeEstimator
{
	/// <summary>Sampling seeks + captures (and encodes at the requested scale as it goes); Encoding
	/// re-encodes the cached frames after a <see cref="Retarget"/>.</summary>
	public enum Phase { Sampling, Encoding, Done, Failed }

	// Sample-run shape: Sites runs of RunLen consecutive-tick deltas, spread evenly across the span.
	private const int Sites = 5;
	private const int RunLen = 4;

	public Phase State { get; private set; } = Phase.Sampling;

	/// <summary>True while <see cref="Step"/> still has work to do.</summary>
	public bool Running => State is Phase.Sampling or Phase.Encoding;

	/// <summary>Estimated GIF size in bytes; valid once <see cref="State"/> is Done.</summary>
	public long ResultBytes { get; private set; } = -1;

	private readonly GameManager _mgr;
	private readonly int _frameCount;
	private readonly int _savedFrame;
	// Capture schedule: ticks to seek+capture in order. Measured captures feed the delta average;
	// unmeasured ones are the first frame (full-frame cost, counted separately) and re-bases between sites.
	private readonly List<(int Tick, bool Measure)> _ticks = new();
	// Native 240px pixels of every captured tick, parallel to _ticks — the input to (re-)encoding.
	private readonly List<Color32[]> _frames = new();

	private int _scale;
	private int _pendingScale; // scale requested mid-sampling; applied once every frame is captured
	private GifEncoder _enc;
	private int _cursor;       // next tick to seek (Sampling) / next cached frame to encode (Encoding)
	private bool _pendingCapture;
	private long _firstFrameBytes; // header + first frame
	private long _deltaTotal;
	private int _deltaCount;

	public GifSizeEstimator( GameManager mgr, int startTick, int endTick, int scale )
	{
		_mgr = mgr;
		_scale = _pendingScale = scale;

		if ( mgr is null || !mgr.IsReplaying || mgr.Camera is null || mgr.ReplayLength <= 0 )
		{
			State = Phase.Failed;
			return;
		}

		int last = Math.Max( 0, mgr.ReplayLength - 1 );
		startTick = Math.Clamp( startTick, 0, last );
		endTick = Math.Clamp( endTick, startTick, last );
		_frameCount = endTick - startTick + 1;
		_savedFrame = mgr.ReplayFrame;
		ResetEncoder();

		// First frame: full-frame cost.
		_ticks.Add( (startTick, false) );

		if ( _frameCount > 1 )
		{
			int sites = Math.Min( Sites, Math.Max( 1, (_frameCount - 1) / RunLen ) );
			int cur = startTick;

			for ( int i = 0; i < sites; i++ )
			{
				// Run start ticks spread over the span, ascending so every seek is a cheap forward re-sim.
				// Site 0 starts at startTick, where the first frame leaves both the replay and the
				// encoder's delta base — no re-base capture needed there.
				int t = sites == 1 ? startTick : startTick + (int)((long)i * (_frameCount - 1 - RunLen) / (sites - 1));
				t = Math.Max( t, cur ); // short spans can make sites overlap — never rewind

				if ( t != cur )
					_ticks.Add( (t, false) ); // re-base the delta; growth not measured

				for ( int r = 0; r < RunLen && t + r + 1 <= endTick; r++ )
					_ticks.Add( (t + r + 1, true) );

				cur = Math.Min( t + RunLen, endTick );
			}
		}
	}

	/// <summary>Advance by one frame. Call once per UI tick; does nothing once finished.</summary>
	public void Step()
	{
		if ( State == Phase.Encoding )
		{
			EncodeFrame( _cursor++ );
			if ( _cursor >= _frames.Count )
				FinishEstimate();
			return;
		}

		if ( State != Phase.Sampling )
			return;

		if ( _mgr is null || !_mgr.IsReplaying || _mgr.Camera is null )
		{
			State = Phase.Failed;
			return;
		}

		try
		{
			// Same one-frame-per-update pipeline as GifExporter.Step: capture the tick seeked last update
			// (its sprite batch has uploaded by now), then seek the next scheduled tick.
			if ( _pendingCapture )
			{
				_pendingCapture = false;
				_frames.Add( CaptureTight() );
				EncodeFrame( _cursor );

				_cursor++;
				if ( _cursor >= _ticks.Count )
				{
					RestoreReplayFrame();
					// A scale picked while sampling ran: the captures are scale-independent, so just
					// re-encode them.
					if ( _pendingScale != _scale )
						BeginEncoding( _pendingScale );
					else
						FinishEstimate();
					return;
				}
			}

			_mgr.SeekReplayToFrame( _ticks[_cursor].Tick, playSfx: false );
			_pendingCapture = true;
		}
		catch ( Exception e )
		{
			Log.Warning( $"BlockParty GIF size estimate failed: {e.Message}" );
			State = Phase.Failed;
			RestoreReplayFrame();
		}
	}

	/// <summary>Re-estimate at a different output scale using the cached sample frames — no seeks, so
	/// the preview doesn't scrub. Takes effect after sampling if that's still running; restarts the
	/// encode if it was Encoding or Done. Ignored once Failed.</summary>
	public void Retarget( int scale )
	{
		if ( State == Phase.Failed || scale == _pendingScale )
			return;

		_pendingScale = scale;
		if ( State != Phase.Sampling )
			BeginEncoding( scale );
	}

	/// <summary>Abandon the job (heads moved, export started, overlay closed) and, if it was still
	/// seeking, put the replay back on the frame it was showing.</summary>
	public void Cancel()
	{
		if ( !Running )
			return;

		bool wasSampling = State == Phase.Sampling;
		State = Phase.Failed;
		if ( wasSampling )
			RestoreReplayFrame();
	}

	private void BeginEncoding( int scale )
	{
		_scale = _pendingScale = scale;
		ResetEncoder();
		_cursor = 0;
		ResultBytes = -1;
		State = Phase.Encoding;
	}

	private void ResetEncoder()
	{
		int size = GifExporter.OutputSizeFor( _scale );
		_enc = new GifEncoder( size, size, 0 );
		_firstFrameBytes = 0;
		_deltaTotal = 0;
		_deltaCount = 0;
	}

	// Feed cached frame i through the encoder at the current scale, booking its cost.
	private void EncodeFrame( int i )
	{
		int before = _enc.Length;
		_enc.AddFrame( GifExporter.Upscale( _frames[i], _scale ), GifExporter.FrameDelayCs );

		if ( i == 0 )
			_firstFrameBytes = _enc.Length; // header + first frame
		else if ( _ticks[i].Measure )
		{
			_deltaTotal += _enc.Length - before;
			_deltaCount++;
		}
	}

	private void FinishEstimate()
	{
		long estimate = _firstFrameBytes + 1; // + trailer byte
		if ( _frameCount > 1 && _deltaCount > 0 )
			estimate += _deltaTotal / _deltaCount * (_frameCount - 1);
		ResultBytes = estimate;
		State = Phase.Done;
	}

	private void RestoreReplayFrame()
	{
		if ( _mgr is not null && _mgr.IsReplaying )
			_mgr.SeekReplayToFrame( _savedFrame, playSfx: false );
	}

	// Capture with the export's tight arena framing, restoring the preview framing before returning so the
	// on-screen camera never shows it (RenderToBitmap reads the camera transform at call time; only the
	// sprite batches are frozen per update).
	private Color32[] CaptureTight()
	{
		var cam = _mgr.Camera;
		var savedPos = cam.WorldPosition;
		float savedOrtho = cam.OrthographicHeight;

		try
		{
			cam.WorldPosition = new Vector3( Arena.WIDTH / 2f, Arena.HEIGHT / 2f, savedPos.z );
			cam.OrthographicHeight = Arena.HEIGHT;
			return GifExporter.CaptureNative( _mgr );
		}
		finally
		{
			cam.WorldPosition = savedPos;
			cam.OrthographicHeight = savedOrtho;
		}
	}
}