CopBotAI.cs
using Sandbox;
using SWB.Base;
using SWB.Demo;
using SWB.Player;
using System;
using System.Collections.Generic;
using System.Linq;

// Sits alongside a DemoBot component on the same GameObject. DemoBot
// (from SWB) already gives us health, damage, death/respawn, and a
// weapon inventory for free — this component only adds the two things
// DemoBot doesn't: pathfinding movement and the decision to shoot.
//
// Key fact this relies on: Weapon.OnUpdate() skips its entire firing
// loop when Owner.IsBot is true, and CanShoot() bypasses the Input.Down
// checks for bots. That's SWB's intended hook for bot AI to drive a
// weapon directly instead of faking player input.
public sealed class CopBotAI : Component
{
	[RequireComponent]
	NavMeshAgent Agent { get; set; }

	DemoBot _bot;

	// Distance at which the cop stops closing in and just fights from
	// where it's standing, instead of continuing to push toward the
	// player. Tune this per-weapon later if needed.
	[Property]
	public float EngageRange { get; set; } = 800f;

	[Property]
	public float RetargetInterval { get; set; } = 0.3f;

	// What cops are allowed to carry, as WeaponRegistry ClassNames. One is
	// picked at random per spawn.
	//
	// SWB's demo player hands out ALL SIX weapons on respawn and DemoBot
	// then picks one at random, so without this a cop might be holding
	// anything.
	//
	// Pick by damage-per-second, not damage per shot - and note bots
	// ignore semi-auto restrictions (Weapon.Shoot.cs bypasses the
	// Input.Pressed check for them), so every weapon fires at its full
	// RPM in a cop's hands. That makes the Colt (25 dmg @ 700 RPM) the
	// single deadliest gun here, well above the ScarH. Veresk is the
	// gentlest of the automatics; Revolver is slow enough to be fair.
	[Property]
	public List<string> WeaponPool { get; set; } = new() { "swb_veresk" };

	// Multiplies the weapon's own damage for cops only. Applied to this
	// cop's private clone of the weapon, so the same gun in the player's
	// hands is untouched.
	[Property]
	public float DamageScale { get; set; } = 0.6f;

	// Multiplies the weapon's own spread for cops only - higher is less
	// accurate. Cops otherwise aim dead at your eye position every tick,
	// which is far more precise than any human.
	[Property]
	public float SpreadScale { get; set; } = 2.5f;

	// Shots fired before a cop pauses. Together with BurstPause this is
	// the real damage-per-second dial - far more effective than DamageScale,
	// because it controls how much lead is in the air rather than how much
	// each round hurts.
	[Property]
	public int BurstShots { get; set; } = 3;

	[Property]
	public float BurstPause { get; set; } = 0.9f;

	// Keep this in step with MissionSpawner.MinSpawnDistanceFromPlayers -
	// they're the two halves of the same rule, one for new cops and one
	// for cops coming back after being killed.
	[Property]
	public float MinSpawnDistanceFromPlayers { get; set; } = 2000f;

	int _shotsThisBurst = 0;
	TimeSince _timeSinceBurstEnded = 0;

	// How long the cop must have had unbroken line of sight before it's
	// allowed to fire - without this it snaps a shot off the instant LOS
	// goes true, which reads as robotic/unfair rather than alert.
	[Property]
	public float ReactionTime { get; set; } = 0.2f;

	TimeSince _timeSinceRetarget = 0;
	GameObject _target;

	bool _hadLineOfSight = false;
	TimeSince _timeSinceLineOfSightAcquired;

	// Whether we currently have a path request in flight toward _target -
	// see the comment above the MoveTo() call for why this matters.
	bool _isPathing = false;

	// How far from the target to go looking for a navigable point when the
	// target itself isn't standing on the navmesh. Roughly "how far below
	// or beside you a cop will settle for". Too large and they'll path to
	// somewhere unhelpfully far away; too small and they give up.
	[Property]
	public float NavSearchRadius { get; set; } = 512f;

