Editor/UI/ArchPresetPreview.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

public enum ArchPreviewFocus {
	Unit,
	Whole
}

public readonly record struct ArchPreviewShot( Vector2 Size, Angles View, ArchPreviewFocus Focus, BBox? Interest );

public interface IArchPreviewWatcher {
	bool Alive { get; }

	void PreviewTick( bool advanced );
}

// Must be non-generic — the event system keys by open type definition
public static class ArchPreviewPump {
	static readonly List<IArchPreviewWatcher> watchers = new();

	public static void Watch( IArchPreviewWatcher watcher ) {
		watchers.Add( watcher );
	}

	[EditorEvent.Frame]
	public static void Frame() {
		watchers.RemoveAll( watcher => !watcher.Alive );

		if ( watchers.Count == 0 ) {
			return;
		}

		var advanced = ArchPresetPreview.Pump();

		foreach ( var watcher in watchers.ToList() ) {
			watcher.PreviewTick( advanced );
		}
	}
}

public static class ArchPresetPreview {
	// Bumped to invalidate the preview cache
	const int Recipe = 6;

	const float Padding = 1.06f;

	public static readonly Angles Elevation = new( 0f, 90f, 0f );
	public static readonly Angles Quarter = new( 20f, 52f, 0f );
	// Underside view for cornices and crowns
	public static readonly Angles Raised = new( -14f, 52f, 0f );

	sealed class Pending {
		public string Key { get; init; }
		public ArchPreviewShot Shot { get; init; }
		public ArchKit Kit { get; init; }
		public Func<ArchStaged> Compose { get; init; }
	}

	static readonly Dictionary<string, Pixmap> cache = new();
	static readonly HashSet<string> failed = new();
	static readonly List<Pending> queue = new();

	// Queues on miss to avoid stalling when many cards open at once
	public static Pixmap For( string identity, ArchPreviewShot shot, ArchKit kit, Func<ArchStaged> compose ) {
		var key = $"{Recipe}|{identity}|{shot.Size.x:0}x{shot.Size.y:0}|{shot.View}|{shot.Focus}|{shot.Interest?.Size}";

		if ( cache.TryGetValue( key, out var hit ) ) {
			return hit;
		}

		if ( failed.Contains( key ) || queue.Any( entry => entry.Key == key ) ) {
			return null;
		}

		queue.Add( new Pending { Key = key, Shot = shot, Kit = kit, Compose = compose } );

		return null;
	}

	public static bool Pump() {
		if ( queue.Count == 0 ) {
			return false;
		}

		var next = queue[0];
		queue.RemoveAt( 0 );

		var pixmap = Render( next );

		if ( pixmap is null ) {
			failed.Add( next.Key );
			return true;
		}

		cache[next.Key] = pixmap;

		return true;
	}

	public static void Invalidate( string identity = null ) {
		queue.Clear();

		if ( string.IsNullOrWhiteSpace( identity ) ) {
			cache.Clear();
			failed.Clear();
			return;
		}

		foreach ( var key in cache.Keys.Where( key => key.Contains( $"|{identity}|" ) ).ToList() ) {
			cache.Remove( key );
		}

		foreach ( var key in failed.Where( key => key.Contains( $"|{identity}|" ) ).ToList() ) {
			failed.Remove( key );
		}
	}

	static Pixmap Render( Pending request ) {
		Scene scene = null;

		try {
			var staged = request.Compose();

			if ( staged.Plan is null ) {
				return null;
			}

			scene = Scene.CreateEditorScene();

			using ( scene.Push() ) {
				Staged( scene, staged, request.Kit, request.Shot );
				Light( scene );

				if ( !Bounds( scene, out var bounds ) ) {
					return null;
				}

				var camera = Aim( scene, request.Shot.View, bounds, request.Shot.Size.x / request.Shot.Size.y );
				var pixmap = new Pixmap( (int)request.Shot.Size.x, (int)request.Shot.Size.y );

				if ( !camera.RenderToPixmap( pixmap ) ) {
					return null;
				}

				try {
					Edges( scene, camera, pixmap );
				} catch ( Exception exception ) {
					Log.Warning( $"Architecture: could not line {request.Key}: {exception.Message}" );
				}

				return pixmap;
			}
		} catch ( Exception exception ) {
			Log.Warning( $"Architecture: could not preview {request.Key}: {exception.Message}" );
			return null;
		} finally {
			scene?.Destroy();
		}
	}

	public static List<ArchSurface> Staged( Scene scene, ArchStaged staged, ArchKit kit, ArchPreviewShot shot ) {
		Dress( scene, staged.Plan, kit );

		if ( shot.Focus == ArchPreviewFocus.Unit ) {
			Strip( scene, shot.Interest );
		}

		return Standing( scene );
	}

