DiamondMatching.cs
using System;
using System.Collections.Generic;
using static Diamonds.DiamondBoard;

namespace Diamonds;

/// <summary>Side-connected color bonds and rigid-group gravity.</summary>
public static class DiamondMatching
{
	const float Epsilon = 0.01f;

	/// <summary>Matching and bonding both require a full sloped side, never a tip.</summary>
	public static bool Touching( Diamond a, Diamond b ) =>
		MathF.Abs( MathF.Abs( a.Lane - b.Lane ) - 1 ) < Epsilon / Width
		&& MathF.Abs( MathF.Abs( a.Y - b.Y ) - Height * 0.5f ) < Epsilon;

	public readonly record struct CellMove( int Index, Diamond From, Diamond To );

	/// <summary>All cells share one continuous translation and one animation clock.</summary>
	public sealed record SettlementMove( IReadOnlyList<CellMove> Cells )
	{
		public bool IsSlide => Cells[0].From.Lane != Cells[0].To.Lane;
		// Sharper acceleration across the whole slope, with a brisk finish.
		// Crossing another lattice position must not restart the easing clock.
		// Root-scaled fall time keeps acceleration consistent as drop height grows,
		// so long falls build speed instead of stretching to the same terminal speed.
		public float Duration => IsSlide ? 0.18f * MathF.Sqrt( MathF.Abs( Cells[0].To.Lane - Cells[0].From.Lane ) )
			: MathF.Max( 0.14f, 0.2f * MathF.Pow( (Cells[0].To.Y - Cells[0].From.Y) / Height, 1 / EasePower ) );
		public float EasePower => IsSlide ? 2.8f : 2.2f;
		public float Blend( float t ) => MathF.Pow( Math.Clamp( t, 0, 1 ), EasePower );
		public float VelocityScale( float t, float speed ) => EasePower * MathF.Pow( Math.Clamp( t, 0, 1 ), EasePower - 1 ) * speed / Duration;
	}

	public static List<List<int>> FindGroups( IReadOnlyList<Diamond> diamonds )
		=> FindGroups( diamonds, -1 );

	internal static List<List<int>> FindGroups( IReadOnlyList<Diamond> diamonds, int incomingStart )
	{
#if STANDALONE
#endif
		var groups = new List<List<int>>();
		var visited = new bool[diamonds.Count];
		for ( int start = 0; start < diamonds.Count; start++ )
		{
			if ( visited[start] ) continue;
			var group = new List<int> { start };
			visited[start] = true;
			for ( int head = 0; head < group.Count; head++ )
			{
				var current = diamonds[group[head]];
				for ( int next = 0; next < diamonds.Count; next++ )
				{
					// A newly landed piece has not bonded to the existing stack yet.
					if ( incomingStart >= 0 && (group[head] >= incomingStart) != (next >= incomingStart) ) continue;
					if ( visited[next] || diamonds[next].ColorIndex != current.ColorIndex || !Touching( current, diamonds[next] ) ) continue;
					visited[next] = true;
					group.Add( next );
				}
			}
			groups.Add( group );
		}
		return groups;
	}

	/// <summary>Reindex surviving bonds after destruction and split any severed connections.
	/// Never merge different old groups because they happen to touch mid-settlement.</summary>
	public static List<List<int>> SurvivingGroups( IReadOnlyList<Diamond> diamonds,
		IReadOnlyList<IReadOnlyList<int>> oldGroups, IReadOnlyList<int> remap )
	{
		var result = new List<List<int>>();
		foreach ( var old in oldGroups )
		{
			var remaining = new HashSet<int>();
			foreach ( int index in old ) if ( remap[index] >= 0 ) remaining.Add( remap[index] );
			while ( remaining.Count > 0 )
			{
				int first = -1;
				foreach ( int index in remaining ) { first = index; break; }
				remaining.Remove( first );
				var group = new List<int> { first };
				for ( int head = 0; head < group.Count; head++ )
				{
					var attached = new List<int>();
					foreach ( int index in remaining )
						if ( Touching( diamonds[group[head]], diamonds[index] ) ) attached.Add( index );
					foreach ( int index in attached ) { remaining.Remove( index ); group.Add( index ); }
				}
				group.Sort();
				result.Add( group );
			}
		}
		return result;
	}

