AI/Agents/AgentController.cs
using HC3.Terrain;
using Sandbox.Diagnostics;
using System;

namespace HC3;

public abstract class AgentController : Component
{
	[RequireComponent] public Agent Agent { get; set; }

	[ConVar( "hc3.debug.ai" )] public static bool UseDebugDraw { get; set; }

	[Property] public float WalkSpeed { get; set; } = 40.0f;

	[Property] public float RunSpeed { get; set; } = 80.0f;

	[Sync( SyncFlags.FromHost ), Property, ReadOnly] public bool IsGrounded { get; set; } = true;

	[Sync( SyncFlags.FromHost )] private bool _isRunning { get; set; }
	public bool IsOnStairs { get; set; } = false;

	/// <summary>
	/// Are we running?
	/// </summary>
	public new virtual bool IsRunning
	{
		get
		{
			return _isRunning;
		}
		set
		{
			_isRunning = value;
		}
	}

	public bool IsNavigating => NetworkedPath.Count > 0;

	public float MoveSpeed
	{
		get
		{
			var spd = IsRunning ? RunSpeed : WalkSpeed;
			spd += speedOffset;
			return spd;
		}
	}

	/// <summary>
	/// How fast should this agent steer?
	/// </summary>
	[Property] public float SteerSpeed { get; set; } = 3f;

	[Sync( SyncFlags.FromHost )] NetList<Vector3> NetworkedPath { get; set; } = new();

	NavFlags _currentFlags;
	Vector3? _currentTarget;
	float _pathTotalLength = 0f;
	float speedOffset;
	Vector3 _gravity => Scene.PhysicsWorld.Gravity;

	// Cache the current navigation path so we're not reconstructing every frame 
	List<Vector3> _currentPath;
	int _currentPathIndex = 0;

	/// <summary>
	/// The NavigationVersion when this route was computed. Used for cheap invalidation detection.
	/// </summary>
	uint _routeNavVersion;

	/// <summary>
	/// Cached remaining path distance, updated incrementally as waypoints are consumed.
	/// </summary>
	float _cachedRemainingDistance = 0f;

	/// <summary>
	/// Last position where CheckPlatform ran its ray trace fallback.
	/// </summary>
	Vector3 _lastPlatformCheckPos;

	protected override void OnEnabled()
	{
		speedOffset = Game.Random.Int( -10, 10 );
	}

	public IEnumerable<Vector3> GetNetworkedPath() => NetworkedPath;
	public List<Vector3> GetCurrentPath() => _currentPath;

	public void BeginRide() => Enabled = false;
	public void EndRide() => Enabled = true;

	/// <summary>
	/// Navigate to a point in the world.
	/// </summary>
	public bool Navigate( Vector3 target, NavFlags flags = NavFlags.Default )
	{
		using var _ = Performance.Scope( "Guest::Navigation" );

		if ( WorldPosition.Distance( target ) <= (GridManager.GridSize / 2.0f) )
		{
			ClearNavigation();
			return true; // arrived
		}

		if ( _currentPath != null && _currentPath.Count > 0 )
		{
			var targetGrid = GridManager.WorldToGridPosition( target );
			var lastTargetGrid = GridManager.WorldToGridPosition( _currentTarget.Value );

			// don't repath if our destination is the same
			if ( targetGrid == lastTargetGrid )
			{
				return false;
			}
		}

		_currentTarget = target;
		_currentFlags = flags;

		var path = GridNavigation.Instance.FindWorldPath( WorldPosition, target, flags );

		if ( path is not null && path.Count > 0 )
		{
			_currentPath = path;
			_currentPathIndex = 0;
			_routeNavVersion = GridManager.Instance.NavigationVersion;

			ComputePathLength();
			_cachedRemainingDistance = _pathTotalLength;
			UpdateNetworkedPath();
		}
		else if ( path is null )
		{
			Log.Warning( $"{this.GameObject} couldn't path ({Agent.ActionController?.CurrentAction})" );
		}

		return false;
	}

	private GridCell _currentCell;

	public void UpdateAgentCell()
	{
		var gridPos = GridManager.WorldToGridPosition( WorldPosition );
		var newCell = GridManager.Instance.GetCell( gridPos );

		if ( newCell == null || newCell == _currentCell ) return;

		if ( _currentCell != null )
			AgentCellCache.Unregister( this, _currentCell );

		AgentCellCache.Register( this, newCell );
		_currentCell = newCell;
	}

