Editor/FenceLibrary/FenceTool.cs
using System;
using System.Collections.Generic;

namespace Editor;

[EditorTool( "tools.fence-tool" )]
[Title( "Fence" )]
[Icon( "fence" )]
[Group( "Scene" )]
public sealed class FenceTool : EditorTool
{
	private const string DefinitionCookie = "FenceTool.Definition";
	private readonly FenceToolSettings settings = new();
	private readonly List<FencePrototype> prototypes = [];
	private readonly List<FenceSplineControlPoint> splinePoints = [];

	private GameObject previewRoot;
	private FencePlacementPlan currentPlan;
	private Vector3? startPoint;
	private Vector3 currentHoverPoint;
	private Vector3 currentHoverNormal = Vector3.Up;
	private bool hasHoverPoint;
	private bool isDrawingSpline;
	private bool escapeWasDown;
	private int lastDefinitionHash;
	private FenceToolPlacementMode lastMode;
	private Vector3 lastPreviewStart;
	private Vector3 lastPreviewEnd;
	private int lastPreviewPathHash;
	private int lastPreviewSettingsHash;
	private string persistedDefinitionPath;

	public FenceTool()
	{
		RebuildSidebarOnSelectionChange = false;
		AllowContextMenu = false;
		settings.Definition = LoadSavedDefinition();
		persistedDefinitionPath = settings.Definition?.ResourcePath ?? string.Empty;
		lastMode = settings.Mode;
	}

