PixelSpriteComponent.cs
namespace Sandbox;

/// <summary>
/// Manages sprite animation playback and renders the current frame's pixels into
/// the <see cref="PixelScreen"/> buffer.
///
/// This is a pure-C# object (not a <see cref="Component"/>) — identical to the
/// Unity framework's design where entities own a SpriteComponent directly.
/// Call <see cref="Update"/> each frame to advance animation, and <see cref="Draw"/>
/// to write pixels.
///
/// Supports flip, palette swap (colour override), per-frame hitboxes with
/// flip-aware transforms, per-frame callbacks, and all four loop modes
/// (Loops, PlayOnce, PingPong, RandomFrame).
/// </summary>
public class PixelSpriteComponent
{
	// ── Public state ─────────────────────────────────────────────────────

	/// <summary>Sprite type name used to look up animations in <see cref="SpriteManager"/>.</summary>
	public string SpriteName { get; set; }

	/// <summary>The currently playing animation, or null.</summary>
	public AnimationData CurrentAnimation { get; private set; }

	/// <summary>Current frame index within <see cref="CurrentAnimation"/>.</summary>
	public int CurrentFrameNumber { get; private set; }

	/// <summary>Current frame data (pixels + timing).</summary>
	public FrameData CurrentFrame { get; private set; }

	/// <summary>Mirror pixels horizontally.</summary>
	public bool FlipX { get; set; }

	/// <summary>Mirror pixels vertically.</summary>
	public bool FlipY { get; set; }

	/// <summary>Playback speed multiplier. Negative plays in reverse.</summary>
	public float AnimTimeScale { get; set; } = 1f;

	// ── Collision ────────────────────────────────────────────────────────

	/// <summary>Whether this sprite participates in collision queries.</summary>
	public bool Collideable { get; set; } = true;

	/// <summary>Tags used to filter this sprite during collision queries.</summary>
	public List<string> Tags { get; set; } = new();

	/// <summary>
	/// The local-space hitbox for the current frame.
	/// Uses the per-frame hitbox if defined, otherwise falls back to the animation-level hitbox.
	/// Returns a zero rect if no animation is playing.
	/// </summary>
	public PixelRect Hitbox
	{
		get
		{
			if ( CurrentAnimation is null )
				return default;

			return CurrentFrame?.Hitbox ?? CurrentAnimation.Hitbox;
		}
	}

	/// <summary>
	/// Get the world-space hitbox at the given entity position, accounting for
	/// <see cref="FlipX"/> and <see cref="FlipY"/>.
	/// </summary>
	public PixelRect GetOffsetHitbox( int x, int y )
	{
		PixelRect box = Hitbox;

		if ( CurrentAnimation is not null )
		{
			int animW = CurrentAnimation.AnimSize.X;
			int animH = CurrentAnimation.AnimSize.Y;

			// Mirror the hitbox offset when flipped so it stays aligned with the visible pixels.
			if ( FlipX )
				box = new PixelRect( animW - box.Left - box.Width, box.Bottom, box.Width, box.Height );

			if ( FlipY )
				box = new PixelRect( box.Left, animH - box.Bottom - box.Height, box.Width, box.Height );
		}

		return box + new PixelPoint( x, y );
	}

	/// <summary>
	/// Look up the animation-level hitbox for any animation by name.
	/// Useful for checking a hitbox before playing the animation.
	/// </summary>
	public PixelRect GetAnimHitbox( string animName )
	{
		AnimationData anim = SpriteManager.GetAnimation( SpriteName, animName );
		return anim?.Hitbox ?? default;
	}

