Editor/Designers/ArchPrefabAiming.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

// Every mount is read in the same cardinal frame, whichever of its axes means what: a cabinet's +x is out of the
// face, a door leaf's is across the opening, and squaring a picked edge only ever asks which axis it lies nearest.
public static class ArchAimedPrefabMount {
	public static readonly Vector3[] Axes = { Vector3.Forward, Vector3.Left, Vector3.Up };

	public static BBox Of( float depth, float width, float height ) {
		return new BBox( new Vector3( 0f, 0f, -MathF.Max( 1f, height ) ),
			new Vector3( MathF.Max( 0.25f, depth ), MathF.Max( 1f, width ), 0f ) );
	}
}

// The room a prefab is designed against, so the author judges their art at the size it will actually land rather
// than floating on its own. A front gets the standard base-unit opening; a whole carcass gets the whole module,
// because aiming a cabinet inside a door-sized box would put every anchor in the wrong place.
public static class ArchCabinetFrontMount {
	public const float Depth = 1.25f;
	public const float Width = 23.5f;
	public const float Height = 26f;

	// The mount's own frame, laid out at the world origin: +x out of the face, +y along the run, -z down from the
	// top. Identical to ArchCabinetShape's, which is what lets the designer preview the real landing.
	public static BBox Room => ArchAimedPrefabMount.Of( Depth, Width, Height );
}

// One prefab's triangles, edges and corners in the prefab's OWN space, ready to be aimed at. Built off the model
// buffers rather than physics, because a front an author drew may carry no collider at all.
public sealed class ArchAimedPrefabGeometry {
	public List<Vector3> A { get; } = new();
	public List<Vector3> B { get; } = new();
	public List<Vector3> C { get; } = new();

	public BBox Bounds { get; private set; }

	public bool Standing => A.Count > 0;

	public static ArchAimedPrefabGeometry Of( GameObject root ) {
		var built = new ArchAimedPrefabGeometry();

		if ( !root.IsValid() ) {
			return built;
		}

		var origin = root.WorldTransform;
		var found = false;
		var bounds = default( BBox );

		foreach ( var renderer in root.Components.GetAll<ModelRenderer>( FindMode.EnabledInSelfAndDescendants ) ) {
			if ( renderer.Model is not { } model ) {
				continue;
			}

			built.Take( model, origin.ToLocal( renderer.WorldTransform ), ref bounds, ref found );
		}

		built.Bounds = found ? bounds : BBox.FromPositionAndSize( Vector3.Zero, 16f );

		return built;
	}

	// Indices are per-mesh local when they stay under that mesh's vertex count, else already global - the same
	// auto-detect the hide-mask painter needs, and the same reason: nothing in the buffers says which it is.
	void Take( Model model, Transform placed, ref BBox bounds, ref bool found ) {
		var vertices = model.GetVertices();
		var indices = model.GetIndices();

		if ( vertices is null || indices is null ) {
			return;
		}

		var vertexOffset = 0;
		var triangleOffset = 0;

		foreach ( var info in model.MeshInfo.Meshes ) {
			var start = triangleOffset * 3;
			var end = Math.Min( indices.Length, start + info.Triangles * 3 );

			uint highest = 0;

			for ( var index = start; index < end; index++ ) {
				highest = Math.Max( highest, indices[index] );
			}

			var origin = highest < (uint)info.Vertices ? vertexOffset : 0;

			for ( var index = start; index + 2 < end; index += 3 ) {
				var one = placed.PointToWorld( vertices[origin + indices[index]].Position );
				var two = placed.PointToWorld( vertices[origin + indices[index + 1]].Position );
				var three = placed.PointToWorld( vertices[origin + indices[index + 2]].Position );

				A.Add( one );
				B.Add( two );
				C.Add( three );

				foreach ( var point in new[] { one, two, three } ) {
					bounds = found ? bounds.AddPoint( point ) : BBox.FromPositionAndSize( point, 0f );
					found = true;
				}
			}

			vertexOffset += info.Vertices;
			triangleOffset += info.Triangles;
		}
	}