	public override Widget CreateToolSidebar()
	{
		var sidebar = new ToolSidebarWidget();
		sidebar.MinimumWidth = 308;
		sidebar.Layout.Spacing = 4;
		sidebar.AddTitle( "Fence Placement", "fence" );

		var serialized = settings.GetSerialized();
		var definitionGroup = sidebar.AddGroup( "Definition" );
		definitionGroup.Spacing = 2;
		definitionGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.Definition ) ) ) );

		var settingsGroup = sidebar.AddGroup( "Placement" );
		settingsGroup.Spacing = 2;
		settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.Mode ) ) ) );
		settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.SnapToGround ) ) ) );
		settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.GroundTraceHeight ) ) ) );
		settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.SurfaceTag ) ) ) );
		settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.KeepUpright ) ) ) );
		settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.SplinePointSpacing ) ) ) );
		settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.SplineInterpolationSteps ) ) ) );
		settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.Seed ) ) ) );
		settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.IncrementSeedAfterPlacement ) ) ) );

		var usageGroup = sidebar.AddGroup( "Usage" );
		var usage = new Label( "Line: LMB start, LMB place. Spline: hold LMB and drag, release to place. Esc cancel.", sidebar );
		usage.WordWrap = true;
		usageGroup.Add( usage );

		var actionsGroup = sidebar.AddGroup( "Actions" );
		var cancelButton = new Button( "Reset Current Run", "close" );
		cancelButton.Clicked += CancelCurrentRun;
		actionsGroup.Add( cancelButton );

		sidebar.Layout.AddStretchCell();

		return sidebar;
	}

	public override void OnUpdate()
	{
		Gizmo.Hitbox.BBox( BBox.FromPositionAndSize( Vector3.Zero, 999999.0f ) );
		SyncSavedDefinition();

		RefreshPrototypesIfNeeded();
		UpdateHoverPoint();

		if ( settings.Mode != lastMode )
		{
			lastMode = settings.Mode;
			CancelCurrentRun();
		}

		DrawGuidePreview();

		var escapeDown = Application.IsKeyDown( KeyCode.Escape );
		if ( escapeDown && !escapeWasDown )
		{
			escapeWasDown = true;
			CancelCurrentRun();
			return;
		}
		escapeWasDown = escapeDown;

		if ( settings.Mode == FenceToolPlacementMode.Spline )
		{
			UpdateSplineMode();
			return;
		}

		UpdateLineMode();
	}

	private void UpdateLineMode()
	{
		if ( startPoint.HasValue )
		{
			UpdateLinePlacementPreview();
		}
		else
		{
			ClearPreviewRoot();
			currentPlan = null;
		}

		if ( !Gizmo.WasLeftMousePressed || !hasHoverPoint )
			return;

		if ( !startPoint.HasValue )
		{
			startPoint = currentHoverPoint;
			lastPreviewStart = Vector3.Zero;
			lastPreviewEnd = Vector3.Zero;
			return;
		}

		if ( currentPlan is null || currentPlan.Segments.Count == 0 )
			return;

		CommitCurrentPlan();
	}

	private void UpdateSplineMode()
	{
		if ( Gizmo.WasLeftMousePressed && hasHoverPoint )
		{
			BeginSplineRun();
		}

		if ( isDrawingSpline && hasHoverPoint )
		{
			AddSplinePoint( currentHoverPoint, currentHoverNormal, force: false );
			UpdateSplinePlacementPreview( includeHoverPoint: true );
		}
		else if ( !isDrawingSpline )
		{
			ClearPreviewRoot();
			currentPlan = null;
		}

		if ( isDrawingSpline && (Gizmo.WasLeftMouseReleased || (!Gizmo.IsLeftMouseDown && !Gizmo.WasLeftMousePressed)) )
		{
			FinishSplineRun();
		}
	}

	public override void OnDisabled()
	{
		SyncSavedDefinition();
		CancelCurrentRun();
	}

	[Shortcut( "tools.fence-tool", "CTRL+SHIFT+B", typeof( SceneViewWidget ) )]
	public static void ActivateTool()
	{
		if ( EditorToolManager.CurrentModeName == nameof( FenceTool ) )
			return;

		EditorToolManager.SetTool( nameof( FenceTool ) );
	}

	private void RefreshPrototypesIfNeeded()
	{
		var definitionHash = FenceDefinitionEditorUtility.ComputeDefinitionHash( settings.Definition );
		if ( definitionHash == lastDefinitionHash )
			return;

		lastDefinitionHash = definitionHash;
		prototypes.Clear();

		if ( settings.Definition is null )
			return;

		using var sceneScope = Scene.Push();
		prototypes.AddRange( FenceDefinitionEditorUtility.BuildPrototypes( settings.Definition ) );
		lastPreviewSettingsHash = 0;
	}

	private void UpdateHoverPoint()
	{
		hasHoverPoint = false;

		var trace = Scene.Trace.Ray( Gizmo.CurrentRay, Gizmo.RayDepth )
			.UseRenderMeshes( true )
			.UsePhysicsWorld( false )
			.WithoutTags( FenceDefinitionEditorUtility.PreviewTag )
			.WithoutTags( "hidden" );

		var result = default(SceneTraceResult);
		if ( !string.IsNullOrWhiteSpace( settings.SurfaceTag ) )
		{
			result = trace.WithTag( settings.SurfaceTag ).Run();
		}

		if ( !result.Hit )
		{
			result = trace.Run();
		}

		if ( !result.Hit )
			return;

		currentHoverPoint = result.HitPosition;
		currentHoverNormal = result.Normal.Length < 0.001f ? Vector3.Up : result.Normal.Normal;
		hasHoverPoint = true;
	}

	private void UpdateLinePlacementPreview()
	{
		if ( settings.Definition is null || prototypes.Count == 0 || !hasHoverPoint )
		{
			ClearPreviewRoot();
			currentPlan = null;
			return;
		}

		var settingsHash = HashCode.Combine(
			lastDefinitionHash,
			settings.Mode,
			settings.SnapToGround,
			settings.GroundTraceHeight,
			settings.SurfaceTag ?? string.Empty,
			settings.KeepUpright,
			settings.Seed );

		var start = startPoint.Value;
		var end = currentHoverPoint;
		if ( currentPlan is not null
			&& lastPreviewSettingsHash == settingsHash
			&& Vector3.DistanceBetween( lastPreviewStart, start ) < 0.01f
			&& Vector3.DistanceBetween( lastPreviewEnd, end ) < 0.5f )
		{
			return;
		}

		using var sceneScope = Scene.Push();
		currentPlan = FenceDefinitionEditorUtility.BuildPlacementPlan( settings.Definition, prototypes, settings, Scene, start, end );

		if ( !FenceDefinitionEditorUtility.TryUpdatePreviewHierarchy( previewRoot, currentPlan ) )
		{
			ClearPreviewRoot();
			previewRoot = FenceDefinitionEditorUtility.SpawnPreviewHierarchy( currentPlan );
		}
		lastPreviewStart = start;
		lastPreviewEnd = end;
		lastPreviewSettingsHash = settingsHash;
	}

	private void UpdateSplinePlacementPreview( bool includeHoverPoint )
	{
		var points = BuildActiveSplinePoints( includeHoverPoint );
		if ( settings.Definition is null || prototypes.Count == 0 || points.Count < 2 )
		{
			ClearPreviewRoot();
			currentPlan = null;
			return;
		}

		var settingsHash = HashCode.Combine(
			lastDefinitionHash,
			settings.Mode,
			settings.KeepUpright,
			Math.Clamp( settings.SplineInterpolationSteps, 1, 32 ),
			settings.Seed );

		var pathHash = ComputeSplinePointHash( points );
		if ( currentPlan is not null
			&& lastPreviewSettingsHash == settingsHash
			&& lastPreviewPathHash == pathHash )
		{
			return;
		}

		using var sceneScope = Scene.Push();
		currentPlan = FenceDefinitionEditorUtility.BuildSplinePlacementPlan( settings.Definition, prototypes, settings, Scene, points );

		if ( !FenceDefinitionEditorUtility.TryUpdatePreviewHierarchy( previewRoot, currentPlan ) )
		{
			ClearPreviewRoot();
			previewRoot = FenceDefinitionEditorUtility.SpawnPreviewHierarchy( currentPlan );
		}

		lastPreviewPathHash = pathHash;
		lastPreviewSettingsHash = settingsHash;
	}

	private void BeginSplineRun()
	{
		isDrawingSpline = true;
		startPoint = null;
		splinePoints.Clear();
		ResetPreviewCache();
		AddSplinePoint( currentHoverPoint, currentHoverNormal, force: true );
	}

	private void FinishSplineRun()
	{
		if ( hasHoverPoint )
		{
			AddSplinePoint( currentHoverPoint, currentHoverNormal, force: true );
		}

		UpdateSplinePlacementPreview( includeHoverPoint: false );
		isDrawingSpline = false;

		if ( currentPlan is not null && currentPlan.Segments.Count > 0 )
		{
			CommitCurrentPlan();
			return;
		}

		CancelCurrentRun();
	}

	private void AddSplinePoint( Vector3 position, Vector3 normal, bool force )
	{
		var resolvedNormal = normal.Length < 0.001f ? Vector3.Up : normal.Normal;
		var point = new FenceSplineControlPoint( position, resolvedNormal );
		if ( splinePoints.Count == 0 )
		{
			splinePoints.Add( point );
			return;
		}

		var minimumSpacing = Math.Max( settings.SplinePointSpacing, 1.0f );
		var distance = Vector3.DistanceBetween( splinePoints[^1].Position, position );
		if ( !force && distance < minimumSpacing )
			return;

		if ( distance < 0.01f )
			return;

		splinePoints.Add( point );
	}

	private List<FenceSplineControlPoint> BuildActiveSplinePoints( bool includeHoverPoint )
	{
		var points = new List<FenceSplineControlPoint>( splinePoints );
		if ( includeHoverPoint && hasHoverPoint )
		{
			var normal = currentHoverNormal.Length < 0.001f ? Vector3.Up : currentHoverNormal.Normal;
			var hoverPoint = new FenceSplineControlPoint( currentHoverPoint, normal );
			if ( points.Count == 0 || Vector3.DistanceBetween( points[^1].Position, hoverPoint.Position ) > 0.01f )
			{
				points.Add( hoverPoint );
			}
		}

		return points;
	}

	private static int ComputeSplinePointHash( IReadOnlyList<FenceSplineControlPoint> points )
	{
		var hash = new HashCode();
		hash.Add( points?.Count ?? 0 );

		if ( points is not null )
		{
			foreach ( var point in points )
			{
				hash.Add( MathF.Round( point.Position.x, 2 ) );
				hash.Add( MathF.Round( point.Position.y, 2 ) );
				hash.Add( MathF.Round( point.Position.z, 2 ) );
				hash.Add( MathF.Round( point.Normal.x, 3 ) );
				hash.Add( MathF.Round( point.Normal.y, 3 ) );
				hash.Add( MathF.Round( point.Normal.z, 3 ) );
			}
		}

		return hash.ToHashCode();
	}

	private void DrawGuidePreview()
	{
		if ( hasHoverPoint )
		{
			using ( Gizmo.Scope( "FenceHover" ) )
			{
				Gizmo.Draw.Color = Color.White.WithAlpha( 0.8f );
				Gizmo.Draw.LineBBox( BBox.FromPositionAndSize( currentHoverPoint, 2.0f ) );
			}
		}

		if ( !startPoint.HasValue )
		{
			if ( settings.Mode == FenceToolPlacementMode.Spline )
			{
				DrawSplineGuidePreview();
			}

			return;
		}

		if ( settings.Mode != FenceToolPlacementMode.Line )
			return;

		using ( Gizmo.Scope( "FenceRun" ) )
		{
			Gizmo.Draw.Color = Color.Parse( "#f8a64c" ) ?? Color.Orange;
			Gizmo.Draw.LineBBox( BBox.FromPositionAndSize( startPoint.Value, 2.5f ) );

			if ( hasHoverPoint )
			{
				Gizmo.Draw.Line( startPoint.Value, currentHoverPoint );
			}
		}
	}

	private void DrawSplineGuidePreview()
	{
		var points = BuildActiveSplinePoints( includeHoverPoint: isDrawingSpline );
		if ( points.Count == 0 )
			return;

		using ( Gizmo.Scope( "FenceSplineRun" ) )
		{
			Gizmo.Draw.Color = Color.Parse( "#f8a64c" ) ?? Color.Orange;
			Gizmo.Draw.LineBBox( BBox.FromPositionAndSize( points[0].Position, 2.5f ) );

			var samples = FenceDefinitionEditorUtility.BuildSplinePreviewPath( points, settings.SplineInterpolationSteps );
			for ( var index = 1; index < samples.Count; index++ )
			{
				Gizmo.Draw.Line( samples[index - 1].Position, samples[index].Position );
			}
		}
	}

	private void CommitCurrentPlan()
	{
		using var sessionScope = SceneEditorSession.Scope();
		using var sceneScope = Scene.Push();
		using var undoScope = SceneEditorSession.Active.UndoScope( "Place Fence" ).WithGameObjectCreations().Push();

		var placedRoot = FenceDefinitionEditorUtility.CommitPlacementPlan( currentPlan );
		if ( !placedRoot.IsValid() )
			return;

		EditorScene.Selection.Clear();
		EditorScene.Selection.Add( placedRoot );

		if ( settings.IncrementSeedAfterPlacement )
		{
			settings.Seed++;
		}

		CancelCurrentRun();
	}

	private void CancelCurrentRun()
	{
		startPoint = null;
		isDrawingSpline = false;
		splinePoints.Clear();
		currentPlan = null;
		ResetPreviewCache();
		ClearPreviewRoot();
	}

	private void ResetPreviewCache()
	{
		lastPreviewStart = Vector3.Zero;
		lastPreviewEnd = Vector3.Zero;
		lastPreviewPathHash = 0;
		lastPreviewSettingsHash = 0;
	}

	private void ClearPreviewRoot()
	{
		FenceDefinitionEditorUtility.DestroyHierarchy( previewRoot );
		previewRoot = null;
	}

	private void SyncSavedDefinition()
	{
		var currentPath = settings.Definition?.ResourcePath ?? string.Empty;
		if ( string.Equals( currentPath, persistedDefinitionPath, StringComparison.Ordinal ) )
			return;

		persistedDefinitionPath = currentPath;
		ProjectCookie.Set( DefinitionCookie, persistedDefinitionPath );
	}

	private static FenceDefinition LoadSavedDefinition()
	{
		var savedPath = ProjectCookie.Get( DefinitionCookie, string.Empty );
		if ( string.IsNullOrWhiteSpace( savedPath ) )
			return null;

		var asset = AssetSystem.FindByPath( savedPath );
		return asset?.LoadResource<FenceDefinition>();
	}
}