	/// <summary>
	/// Compute the minimum displacement needed to push this sprite out of
	/// overlap with <paramref name="other"/>. Returns <see cref="PixelPoint.Zero"/>
	/// if there is no overlap.
	/// </summary>
	public PixelPoint GetUnpenetration( int myX, int myY, PixelSpriteComponent other, int otherX, int otherY )
	{
		PixelRect a = GetOffsetHitbox( myX, myY );
		PixelRect b = other.GetOffsetHitbox( otherX, otherY );

		if ( !a.Overlaps( b ) )
			return PixelPoint.Zero;

		// Minimum Translation Vector (MTV): compute penetration depth on all four
		// sides, then push along whichever axis has the smallest overlap.
		// This produces the shortest possible displacement to separate the two rects.
		int pushLeft  = a.XMax - b.XMin; // depth if we push 'a' left
		int pushRight = b.XMax - a.XMin; // depth if we push 'a' right
		int pushDown  = a.YMax - b.YMin; // depth if we push 'a' down
		int pushUp    = b.YMax - a.YMin; // depth if we push 'a' up

		int minX = pushLeft < pushRight ? -pushLeft : pushRight;
		int minY = pushDown < pushUp    ? -pushDown : pushUp;

		if ( Math.Abs( minX ) <= Math.Abs( minY ) )
			return new PixelPoint( minX, 0 );

		return new PixelPoint( 0, minY );
	}

	// ── Callbacks ────────────────────────────────────────────────────────

	/// <summary>Fired when a <see cref="LoopMode.PlayOnce"/> animation finishes.</summary>
	public Action OnAnimationComplete { get; set; }

	private Dictionary<int, Action> _frameCallbacks = new();

	public void AddFrameCallback( int frameNumber, Action callback ) => _frameCallbacks[frameNumber] = callback;
	public void ClearFrameCallbacks() => _frameCallbacks.Clear();

	// ── Colour overrides (palette swap) ──────────────────────────────────

	private Dictionary<uint, Color32> _colorOverrides = new();

	/// <summary>
	/// When drawing, any pixel whose RGB matches <paramref name="from"/> will be
	/// replaced with <paramref name="to"/>. Useful for palette-swapping.
	/// </summary>
	public void AddColorOverride( Color32 from, Color32 to )
	{
		uint key = PackRgb( from );
		_colorOverrides[key] = to;
	}

	public void RemoveColorOverride( Color32 from ) => _colorOverrides.Remove( PackRgb( from ) );
	public void ClearColorOverrides() => _colorOverrides.Clear();

	/// <summary>When true, palette swap preserves the original pixel's alpha.</summary>
	public bool PreserveAlphaOnOverride { get; set; }

	// ── Internal state ───────────────────────────────────────────────────

	private float _frameTimer;
	private bool _pingPongForward = true;
	private bool _playOnceFinished;
	private LoopMode? _loopModeOverride;

	// ── Animation control ────────────────────────────────────────────────

	/// <summary>
	/// Start playing a named animation. No-op if already playing the same animation.
	/// </summary>
	public void PlayAnimation( string animName )
	{
		// No-op if already playing — prevents restarting from frame 0 on every
		// call when the caller doesn't track whether the animation changed.
		if ( CurrentAnimation is not null && CurrentAnimation.Name == animName )
			return;

		AnimationData anim = SpriteManager.GetAnimation( SpriteName, animName );
		if ( anim is null )
			return;

		CurrentAnimation = anim;
		_loopModeOverride = null;
		_playOnceFinished = false;
		_pingPongForward = true;
		SetFrame( 0 );
		_frameTimer = 0f;
	}

	/// <summary>
	/// Start playing an animation with a one-off loop-mode override.
	/// </summary>
	public void PlayAnimation( string animName, LoopMode loopOverride )
	{
		PlayAnimation( animName );
		_loopModeOverride = loopOverride;
	}

