Terrain/TileArraySlice.cs
using System;
using System.Buffers;
using System.Collections;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace HC3.Terrain;

#nullable enable

public readonly record struct IndexedTile( Vector2Int Index, TerrainTile Tile );

/// <summary>
/// Wraps an array of <see cref="TerrainTile"/>s with a bunch of helper methods for accessing them.
/// You can slice this into smaller ranges, without allocating, using <see cref="Slice(RectInt)"/>.
/// </summary>
[JsonConverter( typeof( TileArraySliceConverter ) )]
public readonly struct TileArraySlice : ITerrainData,
	IEnumerable<IndexedTile>,
	IEquatable<TileArraySlice>
{
	private readonly TerrainTile[] _array;

	private readonly int _offset;
	private readonly int _stride;

	public Vector2Int Size { get; }

	public RectInt Bounds => new( 0, Size );

	public TerrainTile this[Vector2Int tileIndex]
	{
		get => _array[GetIndex( tileIndex )];
		set => _array[GetIndex( tileIndex )] = value;
	}

	private int GetIndex( Vector2Int tileIndex ) => _offset + tileIndex.x + tileIndex.y * _stride;

	public TileArraySlice( Vector2Int size )
		: this( size == 0 ? [] : new TerrainTile[size.x * size.y], size )
	{

	}

	internal TileArraySlice( TerrainTile[] array, Vector2Int size )
		: this( array, size, 0, size.x )
	{

	}

	internal TileArraySlice( TerrainTile[] array, Vector2Int size, int offset, int stride )
	{
		ArgumentOutOfRangeException.ThrowIfLessThan( size.x, 0, nameof( size ) );
		ArgumentOutOfRangeException.ThrowIfLessThan( size.y, 0, nameof( size ) );
		ArgumentOutOfRangeException.ThrowIfLessThan( offset, 0, nameof( offset ) );
		ArgumentOutOfRangeException.ThrowIfLessThan( stride, size.x, nameof( stride ) );
		ArgumentOutOfRangeException.ThrowIfGreaterThan( _offset + size.x + (size.y - 1) * stride, array.Length, nameof( size ) );

		_array = array;

		_offset = offset;
		_stride = stride;

		Size = size;
	}

	public TileArraySlice SliceX( int offset, int size ) => Slice( new RectInt( offset, 0, size, Size.y ) );
	public TileArraySlice SliceY( int offset, int size ) => Slice( new RectInt( 0, offset, Size.x, size ) );

	public TileArraySlice Slice( Vector2Int size ) => Slice( new RectInt( 0, size ) );

	public TileArraySlice Slice( RectInt range )
	{
		if ( range.Position == 0 && range.Size == Size ) return this;

		var min = range.Position;

		if ( min.x < 0 || min.y < 0 )
		{
			throw new ArgumentOutOfRangeException( nameof( range ) );
		}

		var max = range.Position + range.Size;

		if ( max.x > Size.x || max.y > Size.y )
		{
			throw new ArgumentOutOfRangeException( nameof( range ) );
		}

		return new TileArraySlice( _array, range.Size, GetIndex( range.Position ), _stride );
	}

	public void CopyTo( RectInt srcRange, TileArraySlice dst ) =>
		Slice( srcRange ).CopyTo( dst );

	public void CopyTo( TileArraySlice dst )
	{
		if ( dst.Size.x < Size.x || dst.Size.y < Size.y )
		{
			throw new ArgumentOutOfRangeException( nameof( dst ), "Destination array too small." );
		}

		for ( var y = 0; y < Size.y; ++y )
		{
			var firstTile = new Vector2Int( 0, y );

			var srcSpan = _array.AsSpan( GetIndex( firstTile ), Size.x );
			var dstSpan = dst._array.AsSpan( dst.GetIndex( firstTile ), Size.x );

			srcSpan.CopyTo( dstSpan );
		}
	}

	public void Clear()
	{
		for ( var y = 0; y < Size.y; ++y )
		{
			var firstTile = new Vector2Int( 0, y );

			Array.Clear( _array, GetIndex( firstTile ), Size.x );
		}
	}

	public void Fill( TerrainTile tile )
	{
		for ( var y = 0; y < Size.y; ++y )
		{
			var firstTile = new Vector2Int( 0, y );

			Array.Fill( _array, tile, GetIndex( firstTile ), Size.x );
		}
	}

	public (int Min, int Max) GetHeightRange()
	{
		var min = int.MaxValue;
		var max = int.MinValue;

		foreach ( var (_, tile) in this )
		{
			min = Math.Min( min, tile.MinHeight );
			max = Math.Max( max, tile.MaxHeight );
		}

		return min > max ? default : (min, max);
	}

	public IEnumerator<IndexedTile> GetEnumerator()
	{
		// TODO: non-allocating enumerator

		var thisCopy = this;

		return Enumerable.Range( 0, Size.y ).SelectMany( y =>
			Enumerable.Range( 0, thisCopy.Size.x ).Select( x =>
			{
				var index = new Vector2Int( x, y );
				return new IndexedTile( index, thisCopy[index] );
			} ) ).GetEnumerator();
	}

	IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

	public void WriteUncompressed( ref ByteStream stream )
	{
		if ( _offset == 0 && _stride == Size.x )
		{
			stream.WriteArray( _array );
		}
		else
		{
			var arrayCopy = ArrayPool<TerrainTile>.Shared.Rent( Size.x * Size.y );

			try
			{


				stream.WriteArray<TerrainTile>( arrayCopy.AsSpan( 0, Size.x * Size.y ) );
			}
			finally
			{
				ArrayPool<TerrainTile>.Shared.Return( arrayCopy );
			}
		}
	}

	private void CopyTo( Span<TerrainTile> span )
	{
		for ( var y = 0; y < Size.y; ++y )
		{
			var firstTile = new Vector2Int( 0, y );

			var srcSpan = _array.AsSpan( GetIndex( firstTile ), Size.x );
			var dstSpan = span.Slice( y * Size.x, Size.x );

			srcSpan.CopyTo( dstSpan );
		}
	}

	public TerrainTile[] ToArray()
	{
		if ( _array.Length == Size.x * Size.y )
		{
			return _array;
		}

		var array = new TerrainTile[Size.x * Size.y];

		CopyTo( array );

		return array;
	}

	[Flags]
	public enum CompressedTerrainFlags
	{
		Legacy = 1
	}

	private readonly record struct LegacyTerrainTile( ushort BaseHeight, TileSlope Slope );

	public static TileArraySlice FromUncompressed( Vector2Int size, CompressedTerrainFlags flags, ref ByteStream stream )
	{
		TerrainTile[] array;

		if ( (flags & CompressedTerrainFlags.Legacy) != 0 )
		{
			array = stream.ReadArray<LegacyTerrainTile>( size.x * size.y )
				.ToArray()
				.Select( x => new TerrainTile( x.BaseHeight, x.Slope, default ) )
				.ToArray();
		}
		else
		{
			array = stream.ReadArray<TerrainTile>( size.x * size.y )
				.ToArray();
		}

		return new TileArraySlice( array, size );
	}

	public string ToBase64()
	{
		var stream = ByteStream.Create( Size.x * Size.y * TerrainTile.SizeBytes );

		try
		{
			WriteUncompressed( ref stream );

			var compressed = stream.Compress();

			return Convert.ToBase64String( compressed.ToArray() );
		}
		finally
		{
			stream.Dispose();
		}
	}

	public static TileArraySlice FromBase64( Vector2Int size, int version, string base64 )
	{
		var compressedBytes = Convert.FromBase64String( base64 );

		using var compressed = ByteStream.CreateReader( compressedBytes );

		var stream = compressed.Decompress();

		try
		{
			return FromUncompressed( size, version < 2 ? CompressedTerrainFlags.Legacy : 0, ref stream );
		}
		finally
		{
			stream.Dispose();
		}
	}

	public bool Equals( TileArraySlice other )
	{
		return _array.Equals( other._array ) && _offset == other._offset && _stride == other._stride && Size.Equals( other.Size );
	}

	public override bool Equals( object? obj )
	{
		return obj is TileArraySlice other && Equals( other );
	}

	public override int GetHashCode()
	{
		return HashCode.Combine( _array, _offset, _stride, Size );
	}
}

file sealed class TileArraySliceConverter : JsonConverter<TileArraySlice>
{
	public const int CurrentVersion = 2;

	private sealed record Model( Vector2Int Size, int Version, string Tiles );

	public override TileArraySlice Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options )
	{
		return JsonSerializer.Deserialize<Model>( ref reader, options ) is { } model
			? TileArraySlice.FromBase64( model.Size, model.Version, model.Tiles )
			: new TileArraySlice( 0 );
	}

	public override void Write( Utf8JsonWriter writer, TileArraySlice value, JsonSerializerOptions options )
	{
		var model = new Model( value.Size, CurrentVersion, value.ToBase64() );

		JsonSerializer.Serialize( writer, model, options );
	}
}