Editor/Carve/ArchCutAffector.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Sunless.Architecture;

// Wall holes are authored openings owned by the cut — re-derived on every commit, gated by ArchLayerGate
public static class ArchCutAffector {
	// The narrowest bite worth taking out of a wall - below this the cut is only clipping the skin's corner.
	const float LeastBite = 4f;

	public static int Resolve( ArchPlan plan, ArchKit kit ) {
		var opened = 0;

		foreach ( var building in plan.Buildings.ToList() ) {
			foreach ( var cut in building.Cuts.ToList() ) {
				if ( Recut( plan, kit, building, cut ) ) {
					opened++;
				}
			}
		}

		return opened;
	}

	static bool Recut( ArchPlan plan, ArchKit kit, ArchBuilding home, ArchCutPart cut ) {
		var wanted = Wanted( plan, kit, home, cut );

		// Slotted by the wall and the ordinal on it, so a cut nudged over the same wall re-dresses the hole
		// it already owns there rather than closing it and allocating another id.
		var moved = ArchOwned.Settle( plan, ArchOwned.Openings, cut.Id,
			wanted.Select( bite => Hole( cut, bite ) ).ToList(), () => new ArchOpening() );

		Swallowed( plan, kit, home, cut );
		Regroup( plan, kit, home, cut, wanted );

		return moved;
	}

	static ArchWant<ArchWall, ArchOpening> Hole( ArchCutPart cut, Bite bite ) {
		return new ArchWant<ArchWall, ArchOpening>( bite.Wall, opening => {
			opening.Preset = "doorway";
			opening.Kind = OpeningKind.Archway;
			opening.Offset = bite.Offset;
			opening.Width = bite.Width;
			opening.SillHeight = bite.Sill;
			opening.Height = bite.Height;
			opening.Cased = cut.Edge;
			opening.HasSill = false;
			opening.CasingWidth = cut.EdgeWidth;
			opening.Leaf = false;
			opening.Glazed = false;
			opening.AutoLayout = false;
		} );
	}

	// Disables (not deletes) units inside the cut — ungating or moving the cut restores them
	static void Swallowed( ArchPlan plan, ArchKit kit, ArchBuilding home, ArchCutPart cut ) {
		var taking = cut.Mode == ArchZoneMode.Carve && ArchCut.Affects( cut, ArchCutAffects.Windows ) && cut.HasContent && ArchLayerGate.On( cut );
		var reach = ArchLayerGroups.Reach( plan, cut.Id, home.Id );

		foreach ( var room in plan.AllRooms() ) {
			if ( reach is not null && plan.OwnerOf( room ) is { } owner && !reach.Contains( owner.Id ) ) {
				continue;
			}

			foreach ( var wall in room.Walls ) {
				// Only hand-placed or wall-rhythm openings — never holes another part punched
				foreach ( var opening in wall.Openings.Where( opening => opening.OwnerId == 0 || opening.OwnerId == wall.Id ) ) {
					var buried = taking && Buried( room, wall, opening, cut );

					if ( buried ) {
						opening.SwallowedBy = cut.Id;
					} else if ( opening.SwallowedBy == cut.Id ) {
						opening.SwallowedBy = 0;
					}
				}
			}
		}
	}

	// Only buried when the cut covers the ENTIRE opening — partial clip leaves it standing
	static bool Buried( ArchRoom room, ArchWall wall, ArchOpening opening, ArchCutPart cut ) {
		foreach ( var leg in cut.Legs() ) {
			if ( leg.BaseHeight - room.BaseHeight > opening.SillHeight + 0.5f || leg.TopHeight - room.BaseHeight < opening.Top - 0.5f ) {
				continue;
			}

			foreach ( var (from, to) in ArchFootprint.Inside( leg.Outline(), wall.Start, wall.End ) ) {
				if ( from * wall.Length <= opening.Left + 0.5f && to * wall.Length >= opening.Right - 0.5f ) {
					return true;
				}
			}
		}

		return false;
	}