	// Pathing diagnostics. Solved the "cops far away don't move" bug (the
	// destination wasn't on the navmesh - see NavigableDestination), so
	// it's off by default now. Tick it on if cop movement ever looks wrong
	// again; it logs what the agent is actually doing rather than leaving
	// you to guess.
	[Property]
	public bool DebugPathing { get; set; } = false;

	[Property]
	public float DebugLogInterval { get; set; } = 1f;

	TimeSince _timeSinceDebugLog = 0;
	Vector3 _debugLastPosition;
	bool _debugHasLastPosition = false;

	// Tracks IsAlive across ticks so we can catch the dead-to-alive
	// transition - see RelocateToPoliceSpawn().
	bool _wasAlive = false;

	protected override void OnAwake()
	{
		_bot = Components.Get<DemoBot>();

		if ( _bot is null )
		{
			Log.Warning( $"{GameObject.Name}: CopBotAI requires a DemoBot component on the same GameObject." );
			return;
		}

		// DemoBot/PlayerBase already drives this GameObject's actual
		// position via its own CharacterController-based movement (it
		// applies gravity and calls CharacterController.Move() every
		// fixed update regardless of IsBot). NavMeshAgent's default
		// behaviour is to ALSO directly overwrite the GameObject's
		// position every frame — two systems fighting over the same
		// Transform, which is exactly what sent bots flying out of the
		// map. Turning these off makes the agent a pure pathfinder: we
		// read its desired direction and feed it into the bot's own
		// WishVelocity instead, so PlayerBase's proven movement code
		// (gravity, ground snapping, collision) does the actual moving.
		Agent.UpdatePosition = false;
		Agent.UpdateRotation = false;
	}

	protected override void OnFixedUpdate()
	{
		if ( _bot is null )
			return;

		if ( _bot.IsAlive && !_wasAlive )
		{
			RelocateToPoliceSpawn();
			ApplyCopLoadout();
		}

		_wasAlive = _bot.IsAlive;

		if ( !_bot.IsAlive )
			return;

		// Keep the agent's internal pathing state in sync with wherever
		// the CharacterController actually put us this tick.
		Agent.SetAgentPosition( WorldPosition );

		// Runs every tick regardless of target/LOS state, so a cop that
		// loses sight of its target mid-reload still finishes reloading
		// instead of getting stuck (see MaintainWeapon for why this has
		// to be driven manually at all).
		MaintainWeapon();

		var justRetargeted = false;
		if ( _timeSinceRetarget >= RetargetInterval )
		{
			_timeSinceRetarget = 0;
			_target = FindNearestPlayer();
			justRetargeted = true;
		}

		if ( _target is null )
		{
			_bot.WishVelocity = Vector3.Zero;
			Agent.Stop();
			_isPathing = false;
			return;
		}

		var toTarget = _target.WorldPosition - WorldPosition;
		var distance = toTarget.Length;
		var hasLineOfSight = HasLineOfSight( _target );

		if ( hasLineOfSight && !_hadLineOfSight )
			_timeSinceLineOfSightAcquired = 0;
		_hadLineOfSight = hasLineOfSight;

		// Aim wherever we can see the target — this is what actually
		// steers the weapon's fire direction (Shoot() reads EyeAngles).
		// Aiming snaps on immediately (looks alert); only firing itself
		// waits out ReactionTime below.
		//
		// Aim from our eyes at THEIR eyes, matching the line
		// HasLineOfSight just verified. Using the root positions instead
		// would aim feet-to-feet while firing from head height, which
		// skews the shot - badly so when there's any height difference
		// between the two.
		if ( hasLineOfSight )
		{
			var aimAt = TargetEyePos( _target );
			_bot.EyeAngles = Rotation.LookAt( aimAt - _bot.EyePos ).Angles();
		}

		// Close the distance until in engage range, or chase if we've
		// lost sight of them. Otherwise hold position and fight.
		// Agent.WishVelocity is the agent's own speed-scaled desired
		// velocity along its calculated path — same field a human
		// player's input would populate, just sourced from pathing
		// instead of Input.AnalogMove.
		var chasing = distance > EngageRange || !hasLineOfSight;
		var usedFallback = false;

		if ( chasing )
		{
			// Only request a new path when we don't have one yet or on the
			// same throttled cadence as retargeting, rather than every
			// fixed update - then keep following the path already in
			// progress the rest of the time.
			if ( !_isPathing || justRetargeted )
			{
				Agent.MoveTo( NavigableDestination( _target.WorldPosition ) );
				_isPathing = true;
			}

			var pathVelocity = Agent.WishVelocity;

			// Last resort when there's no usable path at all: walk straight
			// at the target so a cop never just stands there. Normal
			// collision still applies, so this gets stuck on walls - it's a
			// floor, not a solution.
			usedFallback = pathVelocity.IsNearZeroLength;

			_bot.WishVelocity = usedFallback
				? toTarget.WithZ( 0 ).Normal * _bot.RunSpeed
				: pathVelocity;
		}
		else
		{
			Agent.Stop();
			_bot.WishVelocity = Vector3.Zero;
			_isPathing = false;
		}

		// Firing is independent of movement, so this naturally covers
		// Moving-only (out of range/no LOS, handled above by not
		// calling TryShoot), Shooting-only (stopped + LOS), and
		// MovingAndShooting (still closing distance + already has LOS).
		if ( hasLineOfSight && _timeSinceLineOfSightAcquired >= ReactionTime )
			TryShoot();

		if ( DebugPathing )
			LogPathingState( distance, chasing, hasLineOfSight, usedFallback );
	}

