Game/GifExporter.cs
using System;
using System.Collections.Generic;
using System.Text;

namespace BlockParty;

/// <summary>
/// Captures a segment of the currently-watched replay and writes it out as an animated GIF.
///
/// Capture is driven incrementally (call <see cref="Step"/> once per UI tick), one frame per engine
/// update — a hard limit, not a pacing choice: sprite batches upload once per main-loop update, so a seek
/// (<see cref="GameManager.SeekReplayToFrame"/>) only becomes renderable the following update. Each step
/// captures the previously seeked tick into an <see cref="OutputSize"/> bitmap (<c>RenderToBitmap</c>), feeds the pixels
/// straight into the incremental <see cref="GifEncoder"/>, then seeks the next tick. When every tick has
/// been captured it finishes the GIF and writes the bytes to
/// the <c>gifs/</c> folder of the game data directory (<see cref="FileSystem.Data"/>, see
/// <see cref="DirName"/>) — the sbox sandbox has no native save dialog, so we surface the absolute
/// path instead (<see cref="ResultPath"/>).
///
/// The export re-frames the camera tight on the arena while it captures (independent of the overlay's
/// shrunk preview framing), so the GIF is always the full arena regardless of what the preview is showing,
/// then restores the preview framing when done.
///
/// Frames bake the retro quantize: rendered at the native 240px grid (one game pixel per texel — the CRT
/// shader's quantize) and integer-upscaled nearest-neighbour to <see cref="Scale"/>. Scanlines/curvature/
/// vignette/halation are deliberately left out — chunky pixels only.
/// </summary>
public sealed class GifExporter
{
	public enum Phase { Capturing, Done, Failed }

	/// <summary>Exported GIFs live in their own subfolder of the game data directory, beside the
	/// player's <c>levels/</c> folder (see <see cref="LocalLevels"/>).</summary>
	public const string DirName = "gifs";

	/// <summary>Largest integer upscale of the 240px arena offered by the export overlay (3x = 720px).</summary>
	public const int MaxScale = 3;

	/// <summary>Upscale used when the player hasn't picked one (see <see cref="GameSettings.GifScale"/>).</summary>
	public const int DefaultScale = 3;

	/// <summary>Clamp to the offered 1..<see cref="MaxScale"/> range. The single validation point — applied by
	/// the <see cref="GameSettings.GifScale"/> setter, which every export/estimate reads its scale from.</summary>
	public static int ClampScale( int scale ) => Math.Clamp( scale, 1, MaxScale );

	/// <summary>Square output size for an integer upscale of the arena's native pixel grid.</summary>
	public static int OutputSizeFor( int scale ) => Arena.HEIGHT * scale;

	/// <summary>Integer upscale from the arena's native pixel grid this export renders at.</summary>
	public int Scale { get; }

	/// <summary>Output GIF dimensions (square): the 240px arena at <see cref="Scale"/>.</summary>
	public int OutputSize => OutputSizeFor( Scale );

	/// <summary>Per-frame delay in centiseconds. Every tick is captured and played at 2cs (~50fps) — the
	/// fastest delay browsers reliably honour, so it's the smoothest near-real-time playback a GIF allows
	/// (60Hz gameplay shown at 50fps). Anything faster (1cs) gets clamped to 10fps by most browsers.</summary>
	public const int FrameDelayCs = 2;

	/// <summary>Hard cap on the number of frames (every tick is captured). Frames are compressed as they're
	/// captured (see GifEncoder), so memory isn't the limit — this bounds export time (one frame per engine
	/// update, see Step) and the output file size (frames are delta-encoded, so per-frame cost scales with
	/// how much of the arena changes). ~1800 frames at 60Hz is 30s of gameplay.</summary>
	public const int MaxFrames = 1800;

	public Phase State { get; private set; } = Phase.Capturing;
	public float Progress { get; private set; }
	public string ResultPath { get; private set; }
	public string Error { get; private set; }

	private readonly GameManager _mgr;
	private readonly List<int> _ticks = new();
	private readonly int _delayCs;
	private readonly string _fileName;
	// Frames are quantized + compressed straight into this writer as they're captured (no raw-frame buffer).
	private readonly GifEncoder _writer;

	private int _cursor;
	private bool _pendingCapture;
	private bool _started;
	private Vector3 _savedCamPos;
	private float _savedOrtho;