	// The aim SNAPS: whichever corner or edge of the triangle under the cursor is nearest the hit wins, so the
	// author points roughly and lands exactly on the feature they meant.
	public bool Aim( Vector3 origin, Vector3 direction, out Vector3 anchor, out Vector3 along ) {
		anchor = Vector3.Zero;
		along = Vector3.Zero;

		var nearest = float.MaxValue;
		var found = -1;
		var landed = Vector3.Zero;

		for ( var triangle = 0; triangle < A.Count; triangle++ ) {
			if ( !Hits( origin, direction, A[triangle], B[triangle], C[triangle], out var distance ) || distance >= nearest ) {
				continue;
			}

			nearest = distance;
			found = triangle;
			landed = origin + direction * distance;
		}

		if ( found < 0 ) {
			return false;
		}

		Snap( landed, A[found], B[found], C[found], out anchor, out along );

		return true;
	}

	// A corner wins over the edge it sits on whenever the aim is inside the corner's own share of it, which is what
	// stops a vertex being unreachable on a long edge.
	static void Snap( Vector3 landed, Vector3 one, Vector3 two, Vector3 three, out Vector3 anchor, out Vector3 along ) {
		var corners = new[] { one, two, three };
		var closest = corners.OrderBy( corner => corner.Distance( landed ) ).First();
		var edges = new[] { (one, two), (two, three), (three, one) };
		var pick = edges.OrderBy( edge => OnEdge( landed, edge.Item1, edge.Item2 ).Distance( landed ) ).First();
		var span = (pick.Item2 - pick.Item1).Length;

		if ( closest.Distance( landed ) <= span * 0.25f ) {
			anchor = closest;
			along = Vector3.Zero;

			return;
		}

		anchor = (pick.Item1 + pick.Item2) * 0.5f;
		along = span < 0.001f ? Vector3.Zero : (pick.Item2 - pick.Item1) / span;
	}

	static Vector3 OnEdge( Vector3 point, Vector3 from, Vector3 to ) {
		var run = to - from;
		var length = run.LengthSquared;

		if ( length < 0.000001f ) {
			return from;
		}

		return from + run * Math.Clamp( Vector3.Dot( point - from, run ) / length, 0f, 1f );
	}

	static bool Hits( Vector3 origin, Vector3 direction, Vector3 one, Vector3 two, Vector3 three, out float distance ) {
		distance = 0f;

		var first = two - one;
		var second = three - one;
		var cross = Vector3.Cross( direction, second );
		var determinant = Vector3.Dot( first, cross );

		if ( determinant > -1e-6f && determinant < 1e-6f ) {
			return false;
		}

		var inverse = 1f / determinant;
		var toOrigin = origin - one;
		var u = Vector3.Dot( toOrigin, cross ) * inverse;

		if ( u < 0f || u > 1f ) {
			return false;
		}

		var q = Vector3.Cross( toOrigin, first );
		var v = Vector3.Dot( direction, q ) * inverse;

		if ( v < 0f || u + v > 1f ) {
			return false;
		}

		distance = Vector3.Dot( second, q ) * inverse;

		return distance > 1e-4f;
	}
}

// The same solve ArchFittingRequest makes when the scene pass hangs the prefab, with the designer's orientation
// nudge composed in front of it - so what the author turns here is what stands in the room.
public static class ArchAimedPrefabFit {
	public static Transform Landing( IArchAimedPrefab preset, BBox bounds, Transform mount, BBox into ) {
		if ( preset is null ) {
			return mount;
		}

		var rotation = Squared( mount.Rotation * preset.AnchorTurn.ToRotation(), preset.AnchorAlong );
		var scale = preset.Scales ? Filled( bounds, into ) : Vector3.One;

		return new Transform( mount.Position - rotation * (preset.Anchor * scale), rotation, scale );
	}

	// The shortest turn carrying the picked edge onto whichever mount axis it already lies nearest - a square
	// prefab is left exactly as it is and a slightly-off one is corrected rather than spun.
	static Rotation Squared( Rotation nudged, Vector3 along ) {
		if ( along.Length < 0.001f ) {
			return nudged;
		}

		var pointing = nudged * along.Normal;
		var nearest = ArchAimedPrefabMount.Axes
			.SelectMany( axis => new[] { axis, -axis } )
			.OrderByDescending( axis => Vector3.Dot( axis, pointing ) )
			.First();

		return Vector3.Dot( pointing, nearest ) > 0.9999f ? nudged : Rotation.FromToRotation( pointing, nearest ) * nudged;
	}