	/// <summary>
	/// Prefer moving complete assemblies, even across different colors. If an assembly
	/// is blocked, its movable color chunks may peel away. Same-color side bonds never
	/// split, and all members of a chosen assembly share one animation clock.
	/// </summary>
	public static List<SettlementMove> PlanSettlement( IReadOnlyList<Diamond> source, Random random, int incomingStart = -1,
		IReadOnlyList<IReadOnlyList<int>> frozenBonds = null, bool fillCornerPockets = true )
	{
#if STANDALONE
#endif
		var diamonds = new List<Diamond>( source );
		var moves = new List<SettlementMove>();
		var directions = new int[diamonds.Count];
		// Preserve existing bonds and the incoming piece's internal color chunks.
		// New side contacts only bond after this entire settling pass has completed.
		var groups = new List<List<int>>();
		if ( frozenBonds is null ) groups = FindGroups( diamonds, incomingStart );
		else foreach ( var group in frozenBonds ) groups.Add( new List<int>( group ) );
		var workspace = new SettlementWorkspace( diamonds.Count, groups, fillCornerPockets );
		var candidates = new List<MotionCandidate>();
		while ( true )
		{
			workspace.IndexLanes( diamonds );
			candidates.Clear();
			FindMovableAssemblies( diamonds, groups, 0, workspace, candidates );
			FindMovableAssemblies( diamonds, groups, -1, workspace, candidates );
			FindMovableAssemblies( diamonds, groups, 1, workspace, candidates );
			if ( candidates.Count == 0 ) return moves;

			// Prefer the largest coherent assembly over any of its smaller chunks,
			// including when the whole can slide but a smaller part could fall alone.
			candidates.Sort( (a, b) =>
			{
				int order = b.Members.Count.CompareTo( a.Members.Count );
				if ( order != 0 ) return order;
				order = b.Bottom.CompareTo( a.Bottom );
				if ( order != 0 ) return order;
				for ( int i = 0; i < a.Members.Count; i++ )
				{
					order = a.Members[i].CompareTo( b.Members[i] );
					if ( order != 0 ) return order;
				}
				return Math.Abs( a.Direction ).CompareTo( Math.Abs( b.Direction ) );
			} );
			var chosen = candidates[0];
			int directionChosen = chosen.Direction;
			if ( directionChosen != 0 )
			{
				bool bothDirections = candidates.Exists( c => c.Direction == -directionChosen && SameMembers( c.Members, chosen.Members ) );
				if ( bothDirections )
				{
					directionChosen = 0;
					foreach ( int i in chosen.Members ) if ( directions[i] != 0 ) { directionChosen = directions[i]; break; }
					if ( directionChosen == 0 ) directionChosen = random.Next( 2 ) == 0 ? -1 : 1;
				}
			}
			float drop = directionChosen == 0 ? FallDistance( diamonds, chosen.Members, fillCornerPockets, workspace ) : Height * 0.5f;
			var cells = new List<CellMove>( chosen.Members.Count );
			foreach ( int i in chosen.Members )
			{
				var before = diamonds[i];
				diamonds[i] = before with { Lane = before.Lane + directionChosen, Y = before.Y + drop };
				cells.Add( new( i, before, diamonds[i] ) );
				if ( directionChosen != 0 ) directions[i] = directionChosen;
			}
			AppendContinuousMove( moves, new( cells ) );
			// Rebuild temporary movement assemblies, but do not create new color bonds yet.
		}
	}

	static void AppendContinuousMove( List<SettlementMove> moves, SettlementMove next )
	{
		if ( moves.Count > 0 && next.IsSlide )
		{
			var previous = moves[moves.Count - 1];
			bool continues = previous.IsSlide && previous.Cells.Count == next.Cells.Count &&
				Math.Sign( previous.Cells[0].To.Lane - previous.Cells[0].From.Lane ) ==
				Math.Sign( next.Cells[0].To.Lane - next.Cells[0].From.Lane );
			for ( int i = 0; continues && i < next.Cells.Count; i++ )
				continues = previous.Cells[i].Index == next.Cells[i].Index && previous.Cells[i].To == next.Cells[i].From;
			if ( continues )
			{
				var cells = new List<CellMove>( next.Cells.Count );
				for ( int i = 0; i < next.Cells.Count; i++ )
					cells.Add( new( next.Cells[i].Index, previous.Cells[i].From, next.Cells[i].To ) );
				moves[moves.Count - 1] = new( cells );
				return;
			}
		}
		moves.Add( next );
	}

