Npcs/Diner/DinerNpc.cs
using Sandbox.Npcs.Layers;
using Sandbox.Npcs.Schedules;

namespace Sandbox.Npcs.Diner;

/// <summary>
/// A civilian enjoying breakfast at an outdoor cafe — until someone drives through it.
///
/// Behaviour states:
///   Sitting  → vehicle gets too close → Fleeing
///   Fleeing  → fear decays → Milling (looking for a chair)
///   Milling  → finds a free chair → Sitting
///   Milling  → no chair found → keeps milling + complaining
/// </summary>
public class DinerNpc : Npc, Component.IDamageable
{
	// ── Inspector ─────────────────────────────────────────────────────────────

	[Property, Sync]
	public float Health { get; set; } = 100f;

	/// <summary>
	/// The chair this NPC considers "theirs" at spawn. They'll try to return to it.
	/// Drag a BaseChair GameObject in here in the editor.
	/// </summary>
	[Property]
	public GameObject AssignedChair { get; set; }

	/// <summary>
	/// The area the NPC mills around in when they can't find a seat.
	/// Centre point — they'll wander within MillRadius of this.
	/// </summary>
	[Property]
	public Vector3 HomeArea { get; set; }

	[Property, Range( 100f, 1000f )]
	public float MillRadius { get; set; } = 300f;

	/// <summary>
	/// How close a vehicle has to get before this NPC panics.
	/// </summary>
	[Property, Range( 50f, 500f )]
	public float PanicRange { get; set; } = 200f;

	/// <summary>
	/// Chance, each time the customer settles, to take an ambient stroll instead of sitting.
	/// 0 = always sit when a seat is free (original behaviour); 1 = always wander.
	/// Raise it to make the crowd livelier.
	/// </summary>
	[Property, Group( "Balance" ), Range( 0f, 1f )]
	public float WanderChance { get; set; } = 0.15f;

	/// <summary>
	/// Chance, when settling, to go inspect a nearby food item instead of sitting/wandering.
	/// Only fires if there's actually food (tagged <see cref="FoodTag"/>) within MillRadius.
	/// </summary>
	[Property, Group( "Balance" ), Range( 0f, 1f )]
	public float InspectFoodChance { get; set; } = 0.5f;

	/// <summary>
	/// Tag that marks something as edible/inspectable. Tag your served-food prefabs (and, for
	/// laughs, peaches) with this. Karts and other props won't carry it, so customers won't
	/// grab them. Peaches additionally tagged "peach" get a different reaction.
	/// </summary>
	[Property, Group( "Balance" )]
	public string FoodTag { get; set; } = "food";

	/// <summary>
	/// How long a customer stays seated before getting up to re-evaluate (and maybe wander).
	/// Set to 0 to sit indefinitely until scared (the original behaviour).
	/// </summary>
	[Property, Group( "Balance" ), Range( 0f, 120f )]
	public float MaxSeatedTime { get; set; } = 18f;

	/// <summary>
	/// Seconds at full fear before decay starts.
	/// </summary>
	[Property, Group( "Balance" )]
	public float FearGracePeriod { get; set; } = 4f;

	/// <summary>
	/// Fear units lost per second after grace period.
	/// </summary>
	[Property, Group( "Balance" )]
	public float FearDecayRate { get; set; } = 0.12f;

	// ── State ─────────────────────────────────────────────────────────────────

	public float AfraidLevel
	{
		get
		{
			if ( _peakFear <= 0f ) return 0f;
			if ( _timeSinceScared <= FearGracePeriod ) return _peakFear;
			var decay = _timeSinceScared - FearGracePeriod;
			return MathF.Max( _peakFear - decay * FearDecayRate, 0f );
		}
	}

	public bool IsSitting { get; private set; }
	public bool IsFleeing { get; private set; }

	/// <summary>
	/// The vehicle (or other threat) that scared this NPC.
	/// </summary>
	public GameObject ThreatSource { get; private set; }