	static Vector3 Filled( BBox bounds, BBox into ) {
		var room = into.Size;
		var size = bounds.Size;

		return new Vector3( Fitted( size.x, room.x ), Fitted( size.y, room.y ), Fitted( size.z, room.z ) );
	}

	static float Fitted( float modelled, float room ) {
		return modelled > 0.01f && room > 0.01f ? room / modelled : 1f;
	}
}

// The prefab in its mount, orbited and aimed at. Everything the author drew stands here as it was drawn - it is a
// prefab instance, components and all, never a mesh lifted out of one.
public sealed class ArchAimedPrefabStage : SceneRenderingWidget {
	readonly Scene stage;
	readonly Func<IArchAimedPrefab> read;
	readonly Func<BBox> room;
	readonly Action<Vector3, Vector3> picked;

	GameObject standing;
	ArchAimedPrefabGeometry geometry = new();
	string showing;
	Vector2 lastMouse;
	Vector3 hoverAnchor;
	Vector3 hoverAlong;
	bool hovering;
	bool orbiting;
	float yaw = 40f;
	float pitch = 14f;
	float distance = 70f;

	public ArchAimedPrefabStage( Widget parent, Func<IArchAimedPrefab> read, Action<Vector3, Vector3> picked,
		Func<BBox> room ) : base( parent ) {
		this.read = read;
		this.picked = picked;
		this.room = room;

		stage = Scene.CreateEditorScene();
		Scene = stage;
		MinimumSize = 420;
		MouseTracking = true;

		using ( stage.Push() ) {
			new GameObject( true, "camera" ).GetOrAddComponent<CameraComponent>().BackgroundColor = new Color( 0.075f, 0.08f, 0.095f );

			var sun = new GameObject( true, "sun" ).GetOrAddComponent<DirectionalLight>();
			sun.WorldRotation = Rotation.From( 42f, -145f, 0f );
			sun.LightColor = Color.White * 3.2f;
			sun.SkyColor = new Color( 0.38f, 0.43f, 0.52f ) * 2.2f;
			sun.Shadows = false;
		}
	}

	public bool Aimed => geometry.Standing;

	public void Frame() {
		distance = MathF.Max( 24f, geometry.Bounds.Size.Length * 1.6f );
	}

	[EditorEvent.Frame]
	public void OnFrame() {
		if ( !Visible || Width < 1f || Height < 1f ) {
			Scene = null;

			return;
		}

		Scene = stage;

		Restand();
		Place();

		using ( stage.Push() ) {
			stage.EditorTick( RealTime.Now, RealTime.Delta );

			var look = new Angles( pitch, yaw, 0f );
			var about = room().Center;

			stage.Camera.WorldPosition = about - look.Forward * distance;
			stage.Camera.WorldRotation = look.ToRotation();
			stage.Camera.FieldOfView = 40f;
			stage.Camera.ZNear = 1f;
			stage.Camera.ZFar = 5000f;
		}

		Mark();
	}

	// The instance is rebuilt only when the PREFAB changes: rebuilding it per frame would restart every component
	// the author hung on it, which is the one thing this preview exists to show working.
	void Restand() {
		var wanted = read()?.Prefab ?? "";

		if ( wanted == showing ) {
			return;
		}

		showing = wanted;
		standing?.Destroy();
		standing = null;
		geometry = new ArchAimedPrefabGeometry();

		if ( string.IsNullOrWhiteSpace( wanted ) || ResourceLibrary.Get<PrefabFile>( wanted ) is not { } file ) {
			return;
		}

		using ( stage.Push() ) {
			standing = GameObject.Clone( file );
			standing.WorldTransform = Transform.Zero;
		}

		geometry = ArchAimedPrefabGeometry.Of( standing );

		Frame();
	}

	void Place() {
		if ( !standing.IsValid() ) {
			return;
		}

		standing.WorldTransform = ArchAimedPrefabFit.Landing( read(), geometry.Bounds, Transform.Zero, room() );
	}