	static void Regroup( ArchPlan plan, ArchKit kit, ArchBuilding home, ArchCutPart cut, IReadOnlyList<Bite> bites ) {
		var kinds = ArchKinds.Load();
		var reached = plan.Buildings
			.Where( building => building != home
				&& (bites.Any( bite => building.Rooms.Any( room => plan.Filed( ArchKind.Wall, room, kinds ).Contains( bite.Wall ) ) )
					|| Touches( building, cut, kit )) )
			.ToList();
		var homeGroup = ArchLayerGroups.Holding( plan, home.Id );

		if ( reached.Count == 0 ) {
			if ( homeGroup is { Kind: not ArchAssemblyKind.SharedCore } ) {
				ArchLayerGroups.Join( plan, homeGroup, cut.Id );
			} else {
				ArchLayerGroups.Leave( plan, cut.Id );
			}

			return;
		}

		var join = ArchLayerGroups.Holding( plan, cut.Id )
			?? homeGroup
			?? ArchLayerGroups.Create( plan, ArchLayerGroups.Named( plan ), ArchAssemblyKind.SharedCore, Array.Empty<int>() );
		var buildings = join.Children.Where( id => plan.FindBuilding( id ) is not null )
			.Concat( reached.Select( building => building.Id ) )
			.Append( home.Id )
			.Distinct()
			.ToList();

		foreach ( var member in buildings.Append( cut.Id ) ) {
			ArchLayerGroups.Leave( plan, member );
		}

		join.Children.Clear();
		join.Children.AddRange( buildings );
		join.Children.Add( cut.Id );
	}

	static bool Touches( ArchBuilding building, ArchCutPart cut, ArchKit kit ) {
		foreach ( var volume in ArchCut.Resolve( cut, kit ) ) {
			foreach ( var room in building.Rooms.Where( room => room.HasFootprint ) ) {
				var wallHeight = ArchFloorGen.WallHeight( room, kit );

				if ( ArchCut.Reaches( volume, room.BaseHeight - kit.FloorThickness, room.BaseHeight + wallHeight )
					&& ArchFootprint.Overlaps( ArchFloorGen.Footprint( room ), volume.Footprint ) ) {
					return true;
				}
			}

			foreach ( var platform in building.Platforms ) {
				if ( ArchCut.Reaches( volume, platform.GradeHeight, platform.TopHeight )
					&& ArchFootprint.Overlaps( platform.Outline(), volume.Footprint ) ) {
					return true;
				}
			}
		}

		return false;
	}

	static List<Bite> Wanted( ArchPlan plan, ArchKit kit, ArchBuilding home, ArchCutPart cut ) {
		var bites = new List<Bite>();

		if ( cut.Mode != ArchZoneMode.Carve || !cut.HasContent || !ArchLayerGate.On( cut ) || !ArchCut.Affects( cut, ArchCutAffects.Walls ) ) {
			return bites;
		}

		var reach = ArchLayerGroups.Reach( plan, cut.Id, home.Id );

		foreach ( var room in plan.AllRooms() ) {
			if ( reach is not null && plan.OwnerOf( room ) is { } owner && !reach.Contains( owner.Id ) ) {
				continue;
			}

			var wallHeight = ArchFloorGen.WallHeight( room, kit );

			// Only walls that were standing when the cut was placed (stack-ordered)
			foreach ( var wall in room.Walls.Where( wall => wall.Length >= 1f && ArchLayerOrder.Applies( plan, cut.Id, wall.Id ) ) ) {
				foreach ( var leg in cut.Legs() ) {
					Bitten( room, wall, wallHeight, leg, bites );
				}
			}
		}

		return bites;
	}

	static void Bitten( ArchRoom room, ArchWall wall, float wallHeight, ArchCutSegment leg, List<Bite> into ) {
		var sill = MathF.Max( 0f, leg.BaseHeight - room.BaseHeight );
		var head = MathF.Min( wallHeight, leg.TopHeight - room.BaseHeight );

		if ( head - sill < 1f ) {
			return;
		}

		foreach ( var (from, to) in ArchFootprint.Inside( leg.Outline(), wall.Start, wall.End ) ) {
			var start = from * wall.Length;
			var finish = to * wall.Length;

			if ( finish - start < LeastBite ) {
				continue;
			}

			into.Add( new Bite {
				Wall = wall,
				Offset = (start + finish) * 0.5f,
				Width = finish - start,
				Sill = sill,
				Height = head - sill
			} );
		}
	}

	// Plan-wide whatever the cut's reach, or a cut regrouped out of a scope leaves its old holes standing
	// with nobody left who is allowed to close them.
	public static void Close( ArchPlan plan, ArchCutPart cut ) {
		ArchOwned.Close( plan, ArchOwned.Openings, cut.Id );
	}

	public static void Remove( ArchPlan plan, ArchCutPart cut ) {
		Close( plan, cut );
		ArchLayerGroups.Leave( plan, cut.Id );
	}

	sealed class Bite {
		public ArchWall Wall;
		public float Offset;
		public float Width;
		public float Sill;
		public float Height;
	}
}