Editor/TerrainLineTool.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Editor.TerrainEditor;
using Sandbox;

namespace TerrainLines;

[Title( "Lines" ), Icon( "route" ), Alias( "tools.terrain.lines" )]
[Description( "Draw terrain lines. Drag to draw; click to extend. Edit Points adjusts the preview. Apply commits, Cancel restores." )]
public sealed partial class TerrainLineTool : EditorTool
{
	internal LineSettings Settings { get; set; } = new();
	internal IReadOnlyList<Vector3> Points => _points;
	private readonly List<Vector3> _points = [];
	private readonly List<bool> _corners = [];
	private IReadOnlyList<Vector3> _path = [];
	private LineEdit _edit;
	private LineHistory _history;
	private sealed record PointState( Vector3[] Points, bool[] Corners );
	private PointState _gestureBefore;
	private bool _drawing, _dirty, _straightGesture, _editingPoints;
	private int _settingsHash, _movingPoint = -1;
	private double _nextPreview;
	private Label _status;
	private Button _apply, _cancel, _editMode;
	private Terrain _sidebarTerrain;
	private LineMaterialPicker _surfaceMaterials, _shoulderMaterials;
	private Widget _shapingSettings, _paintingSettings;
	private string _message = "Select a terrain, then drag to draw.";

	public override void OnEnabled()
	{
		_settingsHash = Settings.Fingerprint;
		// Native brush tools leave their parent's preview object alive between subtool switches.
		if ( Manager.CurrentTool is TerrainEditorTool parent ) parent.OnDisabled();
	}
	public override Widget CreateShortcutsWidget() => new LineShortcuts( this );

	private sealed class LineShortcuts : Widget
	{
		private readonly TerrainLineTool _tool;
		internal LineShortcuts( TerrainLineTool tool ) => _tool = tool;
		[Shortcut( "terrain-lines.apply", "enter", typeof( SceneViewWidget ) )]
		private void ApplyLine() => _tool.Apply();
		[Shortcut( "terrain-lines.cancel", "ESC", typeof( SceneViewWidget ) )]
		private void CancelLine() => _tool.Cancel();
	}
	public override void OnDisabled() => Cancel();
	public override void OnSelectionChanged()
	{
		if ( _edit is not null && GetSelectedComponent<Terrain>() != _edit.Terrain ) Cancel();
	}