	private void ComputePathLength()
	{
		_pathTotalLength = 0f;

		if ( _currentPath == null || _currentPath.Count < 2 )
			return;

		for ( int i = 1; i < _currentPath.Count; i++ )
			_pathTotalLength += _currentPath[i - 1].Distance( _currentPath[i] );
	}

	/// <summary>
	/// Navigate among a pre-defined path
	/// </summary>
	/// <param name="path"></param>
	public void Navigate( List<Vector3> path )
	{
		if ( _currentPath != null && _currentPath.Count > 0 )
		{
			var finalTarget = _currentPath[^1];
			if ( finalTarget.Distance( path.Last() ) <= (GridManager.GridSize / 2.0f) )
				return;
		}

		_currentFlags = NavFlags.IncludeUnwalkable;
		_currentPath = path;
		_currentTarget = path.Last();
		_currentPathIndex = 0;
		_routeNavVersion = GridManager.Instance.NavigationVersion;
		ComputePathLength();
		_cachedRemainingDistance = _pathTotalLength;
		UpdateNetworkedPath();
	}

	public Vector3Int TilePosition
	{
		get
		{
			var pos2d = GridManager.WorldToGridPosition( WorldPosition );
			int level = (int)MathF.Floor( (WorldPosition.z + GridNavigation.Z_BIAS) / GridManager.HeightStep );

			return new Vector3Int( pos2d.x, pos2d.y, level );
		}
	}

	public bool IsRouteValid()
	{
		if ( _currentFlags.HasFlag( NavFlags.IncludeUnwalkable ) ) return true;

		// Fast path: if the navigation graph hasn't changed since we computed this route, it's still valid
		if ( _routeNavVersion == GridManager.Instance.NavigationVersion )
			return true;

		// Navigation graph changed — do a full revalidation
		var currentGrid = GridManager.WorldToGridPosition3D( _currentPath[_currentPathIndex] );
		var destPos = GridManager.WorldToGridPosition3D( _currentPath[^1] );

		// Quick region check first
		if ( !GridManager.IsWalkable( currentGrid, destPos ) )
		{
			Log.Info( $"InvalidRoute: WorldPosition {currentGrid} not walkable to destination {destPos} ({Agent.FullName} doing {Agent.ActionController.CurrentAction})" );
			return false;
		}

		// Spot-check a few upcoming waypoints + destination instead of entire path
		int remaining = _currentPath.Count - _currentPathIndex;
		int step = Math.Max( 1, remaining / 5 );

		for ( int i = _currentPathIndex; i < _currentPath.Count; i += step )
		{
			var worldPos = _currentPath[i];
			var gridPos = GridManager.WorldToGridPosition( worldPos );

			if ( GridManager.Instance.GetCell( gridPos ) is not { } cell )
			{
				Log.Info( $"InvalidRoute: cell missing at {gridPos}" );
				return false;
			}
			if ( !GridNavigation.Instance.CanEnter( gridPos, _currentFlags ) )
			{
				Log.Info( $"InvalidRoute: can't enter cell at {gridPos}" );
				return false;
			}
		}

		// Always check the final waypoint
		if ( remaining > 1 )
		{
			var lastPos = GridManager.WorldToGridPosition( _currentPath[^1] );
			if ( GridManager.Instance.GetCell( lastPos ) is not { } lastCell ||
				 !GridNavigation.Instance.CanEnter( lastPos, _currentFlags ) )
			{
				Log.Info( $"InvalidRoute: destination unreachable at {lastPos}" );
				return false;
			}
		}

		// Route is still valid despite graph change — update our version stamp
		_routeNavVersion = GridManager.Instance.NavigationVersion;
		return true;
	}

	/// <summary>
	/// Should we try to path around decorations (expensive)
	/// </summary>
	[Property]
	public bool AvoidDecorations { get; set; } = false;