	// Dumps everything the agent is reporting, once per DebugLogInterval.
	// The two numbers that matter most:
	//
	//  moved=   how far this cop ACTUALLY travelled in world space since
	//           the last log. Ground truth - if this is ~0 while chasing,
	//           it's genuinely stuck regardless of what anything else says.
	//
	//  agentOff= distance between where the cop really is and where the
	//           NavMeshAgent thinks it is. Should be ~0 because we call
	//           SetAgentPosition every tick. If it's large, the agent is
	//           pathing from the wrong place entirely - and a spawn point
	//           sitting slightly off the navmesh would do exactly that
	//           while still looking fine in the editor's path checker.
	void LogPathingState( float distance, bool chasing, bool hasLineOfSight, bool usedFallback )
	{
		if ( _timeSinceDebugLog < DebugLogInterval )
			return;

		var moved = _debugHasLastPosition ? WorldPosition.Distance( _debugLastPosition ) : 0f;
		_debugLastPosition = WorldPosition;
		_debugHasLastPosition = true;
		_timeSinceDebugLog = 0;

		var agentOffset = Agent.AgentPosition.Distance( WorldPosition );
		var target = Agent.TargetPosition.HasValue ? Agent.TargetPosition.Value.ToString() : "none";

		Log.Info(
			$"[cop] {GameObject.Name} " +
			$"dist={distance:0} " +
			$"chasing={chasing} " +
			$"los={hasLineOfSight} " +
			$"moved={moved:0.0} " +
			$"agentOff={agentOffset:0.0} " +
			$"navigating={Agent.IsNavigating} " +
			$"agentWish={Agent.WishVelocity.Length:0.0} " +
			$"agentVel={Agent.Velocity.Length:0.0} " +
			$"botWish={_bot.WishVelocity.Length:0.0} " +
			$"fallback={usedFallback} " +
			$"syncPos={Agent.SyncAgentPosition} " +
			$"tgt={target}" );
	}