	public override Widget CreateToolSidebar()
	{
		var sidebar = new ToolSidebarWidget();
		sidebar.AddTitle( "Terrain Lines", "route" );
		_sidebarTerrain = GetSelectedComponent<Terrain>();
		AddHeading( sidebar.Layout, "Brush" );
		AddControls( sidebar.Layout, nameof( Settings.PathWidth ), nameof( Settings.ShoulderWidth ) );
		sidebar.Layout.AddSpacingCell( 12 );
		AddControls( sidebar.Layout, nameof( Settings.ShapeTerrain ) );
		_shapingSettings = sidebar.Layout.Add( new Widget( sidebar ) { Layout = Layout.Column() } );
		AddControls( _shapingSettings.Layout, nameof( Settings.EvenRamp ), nameof( Settings.SlopeLimit ), nameof( Settings.Elevation ) );
		var curve = new ComboBox();
		foreach ( var (value, label) in new[] { (ShoulderCurve.Smooth, "Smooth"), (ShoulderCurve.Linear, "Linear"), (ShoulderCurve.RoundIn, "Round In"), (ShoulderCurve.RoundOut, "Round Out") } )
			AddCurveItem( curve, value, label );
		AddControlRow( _shapingSettings.Layout, "Shoulder Curve", curve );
		sidebar.Layout.AddSpacingCell( 12 );
		AddControls( sidebar.Layout, nameof( Settings.PaintMaterials ) );
		_paintingSettings = sidebar.Layout.Add( new Widget( sidebar ) { Layout = Layout.Column() } );
		var painting = _paintingSettings.Layout;
		AddControls( painting, nameof( Settings.PaintStrength ) );
		if ( _sidebarTerrain.IsValid() && _sidebarTerrain.Storage is not null )
		{
			painting.Add( new Label( "Surface" ) );
			_surfaceMaterials = painting.Add( new LineMaterialPicker( _paintingSettings, _sidebarTerrain, false, GetSurfaceMaterial, SetSurfaceMaterial ) );
			painting.AddSpacingCell( 8 );
			painting.Add( new Label( "Shoulder" ) );
			_shoulderMaterials = painting.Add( new LineMaterialPicker( _paintingSettings, _sidebarTerrain, true, GetShoulderMaterial, SetShoulderMaterial ) );
		}
		else painting.Add( new Label( "Select a terrain to choose materials." ) { WordWrap = true } );
		RefreshSettingsVisibility();
		AddHeading( sidebar.Layout, "Active Line" );
		var actions = sidebar.Layout;
		_editMode = actions.Add( new Button( "Edit Points" ) );
		_editMode.Clicked = TogglePointEditing;
		var row = actions.AddRow();
		_apply = row.Add( new Button( "Apply" ) );
		_apply.Clicked = Apply;
		_cancel = row.Add( new Button( "Cancel" ) );
		_cancel.Clicked = Cancel;
		_status = actions.Add( new Label( _message ) { WordWrap = true } );
		sidebar.Layout.Add( new Label( "Drag: freehand\nClick: extend straight\nShift + drag: straight segment\nEdit Points: drag points over terrain\nUndo/Redo: preview gestures\nEnter: apply    Escape: cancel" ) { WordWrap = true } );
		sidebar.Layout.AddStretchCell();
		RefreshStatus();
		return sidebar;
	}

	private static int GetSurfaceMaterial() => PaintTextureTool.SplatChannel;
	private static void SetSurfaceMaterial( int index ) => PaintTextureTool.SplatChannel = index;
	private int GetShoulderMaterial() => Settings.ShoulderMaterial;
	private void SetShoulderMaterial( int index ) => Settings.ShoulderMaterial = index;
	private void TogglePointEditing() { EndGesture(); _editingPoints = !_editingPoints; RefreshStatus(); }
	private void AddCurveItem( ComboBox combo, ShoulderCurve value, string label )
		=> combo.AddItem( label, onSelected: () => Settings.ShoulderCurve = value, selected: Settings.ShoulderCurve == value );

	private static void AddHeading( Layout layout, string title )
	{
		layout.AddSpacingCell( 12 );
		layout.Add( new Label.Header( title ) );
	}

	private static void AddControlRow( Layout layout, string title, Widget control, string tooltip = null )
	{
		var container = layout.Add( new Widget { FixedHeight = Theme.RowHeight, Layout = Layout.Row() } );
		var row = container.Layout;
		row.Spacing = 6;
		row.Add( new Label( title ) { FixedWidth = 108, MinimumHeight = Theme.RowHeight, ToolTip = tooltip } );
		control.ToolTip = tooltip;
		control.FixedHeight = Theme.RowHeight;
		if ( control is BoolControlWidget )
		{
			control.FixedWidth = Theme.RowHeight;
			row.Add( control );
			row.AddStretchCell();
		}
		else
		{
			control.HorizontalSizeMode = SizeMode.Flexible;
			row.Add( control, 1 );
		}
	}

	private void AddControls( Layout layout, params string[] names )
	{
		var serialized = Settings.GetSerialized();
		foreach ( var name in names )
		{
			var property = serialized.GetProperty( name );
			var control = ControlWidget.Create( property );
			if ( property.TryGetAttribute<RangeAttribute>( out _ ) )
			{
				layout.Add( new Label( property.DisplayName ) { FixedHeight = Theme.RowHeight, ToolTip = property.Description } );
				control.FixedHeight = Theme.RowHeight;
				control.ToolTip = property.Description;
				layout.Add( control );
			}
			else AddControlRow( layout, property.DisplayName, control, property.Description );
		}
	}