	private float _peakFear;
	private TimeSince _timeSinceScared;

	/// <summary>The chair this diner has reserved (tagged "diner_taken") while settling/sitting.</summary>
	private GameObject _claimedChair;

	// ── Lifecycle ─────────────────────────────────────────────────────────────

	protected override void OnStart()
	{
		base.OnStart();

		if ( HomeArea == Vector3.Zero )
			HomeArea = WorldPosition;

		GameObject.Tags.Add( "diner_npc" );
	}

	protected override void OnUpdate()
	{
		base.OnUpdate();

		if ( IsProxy ) return;

		CheckForNearbyVehicles();
	}

	// ── Schedule selection ────────────────────────────────────────────────────

	public override ScheduleBase GetSchedule()
	{
		var fear = AfraidLevel;

		if ( fear <= 0f && _peakFear > 0f )
		{
			_peakFear    = 0f;
			ThreatSource = null;
			IsFleeing    = false;
		}

		if ( fear > 0f && ThreatSource.IsValid() )
		{
			IsFleeing = true;
			IsSitting = false;
			ReleaseChair();
			var flee = GetSchedule<DinerFleeSchedule>();
			flee.Source     = ThreatSource;
			flee.PanicLevel = fear;
			return flee;
		}

		IsFleeing = false;

		return GetIdleSchedule();
	}

	/// <summary>
	/// Calm behaviour: usually settle into a free seat, but sometimes take a stroll around
	/// the café so customers aren't glued to chairs. Falls back to milling when there's no
	/// free seat. Mirrors ScientistNpc.GetIdleSchedule's variety so the crowd stays alive.
	/// </summary>
	private ScheduleBase GetIdleSchedule()
	{
		// Sometimes wander instead of settling.
		if ( Game.Random.Float() < WanderChance )
		{
			IsSitting = false;
			ReleaseChair();
			return GetSchedule<DinerWanderSchedule>();
		}

		// Sometimes go investigate food that's been served (or a stray peach).
		if ( Game.Random.Float() < InspectFoodChance )
		{
			var food = FindNearbyFood();
			if ( food.IsValid() )
			{
				IsSitting = false;
				ReleaseChair();
				var inspect = GetSchedule<DinerInspectFoodSchedule>();
				inspect.FoodTarget = food;
				inspect.IsPeach    = food.Tags.Has( "peach" );
				return inspect;
			}
		}

		var chair = FindAvailableChair();
		if ( chair.IsValid() )
		{
			IsSitting = true;
			ClaimChair( chair );
			var sit = GetSchedule<DinerSitSchedule>();
			sit.Chair         = chair;
			sit.MaxSeatedTime = MaxSeatedTime;
			return sit;
		}

		IsSitting = false;
		ReleaseChair();
		return GetSchedule<DinerMillSchedule>();
	}

	// ── Vehicle proximity check ───────────────────────────────────────────────

	private void CheckForNearbyVehicles()
	{
		if ( AfraidLevel >= 0.8f ) return;

		var nearby = Scene.FindInPhysics( new Sphere( WorldPosition, PanicRange ) );
		foreach ( var obj in nearby )
		{
			if ( !obj.Tags.HasAny( ["vehicle", "wheel", "kart"] ) ) continue;

			var rb = obj.GetComponent<Rigidbody>();
			if ( rb.IsValid() && rb.Velocity.Length < 30f ) continue;

			Scare( obj, 0.7f + Game.Random.Float( 0f, 0.3f ) );
			EndCurrentSchedule();
			return;
		}
	}

	/// <summary>
	/// Trigger fear from an external source.
	/// </summary>
	public void Scare( GameObject source, float intensity )
	{
		_peakFear        = MathF.Min( _peakFear + intensity, 1f );
		ThreatSource     = source;
		_timeSinceScared = 0;
		IsSitting        = false;
	}

	// ── Chair finding ─────────────────────────────────────────────────────────

