Editor/Output/ArchLocate.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Sunless.Architecture;

// BBox's default is zero-size at the origin, so Any tracks emptiness before unioning.
public struct ArchExtent {
	Vector3 low;
	Vector3 high;

	public bool Any { get; private set; }

	public void Add( Vector3 point ) {
		low = Any ? Vector3.Min( low, point ) : point;
		high = Any ? Vector3.Max( high, point ) : point;
		Any = true;
	}

	public void Add( Vector2 point, float bottom, float top ) {
		Add( new Vector3( point.x, point.y, bottom ) );
		Add( new Vector3( point.x, point.y, top ) );
	}

	public void Add( IEnumerable<Vector2> loop, float bottom, float top ) {
		foreach ( var point in loop ) {
			Add( point, bottom, top );
		}
	}

	public void Add( ArchExtent other ) {
		if ( !other.Any ) {
			return;
		}

		Add( other.low );
		Add( other.high );
	}

	public BBox Box => Any ? new BBox( low, high ) : default;

	public ArchExtent Grown( float margin ) {
		if ( !Any ) {
			return this;
		}

		var grown = new ArchExtent();
		var reach = new Vector3( margin, margin, margin );

		grown.Add( low - reach );
		grown.Add( high + reach );

		return grown;
	}
}

// Every tool resolves through here, so a target string means the same thing to each.
public sealed class ArchTarget {
	public string Describe { get; init; } = "plan";
	public string Kind { get; init; } = "plan";
	public object Item { get; init; }
	public ArchRoom Room { get; init; }
	public ArchBuilding Building { get; init; }
	public List<GameObject> Objects { get; init; } = new();

	// Separate from Built: it aims the camera anyway, and divergence shows as disagreement.
	public BBox Planned { get; init; }
	public BBox Built { get; init; }
	public float Yaw { get; init; }
	public int Faces { get; init; }
	public string Note { get; init; }
	public int? Level { get; init; }
	public bool Focused { get; init; }

	public bool HasGeometry => Faces > 0;

	// A point target's geometry is every root - frame the box asked for, not that.
	public bool FixedFrame { get; private init; }

	public ArchTarget Aimed() => new() {
		Describe = Describe,
		Kind = Kind,
		Item = Item,
		Room = Room,
		Building = Building,
		Objects = Objects,
		Planned = Planned,
		Built = Built,
		Yaw = Yaw,
		Faces = Faces,
		Note = Note,
		Level = Level,
		Focused = Focused,
		FixedFrame = true
	};

	public BBox Frame {
		get {
			var extent = new ArchExtent();

			if ( Planned.Size.Length > 0.01f ) {
				extent.Add( Planned.Mins );
				extent.Add( Planned.Maxs );
			}

			if ( Faces > 0 && !FixedFrame ) {
				extent.Add( Built.Mins );
				extent.Add( Built.Maxs );
			}

			return extent.Any ? extent.Box : BBox.FromPositionAndSize( Vector3.Zero, 256f );
		}
	}

	public IEnumerable<MeshComponent> Meshes() {
		var seen = new HashSet<MeshComponent>();

		foreach ( var node in Objects.Where( node => node.IsValid() ) ) {
			foreach ( var mesh in node.Components.GetAll<MeshComponent>( FindMode.EverythingInSelfAndDescendants ) ) {
				if ( mesh.Mesh is not null && seen.Add( mesh ) ) {
					yield return mesh;
				}
			}
		}
	}

	public IEnumerable<GameObject> RenderObjects() {
		var seen = new HashSet<GameObject>();

		foreach ( var root in Objects.Where( node => node.IsValid() ) ) {
			foreach ( var node in ArchScene.Descendants( root ) ) {
				if ( seen.Add( node ) ) {
					yield return node;
				}
			}
		}
	}
}

// Plan ids are unique document-wide; kind keywords cover 'every X' without hunting ids.
public static partial class ArchLocate {
	public static readonly string[] Kinds =
	{
		"plan", "buildings", "rooms", "walls", "openings", "doors", "windows", "roofs",
		"gutters", "downpipes", "pipes", "brackets", "stairs", "pillars", "porches", "approaches", "fences", "trims", "slabs", "foundations",
		"platforms", "rooflights", "cuts", "cabinets", "roads", "junctions", "tunnels", "walkways", "connections", "connection"
	};

	sealed class Query {
		public string Kind { get; init; }
		public int? Id { get; init; }
		public int? Level { get; init; }
	}

