MoneyTable.cs
using Sandbox;
using SWB.Player;
using SWB.Shared;
using System;
using System.Collections.Generic;
using System.Linq;

// A table of cash stacks the crew has to bag before the escape opens.
//
// Put this on the ROOT of MoneyTable.prefab. It finds its own stacks (every
// child whose name starts with StackNamePrefix), so adding or removing
// stacks in the prefab needs no rewiring - the count is whatever's there.
//
// Stand next to it and hold E: stacks vanish one at a time, top of the pile
// first, until the table is bare. Nothing happens until MissionTimer calls
// Unlock() (when the vault gate opens), so it can't be looted early by
// reaching through the bars.
//
// NETWORKING. Same shape as MissionBoard's ready-up:
//   - Holding E is detected on whichever machine the player is on, and
//     each stack is a REQUEST to the host (RequestBagStack, [Rpc.Host]).
//   - The host is the only one that decides - so two players bagging the
//     same table at once can't both take "stack 64" - and then tells
//     everyone the new count (BroadcastBagged, [Rpc.Broadcast]).
//   - Every machine hides stacks from that count, so they all agree on
//     what's left on the table.
// Co-op is still untested for this game as a whole; this follows the
// pattern the job board already uses rather than inventing a new one.
//
// Range is a plain distance check rather than a trigger collider. Trigger
// enter/exit fires per COLLIDER, and a player is made of several (body,
// hitboxes), so walking out of range could flip "in range" off while you
// were still standing there. Distance to the local player has no such
// edge cases, and means nothing extra has to be added to the prefab.
public sealed class MoneyTable : Component, IInteractable
{
	// Children whose name starts with this are treated as cash. The prefab's
	// are "money_stack", "money_stack (1)" ... "money_stack (63)".
	[Property]
	public string StackNamePrefix { get; set; } = "money_stack";

	// How close you have to be, measured flat (ignoring height) from this
	// object's origin. The prefab's origin sits in the middle of the pile.
	[Property]
	public float InteractRange { get; set; } = 90f;

	// Hold time per stack. 0.1 is ~6.5 seconds for a 64-stack table: long
	// enough that bagging under fire is a real decision, short enough that
	// it doesn't feel like a chore.
	[Property]
	public float SecondsPerStack { get; set; } = 0.1f;

	/// <summary>Stacks bagged so far. Owned by the host, mirrored to everyone.</summary>
	public int StacksBagged { get; private set; }

	/// <summary>How many stacks this table started with.</summary>
	public int StackCount => _stacks.Count;

	public bool IsEmpty => StacksBagged >= StackCount;

	/// <summary>True once MissionTimer has opened the vault.</summary>
	public bool IsUnlocked { get; private set; }

	// Sorted top of the pile first, so the table empties the way a person
	// would grab it rather than cash vanishing from the bottom of the stack.
	List<GameObject> _stacks = new();

	// Local-player hold state. Never networked - only ever about whoever is
	// sitting at this machine.
	bool _playerInRange;
	float _holdTimer;

	ObjectiveMarker _marker;

	protected override void OnStart()
	{
		_stacks = GameObject.Children
			.Where( c => c.Name.StartsWith( StackNamePrefix ) )
			.OrderByDescending( c => c.WorldPosition.z )
			.ToList();

		if ( _stacks.Count == 0 )
			Log.Warning( $"MoneyTable '{GameObject.Name}': no children named '{StackNamePrefix}...' - nothing to bag. " +
				"Is this on the prefab root?" );

		// Waypoint for the table. Created here rather than added to the
		// prefab by hand, so there's one less thing to wire - but if the
		// prefab already has one (someone wanted a custom label), that one
		// is used instead.
		_marker = Components.GetOrCreate<ObjectiveMarker>();
		if ( _marker.Label == "Objective" )
		{
			_marker.Label = "BAG THE CASH";
			_marker.Color = new Color( 0.49f, 0.85f, 0.34f );
			_marker.Offset = Vector3.Up * 32f;
		}

		UpdateMarker();
	}

	// Called by MissionTimer when the vault gate opens.
	public void Unlock()
	{
		IsUnlocked = true;
		UpdateMarker();
	}

	protected override void OnUpdate()
	{
		_playerInRange = LocalPlayerCanReach();

		if ( !_playerInRange || !IsUnlocked || IsEmpty )
		{
			_holdTimer = 0f;
			return;
		}

		if ( !Input.Down( InputButtonHelper.Use ) )
		{
			_holdTimer = 0f;
			return;
		}

		_holdTimer += Time.Delta;

		// A loop rather than an if, so a long frame doesn't lose stacks -
		// at 0.1s a stack, one 250ms hitch is two stacks, not one.
		var step = MathF.Max( SecondsPerStack, 0.01f );
		while ( _holdTimer >= step )
		{
			_holdTimer -= step;
			RequestBagStack();
		}
	}

	// Alive, still in the job, and close enough.
	bool LocalPlayerCanReach()
	{
		var player = PlayerBase.Local;

		if ( player is null || !player.IsAlive )
			return false;

		// Out of the run (dead and spectating, or already extracted) - you
		// don't get to keep looting from the escape van.
		if ( player.Components.Get<MissionPlayerState>()?.IsOut ?? false )
			return false;

		var offset = player.WorldPosition - WorldPosition;
		offset.z = 0f;

		return offset.Length <= InteractRange;
	}

	// Runs on the host whoever called it. The host checks and counts, so
	// simultaneous grabs from two players can never over-count.
	[Rpc.Host]
	void RequestBagStack()
	{
		if ( !IsUnlocked || IsEmpty )
			return;

		BroadcastBagged( StacksBagged + 1 );
	}

	// Everyone - host included - applies the host's count.
	[Rpc.Broadcast]
	void BroadcastBagged( int bagged )
	{
		StacksBagged = Math.Clamp( bagged, 0, StackCount );

		// Hide from the top down. Disabled rather than destroyed: it's
		// cheaper, and a disabled object can't end up half-destroyed on one
		// machine and alive on another.
		for ( var i = 0; i < _stacks.Count; i++ )
		{
			if ( _stacks[i].IsValid() )
				_stacks[i].Enabled = i >= StacksBagged;
		}

		if ( IsEmpty )
			Log.Info( $"MoneyTable '{GameObject.Name}' cleared." );

		UpdateMarker();
	}

	// Marker only while there's something to do here.
	void UpdateMarker()
	{
		if ( _marker.IsValid() )
			_marker.Enabled = IsUnlocked && !IsEmpty;
	}

	// --- IInteractable ----------------------------------------------------

	public bool PlayerInRange => _playerInRange && IsUnlocked;

	// The bar shows how much of the TABLE is bagged rather than the hold on
	// one stack - one stack is a tenth of a second, so a per-stack bar would
	// just flicker. This way the bar is a real progress readout.
	public float HoldFraction => StackCount == 0 ? 0f : (float)StacksBagged / StackCount;

	public string PromptText => $"Hold E to bag the cash — {StacksBagged}/{StackCount}";

	public bool SuppressPrompt => IsEmpty;
}