Editor/LineEdit.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;

namespace TerrainLines;

internal sealed class LineEdit : IDisposable
{
	internal readonly Terrain Terrain;
	private readonly TerrainStorage _storage;
	private readonly ushort[] _heightMap;
	private readonly uint[] _controlMap;
	private readonly Transform _transform;
	private readonly int _resolution;
	private readonly float _size, _height;
	private List<LineTile> _tiles = [];
	private readonly Dictionary<(int, int), LineTile> _previewTiles = new();
	private readonly Func<int, int, ushort> _readPreview;
	private bool _disposed;
	internal bool HasChanges => _tiles.Count > 0;
	internal bool IsCurrent => !_disposed && Terrain.IsValid() && ReferenceEquals( Terrain.Storage, _storage )
		&& ReferenceEquals( _storage.HeightMap, _heightMap ) && ReferenceEquals( _storage.ControlMap, _controlMap )
		&& _storage.Resolution == _resolution && _storage.TerrainSize == _size && _storage.TerrainHeight == _height
		&& Terrain.WorldTransform == _transform;

	internal LineEdit( Terrain terrain )
	{
		Terrain = terrain;
		_readPreview = ReadPreview;
		_storage = terrain.Storage;
		_heightMap = _storage.HeightMap;
		_controlMap = _storage.ControlMap;
		_resolution = _storage.Resolution;
		_size = _storage.TerrainSize;
		_height = _storage.TerrainHeight;
		_transform = terrain.WorldTransform;
		terrain.OnTerrainModified += TerrainModified;
	}

	internal void Preview( IReadOnlyList<Vector3> points, LineSettings settings )
	{
		if ( !IsCurrent ) throw new InvalidOperationException( "The terrain changed. Start a new line." );
		RestorePreview();
		_tiles = LineRaster.Build( _storage, points, settings, Terrain.WorldScale.x );
		_previewTiles.Clear();
		foreach ( var tile in _tiles )
		{
			_previewTiles.Add( (tile.X / LineRaster.TileSize, tile.Y / LineRaster.TileSize), tile );
			tile.UploadPreview( Terrain );
		}
	}

	internal float SampleHeight( float x, float y ) => LineRaster.SampleHeight( _storage, x, y, _readPreview );

	private ushort ReadPreview( int x, int y )
	{
		if ( !_disposed && _previewTiles.TryGetValue( (x / LineRaster.TileSize, y / LineRaster.TileSize), out var tile ) && tile.AfterHeight is not null )
			return tile.AfterHeight[(y - tile.Y) * tile.Width + x - tile.X];
		return _storage.HeightMap[y * _resolution + x];
	}

	internal bool Apply( SceneEditorSession session )
	{
		if ( !IsCurrent || !ReferenceEquals( session?.Scene, Terrain.Scene ) )
			throw new InvalidOperationException( "The selected terrain or scene changed. Start a new line." );
		if ( _tiles.Any( tile => !tile.Matches( _storage ) ) )
			throw new InvalidOperationException( "Terrain data changed during the preview. Cancel and draw again." );
		if ( !HasChanges ) { Dispose(); return false; }
		var delta = new LineDelta( Terrain, _tiles );
		// Detach before native synchronization raises OnTerrainModified.
		Terrain.OnTerrainModified -= TerrainModified;
		_disposed = true;
		_tiles = [];
		_previewTiles.Clear();
		delta.Write( true );
		session.AddUndo( "Terrain Lines", () => delta.Write( false ), () => delta.Write( true ) );
		session.HasUnsavedChanges = true;
		return true;
	}

	private void TerrainModified( Terrain.SyncFlags flags, RectInt region ) => Dispose();

	private void RestorePreview()
	{
		if ( !Terrain.IsValid() || !ReferenceEquals( Terrain.Storage, _storage ) || _storage.Resolution != _resolution ) return;
		foreach ( var tile in _tiles ) tile.RestorePreview( Terrain );
	}

	public void Dispose()
	{
		if ( _disposed ) return;
		if ( Terrain.IsValid() ) Terrain.OnTerrainModified -= TerrainModified;
		RestorePreview();
		_tiles.Clear();
		_previewTiles.Clear();
		_disposed = true;
	}
}

internal sealed class LineDelta
{
	private readonly Terrain _terrain;
	private readonly TerrainStorage _storage;
	private readonly int _resolution;
	private readonly List<LineTile> _tiles;
	private readonly RectInt _region;
	private readonly Terrain.SyncFlags _flags;

	internal LineDelta( Terrain terrain, List<LineTile> tiles )
	{
		_terrain = terrain;
		_storage = terrain.Storage;
		_resolution = _storage.Resolution;
		_tiles = tiles;
		int x = tiles.Min( t => t.X ), y = tiles.Min( t => t.Y );
		_region = new RectInt( x, y, tiles.Max( t => t.X + t.Width ) - x, tiles.Max( t => t.Y + t.Height ) - y );
		foreach ( var tile in tiles ) _flags |= tile.Flags;
	}

	internal void Write( bool after )
	{
		if ( !_terrain.IsValid() || !ReferenceEquals( _terrain.Storage, _storage ) || _storage.Resolution != _resolution ) return;
		foreach ( var tile in _tiles ) tile.WriteStorage( _storage, after );
		// One native synchronization updates collision, normals, storage and terrain-dependent systems.
		_terrain.ApplyStorageChanges( _flags, _region );
	}
}