Voxel/MonolithRenderer.cs
namespace Monolith;

/// <summary>
/// Owns one child GameObject with a ModelRenderer per non-empty chunk, and rebuilds dirty
/// chunk meshes on a per-frame budget so a big blast never stalls the frame.
/// </summary>
public sealed class MonolithRenderer : Component
{
	/// <summary>
	/// Milliseconds of remeshing allowed per frame. See Tuning: this replaced a flat count of
	/// two chunks per frame, which could not keep up with a single large blast and left the
	/// backlog growing forever. Black holes in the shape were a queue that never drained.
	/// </summary>
	[Property] public float RebuildMillisecondsPerFrame { get; set; }
		= Tuning.ChunkRebuildMillisecondsPerFrame;

	/// <summary>
	/// A material that READS VERTEX COLOUR, which `materials/default.vmat` does not.
	///
	/// **This was a live regression and it is most of why the shape looked like a plain box.**
	/// The mesher has always written a per-face shade into vertex colour, and at some point the
	/// material was moved back to `default.vmat` on the reasoning that Tint works everywhere and
	/// vertex colour might not. Tint does work, but it is ONE colour for a whole chunk, so every
	/// face of the shape rendered at identical brightness: a cube lit that way has no form at all,
	/// and no palette or lighting change can put it back.
	///
	/// This is the same bug recorded on 2026-08-02, reintroduced. Vertex colour now carries face
	/// shading, strata banding AND per-corner ambient occlusion, so the material reading it is no
	/// longer optional.
	///
	/// The mesher writes GREYSCALE, and Tint supplies the hue on top. If the shape ever renders
	/// colourless, that means this material is not honouring Tint, and the fix is to bake the
	/// rock colour into the vertex colour in ChunkMesher rather than to revert this line.
	/// </summary>
	[Property] public string MaterialPath { get; set; } = "materials/default/vertex_color.vmat";

	/// <summary>Volatile blocks. Deliberately hot and far brighter than any rock tint.</summary>
	[Property] public Color VolatileColor { get; set; } = new( 1.6f, 0.16f, 0.06f );

	// Devil Daggers palette, applied as Tint. Bright, because the tint is the only thing
	// guaranteed to reach the screen and the shape has to read against a pure black void.
	//
	// Lifted again after the retro colour pass: the reference's rock is closer to lit BONE than
	// to brown, and the gradient from base to top is what stops a carved face from reading as
	// one flat mass. Darkening these to look "gritty" is the mistake the whole look-matching
	// pass nearly made.
	[Property] public Color BaseColor { get; set; } = new( 0.92f, 0.55f, 0.26f );
	[Property] public Color TopColor { get; set; } = new( 1.25f, 1.05f, 0.76f );

	/// <summary>
	/// When true the last few cubes glow so they can actually be found. Driven by
	/// <see cref="MonolithManager"/> once the remaining count drops below the reveal threshold.
	/// </summary>
	public bool HighlightResidue { get; set; }

	private bool lastHighlight;

	private VoxelWorld world;
	private Material material;
	private GameObject[] chunkObjects;
	private ModelRenderer[] chunkRenderers;

	// Volatile faces live in their own object per chunk, purely so they can carry a different
	// Tint. Most chunks never allocate one.
	private GameObject[] volatileObjects;
	private ModelRenderer[] volatileRenderers;
	private readonly List<int> pending = new();

	/// <summary>
	/// Chunk index to its slot in <see cref="pending"/>.
	///
	/// This replaced a plain HashSet, which could answer "is it queued" but not "where". That
	/// gap was a real bug: the queue is LIFO so the newest entry rebuilds first, but a chunk
	/// that was ALREADY queued simply stayed where it was. On a big stage every chunk is queued
	/// at load, so shooting a hole in one left it sitting at the bottom of the initial backlog,
	/// and the shape you had just blown apart did not visibly change until the whole load
	/// finished. Knowing the slot lets a re-dirtied chunk be promoted in O(1).
	/// </summary>
	private readonly Dictionary<int, int> pendingSlot = new();

	public int PendingRebuilds => pending.Count;

	/// <summary>
	/// Queues a chunk, or promotes it to the front of the LIFO if it is already queued.
	///
	/// Promotion is a swap with the last element rather than a remove-and-append, so it stays
	/// O(1) no matter how deep the backlog is.
	/// </summary>
	private void Enqueue( int index )
	{
		if ( pendingSlot.TryGetValue( index, out int slot ) )
		{
			int last = pending.Count - 1;
			if ( slot == last )
				return;

			int moved = pending[last];

			pending[last] = index;
			pending[slot] = moved;

			pendingSlot[index] = last;
			pendingSlot[moved] = slot;
			return;
		}

		pending.Add( index );
		pendingSlot[index] = pending.Count - 1;
	}