	static List<ArchSurface> Standing( Scene scene ) {
		var roles = ArchBlockout.Roles.ToDictionary(
			pair => ArchBlockout.PathFor( pair.Role ),
			pair => pair.Role,
			StringComparer.OrdinalIgnoreCase );

		return scene.GetAllObjects( true )
			.Select( node => node.Components.Get<MeshComponent>() )
			.Where( piece => piece is { Mesh: not null } )
			.SelectMany( piece => piece.Mesh.FaceHandles.Select( face => piece.Mesh.GetFaceMaterial( face )?.ResourcePath ) )
			.Where( path => path is not null && roles.ContainsKey( path ) )
			.Select( path => roles[path] )
			.Distinct()
			.ToList();
	}

	// Blockout palette so roles are recoverable from materials for stripping
	static void Dress( Scene scene, ArchPlan plan, ArchKit kit ) {
		var dressed = kit.Palette;

		try {
			kit.Palette = Grid();
			ArchScene.Generate( scene, plan, kit );
		} finally {
			kit.Palette = dressed;
		}
	}

	static ArchPalette Grid() {
		if ( grid is not null ) {
			return grid;
		}

		grid = new ArchPalette();
		ArchBlockout.Apply( grid );

		return grid;
	}

	static ArchPalette grid;

	static readonly ArchSurface[] Scaffolding =
	{
		ArchSurface.WallExterior,
		ArchSurface.WallInterior,
		ArchSurface.WallCap,
		ArchSurface.WallBase,
		ArchSurface.Siding,
		ArchSurface.Wainscot,
		ArchSurface.Baseboard,
		ArchSurface.Foundation,
		ArchSurface.Floor,
		ArchSurface.Ceiling
	};

	static void Strip( Scene scene, BBox? interest ) {
		var dropped = Scaffolding.Select( ArchBlockout.PathFor ).ToHashSet( StringComparer.OrdinalIgnoreCase );

		foreach ( var node in scene.GetAllObjects( true ).ToList() ) {
			if ( node.Components.Get<MeshComponent>() is not { Mesh: not null } piece ) {
				continue;
			}

			var scaffold = piece.Mesh.FaceHandles
				.Where( face => dropped.Contains( piece.Mesh.GetFaceMaterial( face )?.ResourcePath )
					|| Outside( piece, face, interest ) )
				.ToList();

			// On the openings piece, keep reveal faces if stripping would remove everything
			if ( scaffold.Count == piece.Mesh.FaceHandles.Count() && node.Name == ArchPieces.Openings ) {
				scaffold = piece.Mesh.FaceHandles
					.Where( face => dropped.Contains( piece.Mesh.GetFaceMaterial( face )?.ResourcePath ) )
					.ToList();
			}

			if ( scaffold.Count == 0 ) {
				continue;
			}

			piece.Mesh.RemoveFaces( scaffold );

			if ( !piece.Mesh.FaceHandles.Any() ) {
				if ( node.Children.Count == 0 ) {
					node.Destroy();
				}

				continue;
			}

			piece.RebuildMesh();
		}
	}

	const float Crease = 8f;

	static void Edges( Scene scene, CameraComponent camera, Pixmap pixmap ) {
		var rotation = camera.WorldRotation;
		var origin = camera.WorldPosition;
		var scale = pixmap.Height / camera.OrthographicHeight;
		var middle = new Vector2( pixmap.Width, pixmap.Height ) * 0.5f;

		Vector2 Project( Vector3 world ) {
			var local = rotation.Inverse * (world - origin);

			return new Vector2( middle.x - local.y * scale, middle.y - local.z * scale );
		}

		var forward = rotation.Forward;
		var lines = new List<(Vector2 From, Vector2 To)>();

		foreach ( var node in scene.GetAllObjects( true ) ) {
			if ( node.Components.Get<MeshComponent>() is not { Mesh: not null } piece ) {
				continue;
			}

			var mesh = piece.Mesh;
			var placed = piece.WorldTransform;

			foreach ( var edge in mesh.HalfEdgeHandles ) {
				if ( !edge.IsValid || !edge.Face.IsValid ) {
					continue;
				}

				var opposite = edge.OppositeEdge;

				if ( opposite.IsValid && opposite.Face.IsValid && opposite.Index < edge.Index ) {
					continue;
				}

				if ( !Feature( mesh, edge, opposite, placed, forward ) ) {
					continue;
				}

				mesh.GetEdgeVertices( edge, out var from, out var to );

				lines.Add( (
					Project( placed.PointToWorld( mesh.GetVertexPosition( from ) ) ),
					Project( placed.PointToWorld( mesh.GetVertexPosition( to ) ) )) );
			}
		}

		if ( lines.Count == 0 ) {
			return;
		}

		using ( Paint.ToPixmap( pixmap ) ) {
			Paint.Antialiasing = true;
			Paint.ClearBrush();
			Paint.SetPen( Color.Black.WithAlpha( 0.55f ), 1f );

			foreach ( var line in lines ) {
				Paint.DrawLine( line.From, line.To );
			}
		}
	}

