Npcs/Combat/PoliceDispatcher.cs
using System.Collections.Generic;
using Sandbox.Npcs.CombatNpc;

/// <summary>
/// Scene-placed police director — the "wanted level" brain.
///
/// Listens for <see cref="ICrimeEvents.OnCrimeReported"/> (fired when a diner is harmed) and,
/// while an APB is active, maintains a population of hostile cop NPCs that hunt the culprit.
///
/// DESIGN (agreed with the team):
///   • Maintain-N      — keeps up to <see cref="MaxActiveCops"/> cops alive, replacing the fallen.
///   • Finish-the-fight — when the APB lapses we STOP spawning but let living cops finish up.
///   • Culprit-by-tag  — the suspect is flagged with the "culprit" tag, so ANY taggable entity
///                       (a player, or the rhino once it trips a diner) can become wanted, and
///                       cops prioritise it while still engaging any player they encounter.
///
/// SPAWNING follows the exact, proven sequence in EntitySpawnerEntity:
///   Clone(StartEnabled:false) → Ownable.Set → NetworkSpawn(true,null) → Enabled = true.
///
/// PREFAB SETUP (cop.prefab)
/// ─────────────────────────
///   A CombatNpc (Friendly = false) with its usual layers + NavMeshAgent + Rigidbody,
///   a PeachLauncherWeapon wired into CombatNpc.Weapon, and InfiniteAmmo left ON so the
///   launcher never dry-fires (NPCs have no ammo inventory to draw from).
///
/// SCENE SETUP
/// ───────────
///   Drop one PoliceDispatcher on an empty GameObject. Assign CopPrefab and a handful of
///   SpawnPoints (empty GameObjects placed off-screen / at a station). Eventually this whole
///   object rides inside the police van and the van IS the mobile spawner.
/// </summary>
[Title( "Police Dispatcher" )]
[Category( "Game / Npcs" )]
public sealed class PoliceDispatcher : Component, ICrimeEvents
{
	// ── Wiring ────────────────────────────────────────────────────────────────

	/// <summary>The cop prefab to spawn. Root must have a <see cref="CombatNpc"/>.</summary>
	[Property] public GameObject CopPrefab { get; set; }

	/// <summary>Candidate spawn locations. The one nearest the suspect is used so cops converge.</summary>
	[Property] public List<GameObject> SpawnPoints { get; set; } = new();

	// ── Tuning ────────────────────────────────────────────────────────────────

	/// <summary>How many cops are kept alive at once while the APB is active.</summary>
	[Property, Range( 1, 12 )] public int MaxActiveCops { get; set; } = 4;

	/// <summary>How long the APB stays up after the most recent crime. Re-armed by new crimes.</summary>
	[Property, Range( 10f, 300f )] public float ApbDuration { get; set; } = 90f;

	/// <summary>Minimum seconds between cop spawns while below the cap.</summary>
	[Property, Range( 0.5f, 15f )] public float SpawnInterval { get; set; } = 4f;

	/// <summary>Snap each spawn position onto the navmesh so the agent can path immediately.</summary>
	[Property] public bool SnapToNavMesh { get; set; } = true;

	// ── Runtime ─────────────────────────────────────────────────────────────────

	private TimeUntil _apbExpires;
	private TimeSince _timeSinceSpawn;
	private GameObject _culprit;
	private readonly List<CombatNpc> _activeCops = new();

	/// <summary>Is there a live APB right now?</summary>
	public bool ApbActive => _apbExpires > 0f;

	// ── Crime reporting ──────────────────────────────────────────────────────

	void ICrimeEvents.OnCrimeReported( GameObject culprit, GameObject victim )
	{
		if ( !Networking.IsHost ) return;

		var resolved = ResolveCulprit( culprit );
		if ( !resolved.IsValid() ) return;

		SetCulprit( resolved );
		_apbExpires = ApbDuration; // raise or refresh the wanted window
	}

	// ── Spawn loop ───────────────────────────────────────────────────────────