	/// <summary>
	/// Removes the entry at a slot in O(1) by swapping the last element into its place.
	/// </summary>
	private int DequeueAt( int slot )
	{
		int index = pending[slot];
		int last = pending.Count - 1;

		if ( slot != last )
		{
			int moved = pending[last];
			pending[slot] = moved;
			pendingSlot[moved] = slot;
		}

		pending.RemoveAt( last );
		pendingSlot.Remove( index );

		return index;
	}

	/// <summary>Takes the most recently dirtied chunk. That is the one you are looking at.</summary>
	private int Dequeue() => DequeueAt( pending.Count - 1 );

	/// <summary>Takes from the far end, which is what stops the backlog starving.</summary>
	private int DequeueStale() => DequeueAt( 0 );

	public void Attach( VoxelWorld voxelWorld )
	{
		Teardown();

		world = voxelWorld;

		material = Material.Load( MaterialPath );

		chunkObjects = new GameObject[world.ChunkCount];
		chunkRenderers = new ModelRenderer[world.ChunkCount];
		volatileObjects = new GameObject[world.ChunkCount];
		volatileRenderers = new ModelRenderer[world.ChunkCount];

		pending.Clear();
		pendingSlot.Clear();

		for ( int i = 0; i < world.ChunkCount; i++ )
			Enqueue( i );
	}

	protected override void OnDestroy()
	{
		Teardown();
	}

	private void Teardown()
	{
		if ( chunkObjects == null )
			return;

		foreach ( var obj in chunkObjects )
			obj?.Destroy();

		if ( volatileObjects != null )
		{
			foreach ( var obj in volatileObjects )
				obj?.Destroy();
		}

		chunkObjects = null;
		chunkRenderers = null;
		volatileObjects = null;
		volatileRenderers = null;
	}

	protected override void OnUpdate()
	{
		if ( world == null )
			return;

		if ( HighlightResidue != lastHighlight )
		{
			lastHighlight = HighlightResidue;
			RefreshTints();
		}

		// Pull anything the simulation dirtied since last frame into our queue. Enqueue also
		// PROMOTES an already-queued chunk, which is what makes a shot show up immediately
		// even while a large initial load is still outstanding.
		if ( world.DirtyChunks.Count > 0 )
		{
			foreach ( var index in world.DirtyChunks )
				Enqueue( index );

			world.DirtyChunks.Clear();
		}

		// A big backlog is either an initial load or the aftermath of a large blast. Both are
		// worth a bigger slice of the frame: the alternative is watching the shape assemble in
		// slabs, or fighting next to holes that have not caught up yet.
		float allowance = pending.Count >= Tuning.ChunkRebuildCatchUpThreshold
			? Tuning.ChunkRebuildCatchUpMilliseconds
			: RebuildMillisecondsPerFrame;

		var clock = System.Diagnostics.Stopwatch.StartNew();
		int built = 0;
		int backlogAtStart = pending.Count;

		while ( pending.Count > 0 && built < Tuning.ChunkRebuildMaxPerFrame )
		{
			// ALTERNATES newest and oldest, and this is the fix for the Monolith looking solid
			// at 90% cleared.
			//
			// Newest-first is right for responsiveness: the chunk you just shot should update
			// now. But it starves. While you are firing, new dirt arrives every frame and always
			// goes to the top, so anything already in the queue is never reached: chunks you
			// cleared thirty seconds ago were still holding their original geometry, which is
			// why a shape reported as 90% gone still rendered as a slab.
			//
			// Taking every other rebuild from the far end bounds how long any chunk can wait,
			// at the cost of halving how fast the freshest one lands. **A pure priority queue
			// whose high-priority end is continuously refilled will never drain its tail.**
			Rebuild( (built & 1) == 0 ? Dequeue() : DequeueStale() );

			built++;

			// Checked AFTER at least one rebuild, so a frame always makes progress even if the
			// budget is already blown. Never letting the queue shrink is the failure mode this
			// whole change exists to avoid.
			if ( clock.Elapsed.TotalMilliseconds >= allowance )
				break;
		}

		ReportBacklog( backlogAtStart, built, (float)clock.Elapsed.TotalMilliseconds );
	}