	sealed record MotionCandidate( List<int> Members, int Direction, float Bottom );

	static bool SameMembers( List<int> a, List<int> b )
	{
		if ( a.Count != b.Count ) return false;
		for ( int i = 0; i < a.Count; i++ ) if ( a[i] != b[i] ) return false;
		return true;
	}

	/// <summary>
	/// Scratch storage belongs to one plan: reuse it between directions and steps,
	/// but never retain mutable board state between plans or across boards.
	/// </summary>
	sealed class SettlementWorkspace
	{
		public readonly int[] Owner, Next, Assembly;
		public readonly int[] Heads = new int[LaneCount];
		public readonly bool[] Blocked, Visited, Moving;
		// Mask 1 = touching, mask 2 = movement requires the other group.
		public readonly byte[] Relations;
		public readonly Diamond[] Boundary;

		public SettlementWorkspace( int cells, List<List<int>> groups, bool fillCornerPockets )
		{
			Owner = new int[cells]; Next = new int[cells]; Moving = new bool[cells];
			Assembly = new int[groups.Count]; Blocked = new bool[groups.Count]; Visited = new bool[groups.Count];
			Relations = new byte[groups.Count * groups.Count];
			Boundary = BoundaryDiamonds( fillCornerPockets ).ToArray();
			for ( int group = 0; group < groups.Count; group++ )
				foreach ( int i in groups[group] ) Owner[i] = group;
		}

		public static int Bin( float lane ) => Math.Clamp( (int)MathF.Floor( lane ), 0, LaneCount - 1 );

		public void IndexLanes( List<Diamond> diamonds )
		{
			Array.Fill( Heads, -1 );
			for ( int i = diamonds.Count - 1; i >= 0; i-- )
			{
				int bin = Bin( diamonds[i].Lane );
				Next[i] = Heads[bin]; Heads[bin] = i;
			}
		}

		public bool ClearsBoundary( Diamond cell, int direction, float drop )
		{
			// All wall/corner centers lie at -1, 0, LaneCount-1 or LaneCount.
			// A full path at least two lanes away cannot overlap any of them.
			if ( MathF.Min( cell.Lane, cell.Lane + direction ) >= 2 &&
				MathF.Max( cell.Lane, cell.Lane + direction ) <= LaneCount - 3 ) return true;
			foreach ( var tooth in Boundary )
			{
				if ( MathF.Abs( cell.Lane - tooth.Lane ) * 0.5f + MathF.Abs( cell.Y - tooth.Y ) / Height < 1 - Epsilon / Height ||
					!PathIsClear( cell, tooth, direction, drop ) ) return false;
			}
			return true;
		}
	}