	/// <summary>
	/// Advance animation timing by <paramref name="deltaTime"/> seconds.
	/// Call once per frame.
	/// </summary>
	public void Update( float deltaTime )
	{
		if ( CurrentAnimation is null )
			return;

		LoopMode loop = _loopModeOverride ?? CurrentAnimation.LoopMode;
		int frameCount = CurrentAnimation.Frames.Count;

		_frameTimer += deltaTime * AnimTimeScale;

		if ( _frameTimer < CurrentFrame.AnimTime )
			return;

		// Time to advance.
		_frameTimer = 0f;

		switch ( loop )
		{
			case LoopMode.Loops when frameCount > 1:
				SetFrame( CurrentFrameNumber >= frameCount - 1 ? 0 : CurrentFrameNumber + 1 );
				break;

			case LoopMode.PlayOnce when !_playOnceFinished:
				if ( CurrentFrameNumber < frameCount - 1 )
				{
					SetFrame( CurrentFrameNumber + 1 );
				}
				else
				{
					_playOnceFinished = true;
					OnAnimationComplete?.Invoke();
					OnAnimationComplete = null; // One-shot: clear so re-playing the same anim doesn't re-fire it.
				}
				break;

			case LoopMode.PingPong when frameCount > 1:
				if ( _pingPongForward )
				{
					if ( CurrentFrameNumber < frameCount - 1 )
						SetFrame( CurrentFrameNumber + 1 );
					else
					{
						_pingPongForward = false;
						SetFrame( CurrentFrameNumber - 1 );
					}
				}
				else
				{
					if ( CurrentFrameNumber > 0 )
						SetFrame( CurrentFrameNumber - 1 );
					else
					{
						_pingPongForward = true;
						SetFrame( CurrentFrameNumber + 1 );
					}
				}
				break;

			case LoopMode.RandomFrame when frameCount > 1:
				int next = Game.Random.Next( 0, frameCount );
				while ( next == CurrentFrameNumber )
					next = Game.Random.Next( 0, frameCount );
				SetFrame( next );
				break;
		}
	}

	// ── Drawing ──────────────────────────────────────────────────────────

	/// <summary>
	/// Render the current animation frame into the <see cref="PixelScreen"/> at
	/// the given pixel position (bottom-left origin of the sprite).
	/// </summary>
	public void Draw( int x, int y )
	{
		if ( CurrentAnimation is null || CurrentFrame is null )
			return;

		PixelScreen screen = PixelScreen.Instance;
		if ( screen is null )
			return;

		List<PixelData> pixels = GetCurrentPixels();

		foreach ( PixelData pixel in pixels )
		{
			Color32 color = ApplyColorOverride( pixel.Color );

			int px = x + pixel.Position.X;
			int py = y + pixel.Position.Y;

			if ( color.a == 255 )
				screen.SetPixel( px, py, color );
			else if ( color.a > 0 )
				screen.AddPixel( px, py, color );
		}
	}

	/// <summary>
	/// Draw arbitrary pixel data at a position with optional integer scale.
	/// Useful for text rendering or scaled sprites.
	/// </summary>
	public void DrawPixels( List<PixelData> pixelDataList, int x, int y, int scale = 1 )
	{
		PixelScreen screen = PixelScreen.Instance;
		if ( screen is null )
			return;

		foreach ( PixelData pixel in pixelDataList )
		{
			Color32 color = ApplyColorOverride( pixel.Color );

			for ( int xo = 0; xo < scale; xo++ )
			{
				for ( int yo = 0; yo < scale; yo++ )
				{
					int px = x + pixel.Position.X * scale + xo;
					int py = y + pixel.Position.Y * scale + yo;

					if ( color.a == 255 )
						screen.SetPixel( px, py, color );
					else if ( color.a > 0 )
						screen.AddPixel( px, py, color );
				}
			}
		}
	}

	// ── Queries ──────────────────────────────────────────────────────────