	internal void RefreshSettingsVisibility()
	{
		if ( _shapingSettings.IsValid() ) _shapingSettings.Visible = Settings.ShapeTerrain;
		if ( _paintingSettings.IsValid() ) _paintingSettings.Visible = Settings.PaintMaterials;
	}

	internal void SyncSurfaceMaterial()
	{
		Settings.SurfaceMaterial = PaintTextureTool.SplatChannel;
		if ( _surfaceMaterials.IsValid() ) _surfaceMaterials.Refresh();
		if ( _shoulderMaterials.IsValid() ) _shoulderMaterials.Refresh();
	}

	public override void OnUpdate()
	{
		RefreshSettingsVisibility();
		var terrain = GetSelectedComponent<Terrain>();
		if ( _edit is not null && (!_edit.IsCurrent || terrain != _edit.Terrain || Scene.IsEditor == false) )
		{
			Cancel();
			_message = "Terrain changed; the preview was cancelled.";
		}
		if ( !Usable( terrain ) )
		{
			_message = "Select one native terrain with a positive uniform scale.";
			RefreshStatus();
			return;
		}
		SyncSurfaceMaterial();
		if ( Settings.Fingerprint != _settingsHash )
		{
			_settingsHash = Settings.Fingerprint;
			_dirty = true;
		}
		bool hitTerrain = terrain.RayIntersects( Gizmo.CurrentRay, Gizmo.RayDepth, out var hit );
		if ( _editingPoints ) UpdatePointEditing( terrain, hitTerrain, hit );
		else if ( Gizmo.HasMouseFocus && !Gizmo.IsAltPressed && !Gizmo.IsRightMouseDown )
		{
			if ( Gizmo.WasLeftMousePressed && hitTerrain )
			{
				BeginGesture( terrain );
				_drawing = true;
				_straightGesture = Gizmo.IsShiftPressed;
				if ( !_straightGesture || _points.Count == 0 ) AddPoint( hit, true );
			}
			if ( _drawing && Gizmo.IsLeftMouseDown && hitTerrain && !_straightGesture ) AddPoint( hit, false );
			if ( _drawing && Gizmo.WasLeftMouseReleased )
			{
				if ( hitTerrain ) AddPoint( hit, true );
				EndGesture();
			}
		}
		if ( _drawing && !Gizmo.IsLeftMouseDown ) EndGesture();
		if ( _dirty && _edit is not null && RealTime.Now >= _nextPreview ) RebuildPreview();
		DrawGuide( terrain, hitTerrain, hit );
		RefreshStatus();
	}

	internal static bool Usable( Terrain terrain )
	{
		if ( !terrain.IsValid() || terrain.Storage is null || !terrain.Scene.IsEditor ) return false;
		var s = terrain.WorldScale;
		return float.IsFinite( s.x ) && s.x > 0 && Math.Abs( s.x - s.y ) < 0.0001f && Math.Abs( s.x - s.z ) < 0.0001f;
	}

	internal void Begin( Terrain terrain )
	{
		if ( _edit is not null ) return;
		if ( !Usable( terrain ) ) throw new InvalidOperationException( "Select an editable terrain." );
		_edit = new LineEdit( terrain );
		_history = new LineHistory( Manager.CurrentSession );
		_message = "Draw or extend the active line.";
	}

	internal void BeginGesture( Terrain terrain )
	{
		Begin( terrain );
		_gestureBefore = new PointState( _points.ToArray(), _corners.ToArray() );
	}

	internal void EndGesture( string name = "Extend terrain line" )
	{
		_drawing = false;
		if ( _gestureBefore is null ) return;
		var before = _gestureBefore;
		var after = new PointState( _points.ToArray(), _corners.ToArray() );
		_gestureBefore = null;
		if ( !before.Points.SequenceEqual( after.Points ) || !before.Corners.SequenceEqual( after.Corners ) )
			_history.Push( name, () => RestorePoints( before ), () => RestorePoints( after ) );
	}