	protected override void OnFixedUpdate()
	{
		if ( !Networking.IsHost ) return;

		// Drop any cops that have died — this is what lets us "replace the fallen".
		_activeCops.RemoveAll( c => !c.IsValid() );

		if ( !ApbActive )
		{
			// APB just lapsed → stand down once. Living cops finish their current fight.
			if ( _culprit.IsValid() )
				StandDown();
			return;
		}

		if ( _activeCops.Count >= MaxActiveCops ) return;
		if ( _timeSinceSpawn < SpawnInterval ) return;

		SpawnCop();
		_timeSinceSpawn = 0f;
	}

	// ── Culprit handling ──────────────────────────────────────────────────────

	private void SetCulprit( GameObject culprit )
	{
		if ( _culprit == culprit ) return;

		if ( _culprit.IsValid() )
			_culprit.Tags.Remove( "culprit" );

		_culprit = culprit;
		_culprit.Tags.Add( "culprit" );

		// Re-point every existing cop at the new suspect.
		foreach ( var cop in _activeCops )
		{
			if ( cop.IsValid() )
				cop.PriorityTarget = _culprit;
		}
	}

	private void StandDown()
	{
		if ( _culprit.IsValid() )
			_culprit.Tags.Remove( "culprit" );
		_culprit = null;

		// Clear dispatch intel so cops stop hunting; they'll engage whatever's already
		// in front of them, then fall back to patrol.
		foreach ( var cop in _activeCops )
		{
			if ( cop.IsValid() )
				cop.PriorityTarget = null;
		}
	}

	// ── Spawning ──────────────────────────────────────────────────────────────

	private void SpawnCop()
	{
		if ( !CopPrefab.IsValid() )
		{
			Log.Warning( "PoliceDispatcher: CopPrefab is not assigned." );
			return;
		}

		var spawnTransform = GetSpawnTransform();

		var go = CopPrefab.Clone( new CloneConfig
		{
			Transform = spawnTransform,
			StartEnabled = false,
		} );

		go.Tags.Add( "removable" );
		Ownable.Set( go, GameObject.Network.Owner );
		go.NetworkSpawn( true, null );
		go.Enabled = true;

		var cop = go.GetComponent<CombatNpc>();
		if ( cop.IsValid() )
		{
			cop.Friendly = false;
			cop.PriorityTarget = _culprit;
			_activeCops.Add( cop );
		}
		else
		{
			Log.Warning( "PoliceDispatcher: spawned cop has no CombatNpc component." );
		}
	}

	private Transform GetSpawnTransform()
	{
		// Converge on the suspect: pick the spawn point closest to them (or to us if unknown).
		var origin = _culprit.IsValid() ? _culprit.WorldPosition : WorldPosition;

		GameObject best = null;
		float bestDist = float.MaxValue;
		foreach ( var sp in SpawnPoints )
		{
			if ( !sp.IsValid() ) continue;
			var d = origin.Distance( sp.WorldPosition );
			if ( d < bestDist )
			{
				bestDist = d;
				best = sp;
			}
		}

		var t = best.IsValid() ? best.WorldTransform : WorldTransform;

		// Land the cop on the navmesh, otherwise its NavMeshAgent won't move.
		if ( SnapToNavMesh && Scene.NavMesh is not null
			 && Scene.NavMesh.GetClosestPoint( t.Position ) is { } navPos )
		{
			t = new Transform( navPos, t.Rotation );
		}

		return t;
	}

	// ── Helpers ──────────────────────────────────────────────────────────────

	/// <summary>
	/// Walk up from the raw damage attacker to the most meaningful "responsible" object:
	/// a controlling player if there is one, else an NPC / kill-source, else the object itself.
	///
	/// NOTE: a peach fired by a player currently attributes to the peach projectile (it carries
	/// no player parent once host-spawned), so it falls through to "the object itself". If/when
	/// you want peach-shooters to get wanted, stamp the firer onto PeachProjectile at spawn and
	/// resolve it here.
	/// </summary>
	private static GameObject ResolveCulprit( GameObject attacker )
	{
		if ( !attacker.IsValid() ) return null;

		if ( attacker.GetComponentInParent<Player>( true ) is { } player )
			return player.GameObject;

		if ( attacker.GetComponentInParent<IKillSource>( true ) is Component src )
			return src.GameObject;

		return attacker;
	}
}