Editor/Stair/ArchStairHousing.cs
namespace Sunless.Architecture;

// A step fitted to the shell side it stands on — head and flanks, in the drawn frame.
public readonly record struct ArchStairFit( float AlongFrom, float AlongTo, float AcrossFrom, float AcrossTo );

// Where a step may reach: each step belongs to ONE side of the shell (majority wins), partitions are ignored.
public sealed class ArchStairHousing {
	// Sample density per axis for majority-side detection.
	const int Samples = 7;

	readonly List<IReadOnlyList<Vector2>> region = new();
	readonly List<ArchWall> shell = new();
	readonly ArchKit kit;

	ArchStairHousing( ArchKit kit ) {
		this.kit = kit;
	}

	public static ArchStairHousing On( IEnumerable<ArchRoom> storey, int level, ArchKit kit, ArchKinds kinds ) {
		var housing = new ArchStairHousing( kit );

		if ( storey is null ) {
			return housing;
		}

		var rooms = storey.Where( entry => entry.Floor == level ).ToList();
		var loops = rooms.Select( ArchFloorGen.Footprint ).Where( loop => loop.Count >= 3 ).ToList();

		if ( loops.Count == 0 ) {
			return housing;
		}

		// A non-rectilinear room has no grid to merge on, so it keeps its own loop - the same split ArchFloorGen
		// makes when it groups a storey's slabs, and for the same reason.
		housing.region.AddRange( ArchFootprint.Bearing( loops, out var bearing )
			&& loops.All( loop => ArchFootprint.IsRectilinear( loop, bearing ) )
				? ArchFootprint.Union( loops )
				: loops );

		foreach ( var room in rooms ) {
			foreach ( var wall in ArchPlanStore.FiledOn( ArchKind.Wall, room, kinds ).OfType<ArchWall>() ) {
				if ( Bounding( housing.region, wall ) ) {
					housing.shell.Add( wall );
				}
			}
		}

		return housing;
	}

	public ArchStairFit Fit( ArchStairAxes axes, float alongFrom, float alongTo, float acrossFrom, float acrossTo ) {
		var drawn = new ArchStairFit( alongFrom, alongTo, acrossFrom, acrossTo );

		if ( shell.Count == 0 || alongTo - alongFrom < 0.5f || acrossTo - acrossFrom < 0.5f ) {
			return drawn;
		}

		var seat = Seat( axes, drawn );

		// The step is wholly on one side already, and there is nothing for the shell to take off it.
		if ( !seat.Crossed ) {
			return drawn;
		}

		var seed = axes.Flat( seat.Along, seat.Across );

		var head = seat.Along + Bounded( seed, axes.Along, alongTo - seat.Along );
		var left = seat.Across + Bounded( seed, axes.Across, acrossTo - seat.Across );
		var right = seat.Across - Bounded( seed, -axes.Across, seat.Across - acrossFrom );

		// Nothing the shell takes off a step may take it under the shortest one the tool will stand, or a flight
		// sitting a hair over a wall comes back as no flight at all. It is widened back toward the step as it was
		// drawn, never out past it.
		head = MathF.Max( head, alongFrom + MathF.Min( ArchStairLanes.MinLane, alongTo - alongFrom ) );

		var least = MathF.Min( ArchStairLanes.MinLane, acrossTo - acrossFrom );

		if ( left - right < least ) {
			left = MathF.Min( acrossTo, right + least );
			right = MathF.Max( acrossFrom, left - least );
		}

		return new ArchStairFit( alongFrom, head, right, left );
	}

	// Where the step mostly stands, and whether it is over the shell at all. The samples on the winning side
	// average out to a point that side genuinely holds, which is what every probe is then fired from - a plain
	// centre lands in the wall itself on the step this exists to fix.
	(float Along, float Across, bool Crossed) Seat( ArchStairAxes axes, ArchStairFit drawn ) {
		var inside = 0;
		var outside = 0;
		var held = Vector2.Zero;
		var away = Vector2.Zero;

		for ( var alongStep = 0; alongStep < Samples; alongStep++ ) {
			var along = MathX.Lerp( drawn.AlongFrom, drawn.AlongTo, (alongStep + 0.5f) / Samples );

			for ( var acrossStep = 0; acrossStep < Samples; acrossStep++ ) {
				var across = MathX.Lerp( drawn.AcrossFrom, drawn.AcrossTo, (acrossStep + 0.5f) / Samples );
				var at = new Vector2( along, across );

				if ( ArchFootprint.Encloses( region, axes.Flat( along, across ) ) ) {
					inside++;
					held += at;
				} else {
					outside++;
					away += at;
				}
			}
		}

		if ( inside == 0 || outside == 0 ) {
			return (0f, 0f, false);
		}

		var winner = inside >= outside ? held / inside : away / outside;

		return (winner.x, winner.y, true);
	}

	// How far the shell stands off the seed that way, capped by how far the step reached anyway. A probe that
	// started inside a wall's own thickness answers negative - a flight flush against the shell is not a flight
	// of nothing.
	float Bounded( Vector2 seed, Vector2 heading, float limit ) {
		if ( limit <= 0f ) {
			return 0f;
		}

		var reach = ArchProbe.Reach( shell, kit, seed, heading, limit );

		return reach >= limit ? limit : MathF.Max( 0f, reach );
	}

	static bool Bounding( IReadOnlyList<IReadOnlyList<Vector2>> region, ArchWall wall ) {
		return wall.Length >= 1f
			&& ArchProbe.Faces( region, wall.Start, wall.End, wall.Normal )
			!= ArchProbe.Faces( region, wall.Start, wall.End, -wall.Normal );
	}
}

// Every storey a climb passes through, resolved on first ask and kept for the rest of that resolve. A stair of
// six steps in one room would otherwise walk the storey's rooms and walls six times over to be told the same
// thing each time.
public sealed class ArchStairHousings {
	readonly Dictionary<int, ArchStairHousing> standing = new();
	readonly IEnumerable<ArchRoom> storey;
	readonly ArchKit kit;
	readonly ArchKinds kinds;

	public ArchStairHousings( IEnumerable<ArchRoom> storey, ArchKit kit, ArchKinds kinds ) {
		this.storey = storey;
		this.kit = kit;
		this.kinds = kinds;
	}

	public ArchStairHousing On( int level ) {
		if ( !standing.TryGetValue( level, out var housing ) ) {
			housing = ArchStairHousing.On( storey, level, kit, kinds );
			standing[level] = housing;
		}

		return housing;
	}
}