	// PlayerBase.Respawn() sends a cop to a random generic SpawnPoint
	// whenever it's called with no explicit transform - which is every
	// automatic call PlayerBase makes itself: OnStart()'s initial spawn
	// and OnDeath()'s 2-second delayed respawn. Trying to preempt each of
	// those calls individually is fragile (each is a different async path
	// with its own timing - that's what caused the original spawn-race
	// bug). Reacting to the result instead is robust: any time this cop
	// flips from dead to alive, Respawn() has already fully finished, so
	// correcting the position here can never lose a race, and it covers
	// every respawn path at once instead of needing a patch per call site.
	void RelocateToPoliceSpawn()
	{
		// Same no-spawn perimeter the wave spawner uses. This path matters
		// just as much: cops die constantly during a job, so without it
		// the same cop would keep popping back into existence next to you
		// however careful the initial spawn was.
		var point = PoliceSpawns.Pick( Scene, MinSpawnDistanceFromPlayers );

		if ( point is null )
		{
			Log.Warning( $"{GameObject.Name}: no GameObjects tagged 'police_spawn' found in the scene." );
			return;
		}

		WorldPosition = point.WorldPosition;
		WorldRotation = point.WorldRotation;
		Agent.SetAgentPosition( WorldPosition );
	}

	// Replaces whatever DemoBot handed this cop with a single weapon from
	// WeaponPool, then tones it down.
	//
	// Runs on every spawn, which matters: Inventory.Clear() destroys the
	// old weapon and AddClone makes a fresh one from the registry's master
	// copy, so the scales below always apply to unmodified base values
	// rather than compounding on an already-scaled weapon.
	void ApplyCopLoadout()
	{
		_shotsThisBurst = 0;

		if ( _bot.Inventory is null || WeaponPool.Count == 0 )
			return;

		// DemoBot already handed this cop every weapon and picked one at
		// random. Rather than tearing the inventory down and rebuilding it
		// (which churns networked weapon objects mid-spawn), just choose
		// which of the ones it already has becomes active. Items are named
		// by ClassName, which is what SetActive matches on.
		var className = WeaponPool[Random.Shared.Next( WeaponPool.Count )];
		_bot.Inventory.SetActive( className );

		var weapon = GetActiveWeapon();

		if ( weapon?.Primary is null )
			return;

		if ( weapon.ClassName != className )
		{
			Log.Warning( $"{GameObject.Name}: '{className}' isn't in this bot's inventory - check it's spelled like a WeaponRegistry ClassName." );
			return;
		}

		// Safe to scale in place every spawn: PlayerBase.Respawn() clears
		// the inventory and the demo player re-clones fresh weapons from
		// the registry each time, so these always start from base values
		// rather than compounding on an already-scaled weapon. It's also
		// this cop's own clone - the player's copy of the same gun is a
		// separate instance and keeps its real numbers.
		// Job difficulty stacks on top of the per-cop tuning, so the
		// prefab keeps showing the baseline rather than whatever the last
		// job scaled it to.
		weapon.Primary.Damage *= DamageScale * ActiveMission.CopDamageScale;
		weapon.Primary.Spread *= SpreadScale;
	}

	// Weapon.OnUpdate() only ever calls Reload() and only ever completes
	// one (OnReloadFinish/OnShellReloadFinish) inside its human-input
	// branch, which is skipped entirely when Owner.IsBot is true. So a bot
	// has to drive both halves itself: start the reload when empty, and
	// finish it once the timer's elapsed - otherwise IsReloading gets
	// stuck true forever and CanShoot() fails permanently even after
	// Reload() "worked".
	void MaintainWeapon()
	{
		var weapon = GetActiveWeapon();
		if ( weapon is null )
			return;

		if ( weapon.IsReloading )
		{
			if ( weapon.TimeSinceReload >= 0 )
			{
				if ( weapon.ShellReloading )
					weapon.OnShellReloadFinish();
				else
					weapon.OnReloadFinish();
			}

			return;
		}

		if ( !weapon.HasAmmo() )
			weapon.Reload();
	}