	void Mark() {
		using ( GizmoInstance.Push() )
		using ( Gizmo.Scope( "cabinet_front" ) ) {
			Gizmo.Draw.LineThickness = 1.5f;
			Gizmo.Draw.Color = Theme.Blue.WithAlpha( 0.55f );
			Gizmo.Draw.LineBBox( room() );

			var preset = read();

			if ( preset is { Anchored: true } ) {
				Show( World( preset.Anchor, preset.AnchorAlong ), Theme.Green );
			}

			if ( hovering ) {
				Show( World( hoverAnchor, hoverAlong ), Theme.Yellow );
			}
		}
	}

	void Show( (Vector3 Point, Vector3 Along) feature, Color colour ) {
		Gizmo.Draw.Color = colour;
		Gizmo.Draw.LineSphere( feature.Point, 0.6f );

		if ( feature.Along.Length > 0.01f ) {
			Gizmo.Draw.LineThickness = 3f;
			Gizmo.Draw.Line( feature.Point - feature.Along * 3f, feature.Point + feature.Along * 3f );
			Gizmo.Draw.LineThickness = 1.5f;
		}
	}

	(Vector3 Point, Vector3 Along) World( Vector3 anchor, Vector3 along ) {
		var placed = standing.IsValid() ? standing.WorldTransform : Transform.Zero;

		return (placed.PointToWorld( anchor ), placed.NormalToWorld( along ));
	}

	// Aimed in the PREFAB's own space, so a pick survives the turn the author gives it a moment later.
	bool Reach( Vector2 local, out Vector3 anchor, out Vector3 along ) {
		anchor = Vector3.Zero;
		along = Vector3.Zero;

		if ( !geometry.Standing || !standing.IsValid() ) {
			return false;
		}

		var ray = GetRay( local );
		var placed = standing.WorldTransform;

		return geometry.Aim( placed.PointToLocal( ray.Position ), placed.NormalToLocal( ray.Forward ).Normal, out anchor, out along );
	}

	protected override void OnMousePress( MouseEvent e ) {
		base.OnMousePress( e );

		lastMouse = e.LocalPosition;
		orbiting = (e.ButtonState & MouseButtons.Right) != 0;
	}

	protected override void OnMouseReleased( MouseEvent e ) {
		base.OnMouseReleased( e );

		orbiting = false;
	}

	protected override void OnMouseClick( MouseEvent e ) {
		base.OnMouseClick( e );

		if ( !e.LeftMouseButton || !Reach( e.LocalPosition, out var anchor, out var along ) ) {
			return;
		}

		picked?.Invoke( anchor, along );
	}

	protected override void OnMouseMove( MouseEvent e ) {
		base.OnMouseMove( e );

		var delta = e.LocalPosition - lastMouse;
		lastMouse = e.LocalPosition;

		if ( orbiting && (e.ButtonState & MouseButtons.Right) != 0 ) {
			yaw -= delta.x * 0.35f;
			pitch = Math.Clamp( pitch + delta.y * 0.25f, -80f, 80f );

			return;
		}

		orbiting = false;
		hovering = Reach( e.LocalPosition, out hoverAnchor, out hoverAlong );
	}

	protected override void OnMouseLeave() {
		hovering = false;
	}

	protected override void OnMouseWheel( WheelEvent e ) {
		distance = Math.Clamp( distance * (e.Delta > 0f ? 0.9f : 1.1f), 8f, 600f );
	}

	public override void OnDestroyed() {
		base.OnDestroyed();

		stage?.Destroy();
		Scene = null;
	}
}