	private void RestorePoints( PointState state )
	{
		if ( _edit is null || !_edit.IsCurrent ) return;
		_points.Clear();
		_points.AddRange( state.Points );
		_corners.Clear();
		_corners.AddRange( state.Corners );
		_gestureBefore = null;
		_drawing = false;
		_movingPoint = -1;
		RebuildPreview();
		RefreshStatus();
	}

	internal void AddPoint( Vector3 point, bool endpoint )
	{
		if ( _edit is null || !_edit.IsCurrent ) return;
		var storage = _edit.Terrain.Storage;
		point.z = LineRaster.SampleHeight( storage, point.x, point.y );
		if ( _points.Count > 0 )
		{
			float length = LineRaster.Distance2D( _points[^1], point );
			float spacing = Math.Max( storage.TerrainSize / storage.Resolution * 0.5f, Math.Min( Settings.PathWidth / 8, 32 ) / _edit.Terrain.WorldScale.x );
			if ( length < (endpoint ? 0.001f : spacing) )
			{
				if ( endpoint ) { _corners[^1] = true; _dirty = true; }
				return;
			}
		}
		// Retain authoring points separately from the smoothed freehand path.
		_points.Add( point );
		_corners.Add( endpoint );
		_dirty = true;
	}

	internal void MovePoint( int index, Vector3 point )
	{
		if ( _edit is null || !_edit.IsCurrent || index < 0 || index >= _points.Count ) return;
		var storage = _edit.Terrain.Storage;
		point.z = LineRaster.SampleHeight( storage, point.x, point.y );
		_points[index] = point;
		_dirty = true;
	}

	internal void RebuildPreview()
	{
		_dirty = false;
		_nextPreview = RealTime.Now + 0.08;
		try
		{
			_path = LinePath.Build( _points, _corners, _edit.Terrain.Storage.TerrainSize / _edit.Terrain.Storage.Resolution );
			_edit.Preview( _path, Settings );
			_message = _points.Count < 2 ? "Extend the line to preview." : _edit.HasChanges ? "Preview ready. Apply to keep this line." : "This line makes no terrain changes.";
		}
		catch ( Exception e )
		{
			Cancel();
			_message = e.Message;
			Log.Warning( $"Terrain Lines: {e.Message}" );
		}
	}

	public void Apply()
	{
		if ( _edit is null ) return;
		EndGesture();
		SyncSurfaceMaterial();
		RebuildPreview();
		if ( _edit is null ) return;
		try
		{
			_history.Clear();
			bool applied = _edit.Apply( Manager.CurrentSession );
			Cancel();
			_message = applied ? "Line applied. Draw the next line." : "No changes to apply.";
		}
		catch ( Exception e )
		{
			Cancel();
			_message = e.Message;
			Log.Warning( $"Terrain Lines: {e.Message}" );
		}
		RefreshStatus();
	}

	public void Cancel()
	{
		_history?.Clear();
		_history = null;
		_edit?.Dispose();
		_edit = null;
		_points.Clear();
		_corners.Clear();
		_path = [];
		_gestureBefore = null;
		_drawing = _dirty = _editingPoints = false;
		_movingPoint = -1;
		_message = "Drag to draw a new line.";
		RefreshStatus();
	}

	private void RefreshStatus()
	{
		if ( _status.IsValid() ) _status.Text = _message;
		if ( _apply.IsValid() ) _apply.Enabled = _points.Count > 1 && (_edit?.HasChanges ?? false);
		if ( _cancel.IsValid() ) _cancel.Enabled = _edit is not null;
		if ( _editMode.IsValid() )
		{
			_editMode.Enabled = _points.Count > 0;
			_editMode.Text = _editingPoints ? "Continue Drawing" : "Edit Points";
		}
	}
}