	// Boundary, silhouette or crease — skips back-facing pairs
	static bool Feature( PolygonMesh mesh, HalfEdgeMesh.HalfEdgeHandle edge, HalfEdgeMesh.HalfEdgeHandle opposite, Transform placed, Vector3 forward ) {
		mesh.ComputeFaceNormal( edge.Face, out var near );

		var facing = Vector3.Dot( placed.NormalToWorld( near ), forward ) < 0f;

		if ( !opposite.IsValid || !opposite.Face.IsValid || opposite.Face == edge.Face ) {
			return facing;
		}

		mesh.ComputeFaceNormal( opposite.Face, out var far );

		var behind = Vector3.Dot( placed.NormalToWorld( far ), forward ) < 0f;

		if ( !facing && !behind ) {
			return false;
		}

		return facing != behind || Vector3.GetAngle( near, far ) > Crease;
	}

	static void Light( Scene scene ) {
		var key = new GameObject( true, "key" );
		key.SetParent( scene );

		var sun = key.Components.GetOrCreate<DirectionalLight>();
		sun.WorldRotation = Rotation.From( 44f, -142f, 0f );
		sun.LightColor = Color.White * 3.4f;
		sun.SkyColor = new Color( 0.4f, 0.45f, 0.55f ) * 2.4f;
		sun.Shadows = false;

		var fill = new GameObject( true, "fill" );
		fill.SetParent( scene );

		var bounce = fill.Components.GetOrCreate<DirectionalLight>();
		bounce.WorldRotation = Rotation.From( -12f, 38f, 0f );
		bounce.LightColor = Color.White * 1.2f;
		bounce.SkyColor = Color.Black;
		bounce.Shadows = false;
	}

	// Judged on face centre — corners can extend past interest box legitimately
	static bool Outside( MeshComponent piece, HalfEdgeMesh.FaceHandle face, BBox? interest ) {
		if ( interest is not { } box ) {
			return false;
		}

		return !box.Contains( piece.WorldTransform.PointToWorld( piece.Mesh.GetFaceCenter( face ) ) );
	}

	public static BBox? Framed( Scene scene ) => Bounds( scene, out var bounds ) ? bounds : null;

	public static float Fills( Scene scene, ArchPreviewShot shot ) {
		if ( !Bounds( scene, out var bounds ) ) {
			return 0f;
		}

		var camera = Aim( scene, shot.View, bounds, shot.Size.x / shot.Size.y );

		var top = camera.PointToScreenNormal( bounds.Center + Vector3.Up * bounds.Size.z * 0.5f ).y;
		var bottom = camera.PointToScreenNormal( bounds.Center - Vector3.Up * bounds.Size.z * 0.5f ).y;

		return MathF.Abs( bottom - top );
	}

	static bool Bounds( Scene scene, out BBox bounds ) {
		var found = false;

		bounds = default;

		foreach ( var node in scene.GetAllObjects( true ) ) {
			if ( node.Components.Get<MeshComponent>() is not { Mesh: not null } piece ) {
				continue;
			}

			var mesh = piece.Mesh;
			var placed = piece.WorldTransform;

			// Walk faces not half-edges — removed faces leave stale half-edges
			foreach ( var face in mesh.FaceHandles ) {
				foreach ( var vertex in mesh.GetFaceVertices( face ) ) {
					var point = placed.PointToWorld( mesh.GetVertexPosition( vertex ) );

					bounds = found ? bounds.AddPoint( point ) : BBox.FromPositionAndSize( point, 0f );
					found = true;
				}
			}
		}

		return found;
	}

	static CameraComponent Aim( Scene scene, Angles view, BBox bounds, float aspect ) {
		var node = new GameObject( true, "camera" );
		node.SetParent( scene );

		var camera = node.Components.GetOrCreate<CameraComponent>();
		camera.BackgroundColor = new Color( 0.085f, 0.09f, 0.1f );
		camera.Orthographic = true;
		camera.OrthographicHeight = Extent( bounds, view.ToRotation(), aspect ) * Padding;
		camera.ZNear = 1f;
		camera.ZFar = 40000f;

		var reach = bounds.Size.Length + 512f;

		node.WorldRotation = view.ToRotation();
		node.WorldPosition = bounds.Center - node.WorldRotation.Forward * reach;

		return camera;
	}

	static float Extent( BBox bounds, Rotation rotation, float aspect ) {
		var needed = 0f;

		foreach ( var corner in bounds.Corners ) {
			var local = rotation.Inverse * (corner - bounds.Center);

			needed = MathF.Max( needed, MathF.Max( MathF.Abs( local.y ) / aspect, MathF.Abs( local.z ) ) );
		}

		return MathF.Max( 1f, needed * 2f );
	}
}