file sealed class FenceSidebarField : Widget
{
	public FenceSidebarField( SerializedProperty property ) : base( null )
	{
		SetSizeMode( SizeMode.Expand, SizeMode.CanShrink );
		VerticalSizeMode = SizeMode.CanShrink;

		Layout = Layout.Column();
		Layout.Spacing = 2;
		Layout.Margin = new Sandbox.UI.Margin( 2, 3, 2, 5 );

		var title = Layout.Add( new Label( GetLabelText( property ), this ) );
		title.HorizontalSizeMode = SizeMode.Expand;
		title.WordWrap = false;
		title.Alignment = TextFlag.LeftTop;
		title.SetStyles( "font-size: 11px; font-weight: 500; color: rgba(255,255,255,0.72);" );

		if ( !string.IsNullOrWhiteSpace( property.Description ) )
		{
			title.ToolTip = property.Description;
		}

		var editor = ControlWidget.Create( property );
		editor.HorizontalSizeMode = property.PropertyType == typeof( bool ) ? SizeMode.Default : SizeMode.Flexible;
		editor.VerticalSizeMode = SizeMode.CanShrink;
		editor.MinimumHeight = 0;
		editor.FixedHeight = Theme.ControlHeight;

		if ( property.PropertyType == typeof( bool ) )
		{
			editor.FixedWidth = Theme.ControlHeight;
			var row = Layout.AddRow();
			row.Add( editor );
			row.AddStretchCell();
			return;
		}

		Layout.Add( editor );
	}

	private static string GetLabelText( SerializedProperty property )
	{
		return property.Name switch
		{
			nameof( FenceToolSettings.SnapToGround ) => "Ground Snap",
			nameof( FenceToolSettings.KeepUpright ) => "Upright",
			nameof( FenceToolSettings.SplinePointSpacing ) => "Spline Point Spacing",
			nameof( FenceToolSettings.SplineInterpolationSteps ) => "Spline Smoothing",
			nameof( FenceToolSettings.IncrementSeedAfterPlacement ) => "Advance Seed",
			_ => property.DisplayName
		};
	}
}