	private TimeSince timeSinceBacklogReport;
	private int worstBacklog;

	/// <summary>
	/// Periodic diagnostic for the "Monolith does not draw in" problem.
	///
	/// The question that cannot be answered by reading the code is which of two things is
	/// happening: the queue is DRAINING but too slowly (a budget problem, fix by spending more
	/// frame time) or it is GROWING faster than any budget could clear (a throughput problem,
	/// which needs the mesher itself to get cheaper, or fewer chunks dirtied per shot).
	///
	/// Logging peak backlog alongside how many were actually rebuilt distinguishes them: a
	/// backlog that keeps climbing while `built` is already at the cap is the second case.
	/// Deliberately quiet unless there is a real backlog, so it costs nothing in normal play.
	/// </summary>
	private void ReportBacklog( int backlog, int built, float ms )
	{
		if ( backlog > worstBacklog )
			worstBacklog = backlog;

		if ( timeSinceBacklogReport < 2f || worstBacklog < 40 )
			return;

		timeSinceBacklogReport = 0f;

		Log.Info( $"[mesh] backlog peak {worstBacklog}, now {pending.Count}, " +
			$"rebuilt {built} in {ms:0.0}ms of {Tuning.ChunkRebuildCatchUpMilliseconds}ms, " +
			$"chunks {world.ChunkCount}" );

		worstBacklog = pending.Count;
	}

	private void Rebuild( int index )
	{
		var chunk = world.GetChunkByIndex( index );
		chunk.MeshDirty = false;

		var built = ChunkMesher.Build( world, index, material );

		ApplyPart( index, built.Rock, TintForChunk( chunk.Coord ),
			ref chunkObjects, ref chunkRenderers, "Chunk" );

		ApplyPart( index, built.Volatile, VolatileColor,
			ref volatileObjects, ref volatileRenderers, "Volatile" );
	}

	/// <summary>
	/// Assigns one of a chunk's two meshes to its own object, creating or dropping that object
	/// as needed. The rock and volatile halves are identical in every respect except tint.
	/// </summary>
	private void ApplyPart( int index, Model model, Color tint,
		ref GameObject[] objects, ref ModelRenderer[] renderers, string label )
	{
		if ( model == null )
		{
			if ( objects[index].IsValid() )
			{
				objects[index].Destroy();
				objects[index] = null;
				renderers[index] = null;
			}

			return;
		}

		if ( !objects[index].IsValid() )
		{
			var coord = world.ChunkCoordFromIndex( index );

			var obj = new GameObject( true, $"{label} {coord.x},{coord.y},{coord.z}" );
			obj.Parent = GameObject;
			obj.LocalPosition = world.Origin + new Vector3(
				coord.x * VoxelChunk.Size,
				coord.y * VoxelChunk.Size,
				coord.z * VoxelChunk.Size ) * Tuning.VoxelSize;

			objects[index] = obj;
			renderers[index] = obj.AddComponent<ModelRenderer>();
		}

		renderers[index].Model = model;

		// Tint is the hue. Vertex colour, if the material reads it, supplies the face shading
		// on top. This is why the split exists: two tints, one material, always visible.
		renderers[index].Tint = tint;
	}

	/// <summary>Repaints every live chunk. Cheap again: colour is Tint, so no remeshing.</summary>
	private void RefreshTints()
	{
		if ( chunkRenderers == null )
			return;

		for ( int i = 0; i < chunkRenderers.Length; i++ )
		{
			if ( chunkRenderers[i].IsValid() )
				chunkRenderers[i].Tint = TintForChunk( world.ChunkCoordFromIndex( i ) );
		}
	}

	private Color TintForChunk( Vector3Int coord )
	{
		// Nearly cleared: make what is left unmissable rather than a grey speck in the void.
		if ( HighlightResidue )
			return new Color( 1f, 0.55f, 0.15f );

		float t = world.ChunkCounts.z <= 1
			? 0.5f
			: coord.z / (float)(world.ChunkCounts.z - 1);

		// A little per-chunk jitter stops the monolith reading as a flat gradient.
		float jitter = (HashCoord( coord ) % 1000) / 1000f;
		t = Math.Clamp( t + (jitter - 0.5f) * 0.12f, 0f, 1f );

		return Color.Lerp( BaseColor, TopColor, t );
	}

	private static int HashCoord( Vector3Int c )
	{
		unchecked
		{
			int h = c.x * 73856093 ^ c.y * 19349663 ^ c.z * 83492791;
			return h & 0x7fffffff;
		}
	}
}