// The material groups a prefab's model offers. Read off the compiled model, because a group name that is not on it
// is one the renderer silently ignores - and a picker offering it would look broken rather than wrong.
public static class ArchPrefabGroups {
	public static List<string> Offered( string prefab ) {
		var found = new List<string>();

		if ( string.IsNullOrWhiteSpace( prefab ) || PrefabFile.Load( prefab ) is not { } file ) {
			return found;
		}

		if ( SceneUtility.GetPrefabScene( file ) is not { } source ) {
			return found;
		}

		foreach ( var renderer in source.Components.GetAll<ModelRenderer>( FindMode.EverythingInSelfAndDescendants ) ) {
			if ( renderer.Model is not { MaterialGroupCount: > 0 } model ) {
				continue;
			}

			for ( var index = 0; index < model.MaterialGroupCount; index++ ) {
				var name = model.GetMaterialGroupName( index );

				if ( !string.IsNullOrWhiteSpace( name ) && !found.Contains( name, StringComparer.OrdinalIgnoreCase ) ) {
					found.Add( name );
				}
			}
		}

		return found;
	}
}

// Where a prefab is dropped. A path typed by hand is not a gesture anybody wants twice, so this takes the drag from
// the asset browser and offers the picker as the way in for anyone who would rather browse.
public sealed class ArchPrefabWell : Widget {
	readonly Func<string> read;
	readonly Action<string> took;

	bool inviting;

	public ArchPrefabWell( Widget parent, Func<string> read, Action<string> took ) : base( parent ) {
		this.read = read;
		this.took = took;

		AcceptDrops = true;
		Cursor = CursorShape.Finger;
		FixedHeight = 86f;
	}

	public override void OnDragHover( DragEvent e ) {
		if ( Dropped( e ) is null ) {
			return;
		}

		e.Action = DropAction.Link;
		inviting = true;

		Update();
	}

	public override void OnDragLeave() {
		inviting = false;

		Update();
	}

	public override void OnDragDrop( DragEvent e ) {
		inviting = false;

		if ( Dropped( e ) is not { } path ) {
			return;
		}

		e.Action = DropAction.Link;

		took( path );
		Update();
	}

	static string Dropped( DragEvent e ) {
		var dragged = e.Data.Assets
			.Select( asset => asset.AssetPath )
			.FirstOrDefault( path => path is not null && path.EndsWith( ".prefab", StringComparison.OrdinalIgnoreCase ) );

		if ( dragged is not null ) {
			return dragged;
		}

		return e.Data.HasFileOrFolder && e.Data.FileOrFolder.EndsWith( ".prefab", StringComparison.OrdinalIgnoreCase )
			? e.Data.FileOrFolder
			: null;
	}

	protected override void OnMouseClick( MouseEvent e ) {
		if ( !e.LeftMouseButton ) {
			return;
		}

		var picker = AssetPicker.Create( this, AssetType.Find( "prefab", false ) );

		picker.Window.Title = "Prefab";
		picker.OnAssetPicked = assets => {
			if ( assets.FirstOrDefault() is { } asset ) {
				took( asset.Path );
				Update();
			}
		};

		picker.Show();
	}

	protected override void OnPaint() {
		Paint.Antialiasing = true;
		Paint.TextAntialiasing = true;

		var body = LocalRect.Shrink( 1f );
		var held = read();
		var standing = !string.IsNullOrWhiteSpace( held );

		Paint.SetBrushAndPen( Theme.ControlBackground, Theme.Blue.WithAlpha( inviting ? 1f : standing ? 0.35f : 0.18f ) );
		Paint.DrawRect( body, 4 );

		var art = new Rect( body.Left + 6f, body.Top + 6f, 72f, 72f );

		if ( standing && AssetSystem.FindByPath( held )?.GetAssetThumb() is { } thumb ) {
			Paint.Draw( art, thumb, 1f );
		} else {
			Paint.SetPen( Theme.TextControl.WithAlpha( 0.4f ) );
			Paint.DrawIcon( art, standing ? "widgets" : "download", 30f );
		}

		var text = body.Shrink( 86f, 8f, 8f, 8f );

		Paint.SetDefaultFont( 8f, 600 );
		Paint.SetPen( Theme.Text );
		Paint.DrawText( text, standing ? ArchCabinetFronts.Titled( held ) : "Drop a prefab here", TextFlag.LeftTop );

		Paint.SetDefaultFont( 7f );
		Paint.SetPen( Theme.TextControl.WithAlpha( 0.55f ) );
		Paint.DrawText( text.Shrink( 0f, 16f, 0f, 0f ), standing ? held : "or click to browse", TextFlag.LeftTop );
	}
}