	/// <summary>
	/// Find a chair to sit in. Prefers assigned chair, falls back to any free chair nearby.
	/// Uses IsOccupied to check availability.
	/// </summary>
	public GameObject FindAvailableChair()
	{
		if ( AssignedChair.IsValid() )
		{
			var assignedSeat = AssignedChair.GetComponent<BaseChair>();
			if ( IsChairFree( AssignedChair, assignedSeat ) )
				return AssignedChair;
		}

		var nearby  = Scene.FindInPhysics( new Sphere( HomeArea, MillRadius ) );
		GameObject best     = null;
		float      bestDist = float.MaxValue;

		foreach ( var obj in nearby )
		{
			var seat = obj.GetComponent<BaseChair>();
			if ( !IsChairFree( obj, seat ) ) continue;

			var dist = WorldPosition.Distance( obj.WorldPosition );
			if ( dist < bestDist )
			{
				bestDist = dist;
				best     = obj;
			}
		}

		return best;
	}

	/// <summary>
	/// Find the nearest food item (tagged <see cref="FoodTag"/>) within the mill area. Tag-gated
	/// so customers only ever pick up actual food, never karts or scenery. Skips anything a
	/// player is currently holding/carrying so we don't yank food out of someone's hands.
	/// </summary>
	public GameObject FindNearbyFood()
	{
		var nearby = Scene.FindInPhysics( new Sphere( HomeArea, MillRadius ) );

		GameObject best     = null;
		float      bestDist = float.MaxValue;

		foreach ( var obj in nearby )
		{
			if ( !obj.IsValid() || !obj.Tags.Has( FoodTag ) ) continue;

			// Don't grab food that's already in someone's hands (parented under a player).
			if ( obj.GetComponentInParent<Player>( true ).IsValid() ) continue;

			var dist = WorldPosition.Distance( obj.WorldPosition );
			if ( dist < bestDist )
			{
				bestDist = dist;
				best     = obj;
			}
		}

		return best;
	}

	/// <summary>
	/// A chair is available to this diner if it isn't player-occupied (BaseChair.IsOccupied)
	/// and isn't already reserved by a *different* diner. Our own claimed chair always reads
	/// as free to us, so we can re-select it after standing up.
	/// </summary>
	private bool IsChairFree( GameObject chair, BaseChair seat )
	{
		if ( !seat.IsValid() || seat.IsOccupied ) return false;
		if ( chair.Tags.Has( "diner_taken" ) && chair != _claimedChair ) return false;
		return true;
	}

	/// <summary>
	/// Reserve a chair so other diners route to a different (further) seat. NPC AI only runs
	/// on the host (proxies bail early), so this scene-tag bookkeeping is race-free.
	/// </summary>
	private void ClaimChair( GameObject chair )
	{
		if ( _claimedChair == chair ) return;
		ReleaseChair();
		if ( !chair.IsValid() ) return;
		_claimedChair = chair;
		_claimedChair.Tags.Add( "diner_taken" );
	}

	/// <summary>Release any reserved chair so another diner can take it.</summary>
	private void ReleaseChair()
	{
		if ( _claimedChair.IsValid() )
			_claimedChair.Tags.Remove( "diner_taken" );
		_claimedChair = null;
	}

	// ── IDamageable ───────────────────────────────────────────────────────────

	void IDamageable.OnDamage( in DamageInfo damage )
	{
		if ( IsProxy ) return;

		Health -= damage.Damage;
		Scare( damage.Attacker, damage.Damage / 40f );
		EndCurrentSchedule();

		// A diner has been harmed — report it so the police dispatcher can raise an APB.
		var attacker = damage.Attacker;
		Scene.RunEvent<ICrimeEvents>( x => x.OnCrimeReported( attacker, GameObject ) );

		if ( Health < 1 )
		{
			ReleaseChair();
			Die( damage );
		}
	}

	protected override void OnDestroy()
	{
		// Don't leave a chair reserved forever if we're cleaned up while seated.
		ReleaseChair();
	}
}