	/// <param name="mgr">The game manager owning the replay being captured.</param>
	/// <param name="startTick">First recorded tick of the segment (inclusive).</param>
	/// <param name="endTick">Last recorded tick of the segment (inclusive).</param>
	/// <param name="fileName">Destination file name within the data folder's <see cref="DirName"/> directory.</param>
	/// <param name="scale">Integer upscale of the arena (<see cref="GameSettings.GifScale"/>); see <see cref="Scale"/>.</param>
	public GifExporter( GameManager mgr, int startTick, int endTick, string fileName, int scale )
	{
		_mgr = mgr;
		Scale = scale;

		int last = Math.Max( 0, mgr.ReplayLength - 1 );
		startTick = Math.Clamp( startTick, 0, last );
		endTick = Math.Clamp( endTick, startTick, last );

		// Capture every tick across the span.
		for ( int t = startTick; t <= endTick; t++ )
			_ticks.Add( t );

		_delayCs = FrameDelayCs;
		_fileName = fileName;
		_writer = new GifEncoder( OutputSize, OutputSize, 0 ); // always loop
	}

	/// <summary>Total frames the finished GIF will contain.</summary>
	public int TotalFrames => _ticks.Count;

	/// <summary>Advance the export by one chunk. Safe to call every tick; does nothing once finished.</summary>
	public void Step()
	{
		if ( State != Phase.Capturing )
			return;

		if ( _mgr is null || !_mgr.IsReplaying || _mgr.Camera is null )
		{
			Fail( "replay is no longer active" );
			return;
		}

		if ( !_started )
		{
			_started = true;
			SetTightFraming();
		}

		try
		{
			// Sprite batches upload to the GPU once per main-loop update (SceneSpriteSystem, at
			// Stage.FinishUpdate), and RenderToBitmap draws whatever was last uploaded — a seek made THIS
			// update can only be captured NEXT update. So the capture is pipelined at one frame per update:
			// capture the tick seeked last update, then seek the next one. (Capturing more per update just
			// duplicates the same image — that bug shipped as 4 identical frames per update for a while.)
			if ( _pendingCapture )
			{
				_writer.AddFrame( CaptureFrame(), _delayCs ); // quantized + compressed now; pixels not retained
				_pendingCapture = false;
				_cursor++;
			}

			Progress = _ticks.Count == 0 ? 1f : (float)_cursor / _ticks.Count;

			if ( _cursor >= _ticks.Count )
			{
				Finish();
				return;
			}

			_mgr.SeekReplayToFrame( _ticks[_cursor], playSfx: false );
			_pendingCapture = true;
		}
		catch ( Exception e )
		{
			Log.Warning( $"BlockParty GIF export failed: {e}" );
			Fail( e.Message );
		}
	}

	private void Finish()
	{
		var bytes = _writer.Finish();
		var path = $"{DirName}/{_fileName}";
		FileSystem.Data.CreateDirectory( DirName );
		FileSystem.Data.WriteAllBytes( path, bytes );
		ResultPath = FileSystem.Data.GetFullPath( path );
		Log.Info( $"BlockParty GIF export: wrote {TotalFrames} frames ({bytes.Length / 1024} KB) to {ResultPath}" );

		// Only a completed export counts (Fail() doesn't reach here). Deduplicated per replay inside.
		Achievements.SubmitGifExport( _mgr.CurrentReplayKey );

		State = Phase.Done;
		RestoreFraming();
	}

	private void Fail( string reason )
	{
		Error = reason;
		State = Phase.Failed;
		if ( _started )
			RestoreFraming();
	}

	private Color32[] CaptureFrame() => Upscale( CaptureNative( _mgr ), Scale );

	// Render the camera's current (tight-framed) view at the arena's native pixel grid — one game pixel
	// per texel, which IS the CRT shader's 240px quantize — and read the pixels back. GetPixels32 is
	// row-major top-down, the orientation GifEncoder expects, so no flip is needed. Internal so
	// GifSizeEstimator samples through the identical pipeline (and caches the native frames).
	internal static Color32[] CaptureNative( GameManager mgr )
	{
		using var bmp = new Bitmap( Arena.HEIGHT, Arena.HEIGHT );
		mgr.Camera.RenderToBitmap( bmp, false );
		return bmp.GetPixels32();
	}

	// Plain nearest-neighbour integer upscale of a native frame: crisp chunky pixels, no shading — the
	// frame's palette stays exactly the source colors (a scanline mask was tried and removed by request).
	// 1x returns the source as-is.
	internal static Color32[] Upscale( Color32[] src, int scale )
	{
		if ( scale == 1 )
			return src;

		int size = OutputSizeFor( scale );
		var dst = new Color32[size * size];

		for ( int y = 0; y < Arena.HEIGHT; y++ )
		{
			int srcRow = y * Arena.HEIGHT;
			for ( int sy = 0; sy < scale; sy++ )
			{
				int d = (y * scale + sy) * size;
				for ( int x = 0; x < Arena.HEIGHT; x++ )
				{
					var c = src[srcRow + x];
					for ( int sx = 0; sx < scale; sx++ )
						dst[d++] = c;
				}
			}
		}

		return dst;
	}