	/// <summary>
	/// Get the current frame's pixel list, accounting for flip state.
	/// </summary>
	public List<PixelData> GetCurrentPixels()
	{
		if ( CurrentAnimation is null || CurrentFrame is null )
			return new List<PixelData>();

		int w = CurrentAnimation.AnimSize.X;
		int h = CurrentAnimation.AnimSize.Y;

		if ( FlipX && FlipY ) return CurrentFrame.GetPixelsFlippedXY( w, h );
		if ( FlipX ) return CurrentFrame.GetPixelsFlippedX( w );
		if ( FlipY ) return CurrentFrame.GetPixelsFlippedY( h );

		// Return a copy so callers can safely modify the list without corrupting
		// the cached frame data. (The flipped paths also return new lists.)
		return new List<PixelData>( CurrentFrame.Pixels );
	}

	/// <summary>
	/// Quick visibility check against the current camera viewport.
	/// </summary>
	public bool IsVisible( int entityX, int entityY )
	{
		if ( CurrentAnimation is null )
			return false;

		PixelScreen screen = PixelScreen.Instance;
		if ( screen is null )
			return false;

		int w = CurrentAnimation.AnimSize.X;
		int h = CurrentAnimation.AnimSize.Y;

		if ( entityX < screen.CameraPos.X - w ) return false;
		if ( entityX > screen.PixelWidth + screen.CameraPos.X ) return false;
		if ( entityY < screen.CameraPos.Y - h ) return false;
		if ( entityY > screen.PixelHeight + screen.CameraPos.Y ) return false;

		return true;
	}

	// ── Debug ────────────────────────────────────────────────────────────

	/// <summary>
	/// Draw the world-space hitbox outline into the <see cref="PixelScreen"/>.
	/// Uses <see cref="PixelScreen.DrawLine"/> for the four edges.
	/// Pass a semi-transparent colour to overlay without obscuring sprite pixels.
	/// </summary>
	public void DrawHitbox( int x, int y, Color32 color )
	{
		PixelScreen screen = PixelScreen.Instance;
		if ( screen is null || CurrentAnimation is null )
			return;

		PixelRect box = GetOffsetHitbox( x, y );

		// Exclusive max → inclusive max for line drawing.
		int x0 = box.XMin;
		int y0 = box.YMin;
		int x1 = box.XMax - 1;
		int y1 = box.YMax - 1;

		screen.DrawLine( x0, y0, x1, y0, color ); // bottom
		screen.DrawLine( x0, y1, x1, y1, color ); // top
		screen.DrawLine( x0, y0, x0, y1, color ); // left
		screen.DrawLine( x1, y0, x1, y1, color ); // right
	}

	/// <summary>
	/// Draw the world-space hitbox as a filled semi-transparent rectangle.
	/// </summary>
	public void DrawHitboxFilled( int x, int y, Color32 color )
	{
		PixelScreen screen = PixelScreen.Instance;
		if ( screen is null || CurrentAnimation is null )
			return;

		PixelRect box = GetOffsetHitbox( x, y );

		for ( int py = box.YMin; py < box.YMax; py++ )
		{
			for ( int px = box.XMin; px < box.XMax; px++ )
			{
				screen.AddPixel( px, py, color );
			}
		}
	}

	// ── Internals ────────────────────────────────────────────────────────

	private void SetFrame( int frameNumber )
	{
		if ( _frameCallbacks.TryGetValue( frameNumber, out Action cb ) )
			cb?.Invoke();

		CurrentFrameNumber = frameNumber;
		CurrentFrame = CurrentAnimation.Frames[frameNumber];
	}

	private Color32 ApplyColorOverride( Color32 color )
	{
		if ( _colorOverrides.Count == 0 )
			return color;

		uint key = PackRgb( color );
		if ( !_colorOverrides.TryGetValue( key, out Color32 replacement ) )
			return color;

		if ( PreserveAlphaOnOverride )
			return new Color32( replacement.r, replacement.g, replacement.b, color.a );

		return replacement;
	}

	/// <summary>
	/// Pack RGB into a uint for fast dictionary lookup (ignores alpha).
	/// </summary>
	private static uint PackRgb( Color32 c ) => (uint)(c.r << 16 | c.g << 8 | c.b);
}