	/// <summary>
	/// Called every update to move our guests.
	/// </summary>
	void TryMoveToTarget()
	{
		if ( _currentPath is null || _currentPathIndex >= _currentPath.Count )
		{
			ClearNavigation();
			return;
		}

		if ( !IsRouteValid() )
		{
			ClearNavigation();
			return;
		}

		if ( !IsGrounded )
		{
			// if we're not grounded, we can't move
			return;
		}

		var currentTarget = _currentPath[_currentPathIndex];

		var toTarget = currentTarget - WorldPosition;
		var distanceToTarget = toTarget.Length;

		var desiredDirection = toTarget.Normal.WithZ( 0 );
		var forward = WorldRotation.Forward.WithZ( 0 ).Normal;
		float moveStep = MoveSpeed * Time.Delta;

		// Snap to point if close
		if ( distanceToTarget <= moveStep )
		{
			// Update cached remaining distance incrementally
			_cachedRemainingDistance -= distanceToTarget;

			_currentPathIndex++;
			if ( _currentPathIndex >= _currentPath.Count )
				_currentPath = null;

			WorldPosition = currentTarget;
			return;
		}

		float alignment = forward.Dot( desiredDirection ).Clamp( -1.0f, 1.0f );
		float speedFactor = MathF.Max( 0.2f, alignment ); // prevent full stall
		if ( alignment < -0.99f )
		{
			desiredDirection = Vector3.Cross( Vector3.Up, forward ).Normal;
		}

		WorldRotation = Rotation.Lerp( WorldRotation, Rotation.LookAt( desiredDirection ), Time.Delta * SteerSpeed );

		// clamp XY move to not overshoot
		var move = desiredDirection * MathF.Min( moveStep * speedFactor, distanceToTarget );

		// move on Z if needed
		float zMove = toTarget.Normal.z * moveStep;

		WorldPosition += move.WithZ( zMove );

		if ( IsOnStairs )
		{
			// Local avoidance
			var avoidanceOffset = Vector3.Zero;

			var localAgents = AgentCellCache.GetNeighbouring( _currentCell );
			if ( localAgents is not null )
			{
				foreach ( var other in localAgents )
				{
					if ( other == this ) continue;
					if ( other.IsOnStairs ) continue;

					var toOther = other.WorldPosition - WorldPosition;
					var dist = toOther.Length;

					if ( dist < GridManager.GridSize * 0.25f )
					{
						avoidanceOffset -= toOther.Normal * (GridManager.GridSize - dist) * 0.2f;
					}
				}
			}

			if ( AvoidDecorations )
			{
				const float avoidanceRadius = 64f;

				foreach ( var hit in Scene.FindInPhysics( new Sphere( WorldPosition, avoidanceRadius ) ) )
				{
					var deco = hit;
					if ( deco == null || !deco.Tags.Has( "decoration" ) )
						continue;

					var toDeco = deco.WorldPosition - WorldPosition;
					float dist = toDeco.Length;

					if ( dist < GridManager.GridSize * 0.75f )
					{
						avoidanceOffset -= toDeco.Normal * (GridManager.GridSize - dist) * 0.25f;
					}
				}
			}

			// prevent avoidance pushing people off cell (should we do this only for raised platforms?)
			Vector3 attemptedPos = WorldPosition + (avoidanceOffset.WithZ( 0 ) * Time.Delta);
			Vector3Int gridPos = GridManager.WorldToGridPosition3D( WorldPosition );

			Vector3 cellWorldMin = GridManager.GridToWorldPosition( gridPos );
			Vector3 cellWorldMax = GridManager.GridToWorldPosition( gridPos + 1 );

			WorldPosition = attemptedPos.Clamp( cellWorldMin, cellWorldMax );
		}
	}

	public float GetRemainingPathDistance()
	{
		if ( _currentPath == null || _currentPathIndex >= _currentPath.Count )
			return 0f;

		// Cached distance covers waypoint[currentIndex] → end.
		// Add distance from agent position to current waypoint.
		return WorldPosition.Distance( _currentPath[_currentPathIndex] ) + _cachedRemainingDistance;
	}

	public float GetPathProgress()
	{
		if ( _pathTotalLength <= 0f )
			return 0f;

		float remaining = _cachedRemainingDistance;
		return 1f - (remaining / _pathTotalLength).Clamp( 0f, 1f );
	}