	void TryShoot()
	{
		var weapon = GetActiveWeapon();

		if ( weapon is null || weapon.IsReloading )
		{
			_shotsThisBurst = 0;
			return;
		}

		// Trigger discipline. Without this a cop holds the trigger down
		// forever, which reads as a laser beam rather than someone
		// shooting at you. Also gives the player a window to move.
		if ( _shotsThisBurst >= BurstShots )
		{
			if ( _timeSinceBurstEnded < BurstPause )
				return;

			_shotsThisBurst = 0;
		}

		if ( !weapon.CanPrimaryShoot() )
			return;

		// THE rate limiter. CanShoot() gates firing on
		// TimeSincePrimaryShoot having exceeded the weapon's RPM interval,
		// and Weapon.OnUpdate resets this to 0 on every shot it fires -
		// but OnUpdate skips that whole branch for bots, so driving
		// Shoot() directly means resetting it here too.
		//
		// Leaving it unreset (as this did originally) means the timer
		// stays huge, CanShoot always passes, and the cop fires once per
		// tick - about 60 shots a second whatever the weapon's RPM says.
		// A Colt at 25 damage becomes ~1500 DPS and empties its magazine
		// into you in a tenth of a second.
		weapon.TimeSincePrimaryShoot = 0;
		weapon.Shoot( weapon.Primary, true );

		_shotsThisBurst++;

		if ( _shotsThisBurst >= BurstShots )
			_timeSinceBurstEnded = 0;
	}

	Weapon GetActiveWeapon()
	{
		var weaponGO = _bot.Inventory?.Active;
		return weaponGO?.GetComponent<Weapon>();
	}

	// NavMeshAgent.MoveTo() fails outright if the destination isn't ON the
	// navmesh - it yields no velocity at all, which is what used to freeze
	// every cop whenever the player stood somewhere the mesh doesn't cover
	// (a roof, a ledge, an upper floor). Worse, the engine burns a long
	// exhaustive search failing to find that path.
	//
	// Scene.NavMesh.GetClosestPoint snaps a world position to the nearest
	// navigable point within a radius, so cops path to the reachable spot
	// closest to you - the floor below the ledge you're on - instead of
	// giving up. Null means there's nothing navigable nearby at all (or
	// the navmesh hasn't generated yet), in which case we hand back the
	// raw position and let the straight-line fallback deal with it.
	Vector3 NavigableDestination( Vector3 worldPos )
	{
		return Scene.NavMesh?.GetClosestPoint( worldPos, NavSearchRadius ) ?? worldPos;
	}

	// target.WorldPosition is the root transform, which sits at the
	// player's feet, not eye/chest height. Both line-of-sight and aiming
	// want the eye position instead - it's the height a player judges
	// their own exposure by, and it keeps the two consistent so a cop
	// never fires along a line it didn't actually check.
	Vector3 TargetEyePos( GameObject target )
	{
		return target.Components.Get<PlayerBase>()?.EyePos ?? target.WorldPosition;
	}

	bool HasLineOfSight( GameObject target )
	{
		var targetPos = TargetEyePos( target );

		var tr = Scene.Trace.Ray( _bot.EyePos, targetPos )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		// No hit at all means a clear line; otherwise the trace needs
		// to have stopped ON the target rather than something in front
		// of it (a wall, another cop, etc).
		return !tr.Hit || tr.GameObject?.Root == target.Root;
	}

	// Nearest player still in the run. Anyone eliminated or escaped is
	// invisible and intangible, so shooting at them would just look like
	// cops firing at nothing - Crew handles that filtering.
	//
	// Written as a plain loop rather than OrderBy().First(): every cop on
	// the map runs this several times a second, and sorting the whole list
	// to take one element allocates an array and a comparer each time for
	// no benefit. Comparing squared distances also skips a square root per
	// player, which is safe because squaring preserves ordering.
	GameObject FindNearestPlayer()
	{
		GameObject nearest = null;
		var nearestSqr = float.MaxValue;

		foreach ( var player in Crew.ActivePlayers( Scene ) )
		{
			var distanceSqr = player.WorldPosition.DistanceSquared( WorldPosition );

			if ( distanceSqr >= nearestSqr )
				continue;

			nearestSqr = distanceSqr;
			nearest = player;
		}

		return nearest;
	}
}