	/// <summary>Eliminate blocked chunks, then join the remaining touching chunks and
	/// movement dependencies. A whole piece can slide away from fixed supports.</summary>
	static void FindMovableAssemblies( List<Diamond> diamonds, List<List<int>> groups, int direction,
		SettlementWorkspace workspace, List<MotionCandidate> result )
	{
		int count = groups.Count;
		var owner = workspace.Owner;
		var blocked = workspace.Blocked;
		var relations = workspace.Relations;
		Array.Clear( blocked ); Array.Clear( relations ); Array.Clear( workspace.Visited );
		// Probe the next rest position; vertical assemblies then travel their full clear distance.
		float drop = Height * 0.5f;
		for ( int i = 0; i < diamonds.Count; i++ )
		{
			var cell = diamonds[i];
			int group = owner[i], row = group * count;
			float lane = cell.Lane + direction;
			if ( lane < 0 || lane >= LaneCount || cell.Y + drop > FloorY( lane ) + Epsilon ) blocked[group] = true;
			if ( !workspace.ClearsBoundary( cell, direction, drop ) ) blocked[group] = true;
			// Conservative swept bounds, including fractional lane positions. Exact diamond
			// tests below retain the original epsilon and corner/face contact semantics.
			int first = SettlementWorkspace.Bin( MathF.Min( cell.Lane, lane ) - 2 );
			int last = SettlementWorkspace.Bin( MathF.Max( cell.Lane, lane ) + 2 );
			for ( int bin = first; bin <= last; bin++ )
			for ( int j = workspace.Heads[bin]; j >= 0; j = workspace.Next[j] )
			{
				int otherGroup = owner[j];
				if ( group == otherGroup ) continue;
				var other = diamonds[j];
				if ( other.Y < cell.Y - Height || other.Y > cell.Y + drop + Height ) continue;
				if ( Touching( cell, other ) ) relations[row + otherGroup] |= 1;
				if ( !PathIsClear( cell, other, direction, drop ) ) relations[row + otherGroup] |= 2;
			}
		}
		bool changed;
		do
		{
			changed = false;
			for ( int group = 0; group < count; group++ )
			{
				if ( blocked[group] ) continue;
				int row = group * count;
				for ( int other = 0; other < count; other++ )
					if ( (relations[row + other] & 2) != 0 && blocked[other] )
					{
						blocked[group] = true; changed = true; break;
					}
			}
		} while ( changed );

		var visited = workspace.Visited;
		var assembly = workspace.Assembly;
		for ( int start = 0; start < count; start++ )
		{
			if ( blocked[start] || visited[start] ) continue;
			assembly[0] = start;
			int assemblyCount = 1;
			var members = new List<int>();
			visited[start] = true;
			float bottom = float.MinValue;
			for ( int head = 0; head < assemblyCount; head++ )
			{
				int group = assembly[head], row = group * count;
				foreach ( int i in groups[group] )
				{
					members.Add( i ); bottom = MathF.Max( bottom, diamonds[i].Y );
				}
				for ( int other = 0; other < count; other++ )
				{
					if ( visited[other] || blocked[other] ) continue;
					if ( relations[row + other] == 0 && (relations[other * count + group] & 2) == 0 ) continue;
					visited[other] = true; assembly[assemblyCount++] = other;
				}
			}
			members.Sort();
			result.Add( new( members, direction, bottom ) );
		}
	}

	static float FallDistance( List<Diamond> diamonds, List<int> members, bool fillCornerPockets, SettlementWorkspace workspace )
	{
		var moving = workspace.Moving;
		Array.Clear( moving );
		foreach ( int i in members ) moving[i] = true;
		float drop = float.MaxValue;
		foreach ( int i in members )
		{
			var cell = diamonds[i];
			float landing = BoundaryLandingY( cell.Lane, cell.Y, fillCornerPockets );
			int last = SettlementWorkspace.Bin( cell.Lane + 2 );
			for ( int bin = SettlementWorkspace.Bin( cell.Lane - 2 ); bin <= last; bin++ )
			for ( int j = workspace.Heads[bin]; j >= 0; j = workspace.Next[j] )
			{
				if ( moving[j] ) continue;
				float dx = MathF.Abs( cell.Lane - diamonds[j].Lane ) * 0.5f;
				if ( dx >= 1 ) continue;
				float contact = diamonds[j].Y - Height * (1 - dx);
				if ( contact >= cell.Y - Epsilon ) landing = MathF.Min( landing, contact );
			}
			drop = MathF.Min( drop, landing - cell.Y );
		}
		return drop;
	}

	static bool PathIsClear( Diamond cell, Diamond other, int direction, float drop )
	{
		float crossX = direction == 0 ? 0 : Math.Clamp( (other.Lane - cell.Lane) / direction, 0, 1 );
		float crossY = Math.Clamp( (other.Y - cell.Y) / drop, 0, 1 );
		return ClearAt( 1 ) && ClearAt( crossX ) && ClearAt( crossY );
		bool ClearAt( float t ) => MathF.Abs( cell.Lane + direction * t - other.Lane ) * 0.5f
			+ MathF.Abs( cell.Y + drop * t - other.Y ) / Height >= 1 - Epsilon / Height;
	}
}