	void CheckPlatform()
	{
		var gridPos = GridManager.WorldToGridPosition3D( WorldPosition );

		var cell = GridManager.Instance.GetCell( new Vector2Int( gridPos ) );
		if ( cell is not null && cell.GetComponents<Path>( gridPos.z, 1 ).FirstOrDefault() is Path path )
		{
			if ( WorldPosition.z > path.WorldPosition.z || Math.Abs( path.WorldPosition.z - WorldPosition.z ) < GridNavigation.Z_BIAS )
			{
				IsGrounded = true;

				if ( path.IsStairs )
				{
					bool ascending = Vector2.Dot( WorldRotation.Forward, path.StairDirection ) > 0;
					if ( !ascending )
					{
						WorldPosition = WorldPosition.WithZ( Math.Min( WorldPosition.z, path.GetHeightAt( WorldPosition ) ) );
					}
				}
				IsOnStairs = path.IsStairs;

				return;
			}
		}

		// AreaTile (enclosure floor) also counts as ground
		if ( cell is not null && cell.GetComponents<AreaTile>( gridPos.z, 1 ).Any() )
		{
			IsGrounded = true;
			IsOnStairs = false;
			return;
		}

		// Ray trace fallback — skip if we haven't moved since last check
		if ( WorldPosition.AlmostEqual( _lastPlatformCheckPos, 1f ) )
			return;

		_lastPlatformCheckPos = WorldPosition;

		var tr = Scene.Trace.Ray( WorldPosition + Vector3.Up * 4f, WorldPosition + Vector3.Down * 8f )
			.WithTag( "ground" )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		IsGrounded = tr.Hit;
	}

	void SnapOnZ()
	{
		// we only really want to snap if we're NOT on stairs
		// otherwise we might get pushed to the level above a stair - which is likely a void
		if ( IsOnStairs )
			return;

		int biasedLevel = (int)((WorldPosition.z + GridNavigation.Z_BIAS) / GridManager.HeightStep);
		WorldPosition = WorldPosition.WithZ( biasedLevel * GridManager.HeightStep );
	}

	Vector3Int previousTile;
	public void Tick()
	{
		DebugDraw();

		// Don't do any actions if we're dead as hell
		if ( Agent == null || !Agent.IsAlive )
			return;

		// If we're in a building, don't bother moving
		if ( Agent.Building.IsValid() )
		{
			return;
		}

		TryMoveToTarget();

		var currentTile = TilePosition;
		if ( currentTile.x != previousTile.x || currentTile.y != previousTile.y )
		{
			if ( IsGrounded )
			{
				// entered a new tile, make sure we've not just clipped under
				SnapOnZ();
			}
		}
		previousTile = TilePosition;

		CheckPlatform();

		if ( !IsGrounded )
		{
			var nextPos = WorldPosition;

			// falling!
			nextPos += (_gravity / 5.0f /* this looks fun ok */) * Time.Delta;

			var terrain = GridManager.Instance.Terrain;
			if ( terrain.IsValid() )
			{
				// don't fall thru terrain
				var gridPos = GridManager.WorldToGridPosition( nextPos ).Clamp( terrain.Bounds.Shrink( 0, 0, 1, 1 ) );
				nextPos.z = Math.Max( nextPos.z, terrain.GetHeight( gridPos ) * GridManager.HeightStep );
			}

			WorldPosition = nextPos;

			ClearNavigation();
		}

		UpdateAgentCell();
	}

	void DebugDraw()
	{
		if ( !UseDebugDraw ) return;

		using ( Gizmo.Scope( "GuestPath" ) )
		{
			if ( _currentPath != null && _currentPath.Count > 1 )
			{
				var offset = Vector3.Up * 16f;

				for ( int i = 0; i < _currentPath.Count - 1; i++ )
				{
					var from = _currentPath[i] + offset;
					var to = _currentPath[i + 1] + offset;

					var col = _currentPathIndex > i ? Color.Green : Color.Red;

					Gizmo.Draw.Color = col.WithAlpha( 0.5f );
					Gizmo.Draw.LineSphere( new Sphere( from, 4 ) );

					Gizmo.Draw.Color = Color.White.WithAlpha( 0.5f );

					var spacer = ((to - from).Normal * 8f);
					Gizmo.Draw.Line( from + spacer, to - spacer );
				}

				Gizmo.Draw.Color = Color.Red.WithAlpha( 0.5f );
				Gizmo.Draw.LineSphere( new Sphere( _currentPath.Last() + offset, 4 ) );
			}
		}
	}

	public void ClearNavigation()
	{
		_currentPath = null;
		_currentFlags = NavFlags.None;
		_currentTarget = null;
		_currentPathIndex = 0;
		_cachedRemainingDistance = 0f;
		_routeNavVersion = 0;
		UpdateNetworkedPath();

		SnapOnZ();
	}

	void UpdateNetworkedPath()
	{
		NetworkedPath.Clear();

		if ( _currentPath is null || _currentPath.Count <= 0 )
			return;

		foreach ( var v in _currentPath )
		{
			NetworkedPath.Add( v );
		}
	}

	protected override void OnDestroy()
	{
		if ( _currentCell != null )
			AgentCellCache.Unregister( this, _currentCell );
	}
}