	private void SetTightFraming()
	{
		var c = _mgr.Camera;
		_savedCamPos = c.WorldPosition;
		_savedOrtho = c.OrthographicHeight;
		c.WorldPosition = new Vector3( Arena.WIDTH / 2f, Arena.HEIGHT / 2f, _savedCamPos.z );
		c.OrthographicHeight = Arena.HEIGHT;
	}

	private void RestoreFraming()
	{
		var c = _mgr?.Camera;
		if ( c is null )
			return;
		c.WorldPosition = _savedCamPos;
		c.OrthographicHeight = _savedOrtho;
	}

	// ── static helpers (used by the overlay's readout) ──────────────────────────────────────────────

	/// <summary>Frames a GIF of this segment would contain (every tick is captured), for the readout.</summary>
	public static int FrameCount( int startTick, int endTick, int replayLength )
	{
		int last = Math.Max( 0, replayLength - 1 );
		startTick = Math.Clamp( startTick, 0, last );
		endTick = Math.Clamp( endTick, startTick, last );
		return endTick - startTick + 1;
	}

	/// <summary>
	/// Build a short, filesystem-safe GIF name from the Steam name of the player who recorded the replay
	/// (falling back to the local player for own runs), the level's map tag and display name, today's
	/// date, the segment, the output size and the run's input hash:
	/// e.g. <c>player_3A_Spike-Garden_2026-06-30_120-480_720px_a1b2c3d4.gif</c>.
	/// Tag and level name are omitted when the level doesn't have them. The size is always present (not
	/// just for non-default scales) so exports of one span at different sizes never overwrite each other,
	/// whatever the default becomes. The run hash keeps two runs of one level exported the same day with
	/// the same span and size apart (the default span is the same for every run longer than the frame
	/// cap), while re-exporting the same clip of the same run still replaces the earlier file.
	/// </summary>
	public static string BuildFileName( GameManager mgr, int startTick, int endTick, int scale )
	{
		string name = "player";
		try
		{
			var submitter = mgr is not null ? mgr.ReplaySubmitterInfo : default;
			string n = submitter.Name;
			if ( string.IsNullOrWhiteSpace( n ) )
			{
				long steamId = submitter.SteamId != 0 ? submitter.SteamId : (long)Game.SteamId;
				n = new Friend( steamId ).Name;
			}
			if ( !string.IsNullOrWhiteSpace( n ) )
				name = n;
		}
		catch { /* keep the fallback */ }

		name = Sanitize( name, 16 );

		string tag = "", level = "";
		if ( mgr?.Stage is GameStage stage && stage.Level is LevelDef def )
		{
			level = Sanitize( def.Name ?? "", 24, fallback: "" );
			if ( !string.IsNullOrEmpty( def.Id ) )
				tag = Sanitize( LevelMap.Find( def.Id )?.Tag ?? "", 8, fallback: "" );
		}

		// DateTimeOffset.Now is the sandbox-legal "now" the game already uses (LocalReplays); read its
		// numeric components directly rather than a culture-dependent date format string.
		var now = DateTimeOffset.Now;
		string date = $"{now.Year}-{now.Month:00}-{now.Day:00}";
		string range = $"{startTick}-{endTick}";
		string size = $"{OutputSizeFor( scale )}px";
		string run = mgr?.CurrentReplayRunHash ?? "";
		var parts = new[] { name, tag, level, date, range, size, run }.Where( p => p != "" );
		return $"{string.Join( "_", parts )}.gif";
	}

	// Keep [A-Za-z0-9-_], collapse runs of anything else to a single underscore, trim and truncate.
	private static string Sanitize( string s, int maxLen, string fallback = "player" )
	{
		var sb = new StringBuilder( s.Length );
		bool lastUnderscore = false;
		foreach ( char c in s )
		{
			bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-';
			if ( ok )
			{
				sb.Append( c );
				lastUnderscore = false;
			}
			else if ( !lastUnderscore )
			{
				sb.Append( '_' );
				lastUnderscore = true;
			}
		}

		string result = sb.ToString().Trim( '_', '-' );
		if ( result.Length > maxLen )
			result = result[..maxLen].Trim( '_', '-' );
		return string.IsNullOrEmpty( result ) ? fallback : result;
	}
}