	public static ArchTarget Find( ArchTool tool, string target ) {
		var query = (target ?? string.Empty).Trim();

		if ( query.Length == 0 || query.Equals( "plan", StringComparison.OrdinalIgnoreCase ) || query.Equals( "all", StringComparison.OrdinalIgnoreCase ) ) {
			return Whole( tool );
		}

		if ( query.Equals( "selection", StringComparison.OrdinalIgnoreCase ) || query.Equals( "picked", StringComparison.OrdinalIgnoreCase ) ) {
			return Selected( tool );
		}

		if ( query.Equals( "level", StringComparison.OrdinalIgnoreCase )
			|| query.Equals( "storey", StringComparison.OrdinalIgnoreCase )
			|| query.Equals( "floor", StringComparison.OrdinalIgnoreCase ) ) {
			return Storey( tool, tool.Level );
		}

		if ( Spot( query, out var point, out var radius ) ) {
			return Around( tool, point, radius );
		}

		if ( !TryQuery( query, out var parsed ) ) {
			if ( int.TryParse( query, out var id ) ) {
				return Focus( tool, Part( tool, id ), null );
			}

			return Kind( tool, query, tool.Level );
		}

		if ( parsed.Kind == "level" ) {
			return Storey( tool, parsed.Level ?? tool.Level );
		}

		if ( parsed.Id.HasValue ) {
			var part = Part( tool, parsed.Id.Value );

			if ( part.Kind == "none" ) {
				return part;
			}

			if ( parsed.Kind == "walkway" && (part.Room is null || !ArchAsks.IsWalkway( part.Room )) ) {
				return Missing( query, "That id is not a walkway room." );
			}

			if ( parsed.Kind is "room" or "wall" or "walkway" ) {
				if ( parsed.Kind == "room" && part.Kind != "room" || parsed.Kind == "wall" && part.Kind != "wall" ) {
					return Missing( query, $"That id is not a {parsed.Kind}." );
				}

				return Focus( tool, part, parsed.Level );
			}

			if ( parsed.Kind is "connection" or "connections" ) {
				return Focus( tool, part, parsed.Level );
			}

			return part;
		}

		return Kind( tool, parsed.Kind, parsed.Level ?? tool.Level );
	}

	// The parse on its own, with no plan to resolve against: what a target STRING means. Public so the
	// harness can pin the syntax every tool description promises.
	public static bool Parses( string query, out string kind, out int? id ) {
		kind = null;
		id = null;

		if ( !TryQuery( (query ?? string.Empty).Trim(), out var parsed ) ) {
			return false;
		}

		kind = parsed.Kind;
		id = parsed.Id;

		return true;
	}

	static bool TryQuery( string query, out Query parsed ) {
		parsed = null;

		var tokens = query
			.Split( new[] { ':', '@', '/', ' ', '=' }, StringSplitOptions.RemoveEmptyEntries )
			.Select( token => token.Trim() )
			.Where( token => token.Length > 0 )
			.ToList();

		if ( tokens.Count == 0 ) {
			return false;
		}

		var first = tokens[0].ToLowerInvariant();

		if ( first is "level" or "storey" or "floor" ) {
			if ( tokens.Count < 2 || !int.TryParse( tokens[1], out var level ) ) {
				return false;
			}

			parsed = new Query {
				Kind = tokens.Count > 2 ? Singular( tokens[2] ) : "level",
				Level = level
			};

			return true;
		}

		if ( int.TryParse( first, out var id ) ) {
			parsed = new Query { Kind = "part", Id = id };
			return true;
		}

		// Matched as singulars on BOTH sides. Kinds are written plural, so testing the token against the
		// list as it stands rejected every singular one - which is every focused target the tools tell
		// you to use: 'wall:84', 'room:42', 'walkway:120' all fell through to the unknown-keyword error.
		if ( !Kinds.Any( known => Singular( known ) == Singular( first ) ) ) {
			return false;
		}

		var kind = Singular( first );
		int? partId = null;
		int? partLevel = null;

		if ( tokens.Count > 1 && int.TryParse( tokens[1], out var value ) ) {
			if ( kind is "room" or "wall" or "walkway" or "connection" ) {
				partId = value;
			} else {
				partLevel = value;
			}
		}

		if ( tokens.Count > 2 && int.TryParse( tokens[2], out var explicitLevel ) ) {
			partLevel = explicitLevel;
		}

		parsed = new Query { Kind = kind, Id = partId, Level = partLevel };
		return true;
	}

	static ArchTarget Missing( string query, string note ) {
		return new ArchTarget { Describe = query, Kind = "none", Note = note };
	}

	// 'x,y,z'@radius: the view path for arch_audit findings.
	static bool Spot( string query, out Vector3 point, out float radius ) {
		point = default;
		radius = 48f;

		var at = query.IndexOf( '@' );

		if ( at >= 0 ) {
			if ( float.TryParse( query[(at + 1)..], out var asked ) ) {
				radius = MathF.Max( 2f, asked );
			}

			query = query[..at];
		}

		var parts = query.Split( ',' );

		if ( parts.Length != 3
			|| !float.TryParse( parts[0], out var x )
			|| !float.TryParse( parts[1], out var y )
			|| !float.TryParse( parts[2], out var z ) ) {
			return false;
		}

		point = new Vector3( x, y, z );
		return true;
	}
}