OMRBotManager.cs
using Sandbox;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

/// <summary>
/// Host-only lifecycle manager for OMR player bots.
///
/// BOT 0/0.5 establishes a host-owned player bot. BOT 1 manages the runtime
/// navigation mesh for the modular arena. BOT 2 reuses that same mesh while
/// perception decides whether the bot may navigate to a visible or remembered
/// target position. BOT 3 layers real weapon combat on top. BOT 4 adds
/// contextual sprint/strafe/slide/crouch/jump decisions through synthetic input.
/// BOT 5 adds tactical cover, under-fire reactions, hearing/search, flanking and
/// anti-loop traversal safeguards. BOT 6 adds modifier-aware decisions, bounded
/// tactical variation and an online opponent model while keeping physical movement
/// inside the normal OMR locomotion pipeline.
/// </summary>
public sealed class OMRBotManager : Component
{
	[Property]
	public RoundManager RoundManager { get; set; }

	[Property, ResourceType( "prefab" )]
	public string PlayerPrefabPath { get; set; } =
		"prefabs/player.prefab";

	[Property, Group( "Diagnostics" )]
	public bool LogLifecycle { get; set; } = true;

	private readonly List<OMRBotController> _managedBots = new();
	private readonly List<PlayerState> _managedHumans = new();
	private float _incrementalNavigationQueuedAt;
	private float _navigationRetryAfter;
	private int _navigationFailureCount;

	// RequestTilesGeneration queues work for future frames. IsGenerating alone
	// can briefly be false before that queued work begins.
	public bool NavigationReady => _navigationHasInitialFullBuild &&
		!_navigationRebuildPending && !_incrementalNavigationRefreshPending &&
		_navigationGenerationTask is null && Scene?.NavMesh is not null &&
		!Scene.NavMesh.IsGenerating &&
		( RoundManager?.Arena?.CurrentLayoutState ?? ArenaLayoutState.Invalid ) == _lastNavigationLayoutState;

	private float _nextSyncAt;
	private ArenaLayoutState _lastNavigationLayoutState =
		ArenaLayoutState.Invalid;
	private bool _navigationMeshConfigured;
	private bool _navigationRebuildPending;
	private float _navigationRebuildAt;
	private ArenaLayoutState _pendingNavigationLayoutState =
		ArenaLayoutState.Invalid;
	private Task<bool> _navigationGenerationTask;
	private ArenaLayoutState _generatingNavigationLayoutState =
		ArenaLayoutState.Invalid;
	private bool _navigationHasInitialFullBuild;
	private bool _incrementalNavigationRefreshPending;
	private ArenaLayoutState _incrementalNavigationLayoutState =
		ArenaLayoutState.Invalid;

	protected override void OnStart()
	{
		RoundManager ??=
			Components.Get<RoundManager>();
	}

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

		if ( Time.Now < _nextSyncAt )
			return;

		_nextSyncAt =
			Time.Now + 0.20f;

		SyncNavigationMesh();
		SyncManagedBot();
	}

	public void SyncNow()
	{
		if ( !Networking.IsHost )
			return;

		_nextSyncAt = 0f;
		SyncNavigationMesh();
		SyncManagedBot();
	}

	public void DestroyAllBots()
	{
		if ( !Networking.IsHost || Scene is null )
			return;

		foreach (
			OMRBotController bot in
				Scene
					.GetAllComponents<OMRBotController>()
					.ToArray()
		)
		{
			if (
				bot?.GameObject is null ||
				bot.GameObject.IsDestroyed
			)
				continue;

			if ( LogLifecycle )
			{
				Log.Info(
					$"[OMR BOT] Destroying '{bot.GameObject.Name}'"
				);
			}

			bot.GameObject.Destroy();
		}
	}

	private void SyncNavigationMesh()
	{
		if (
			RoundManager is null ||
			Scene?.NavMesh is null
		)
			return;

		bool sandboxNavigationRequested =
			RoundManager.Mode == SessionMode.Sandbox &&
			RoundManager.SandboxBotEnabled &&
			RoundManager.SandboxBotBehavior != OMRBotBehaviorMode.Stationary;

		bool matchNavigationRequested =
			RoundManager.Mode == SessionMode.Match &&
			RoundManager.MatchBotEnabled;

		bool navigationRequested = sandboxNavigationRequested || matchNavigationRequested;

		Scene.NavMesh.DrawMesh =
			RoundManager.Mode == SessionMode.Sandbox && RoundManager.SandboxBotDrawNavMesh;

		if ( !navigationRequested )
		{
			_navigationRebuildPending = false;
			_pendingNavigationLayoutState = ArenaLayoutState.Invalid;
			return;
		}

		if ( !_navigationMeshConfigured )
		{
			Scene.NavMesh.IsEnabled = true;
			Scene.NavMesh.IncludeStaticBodies = true;
			Scene.NavMesh.IncludeKeyframedBodies = true;
			Scene.NavMesh.DeferGeneration = true;
			// Dynamic player capsules and triggers are never arena geometry.
			Scene.NavMesh.ExcludedBodies.Add( "player" );
			Scene.NavMesh.ExcludedBodies.Add( "playercontroller" );
			Scene.NavMesh.ExcludedBodies.Add( "trigger" );

			// Match the authored player capsule instead of the older generic
			// scene defaults. This preserves realistic clearance around cover.
			Scene.NavMesh.AgentHeight = 72f;
			Scene.NavMesh.AgentRadius = 20f;
			Scene.NavMesh.AgentStepSize = 18f;
			Scene.NavMesh.AgentMaxSlope = 40f;

			_navigationMeshConfigured = true;
		}

		ArenaLayoutState layoutState =
			RoundManager.Arena?.CurrentLayoutState ??
			ArenaLayoutState.Invalid;

		// Finish/log an explicit generation request before considering another.
		if ( _navigationGenerationTask is not null )
		{
			if ( !_navigationGenerationTask.IsCompleted )
				return;

			bool generated = false;

			if ( _navigationGenerationTask.Status == TaskStatus.RanToCompletion )
				generated = _navigationGenerationTask.Result;

			if ( LogLifecycle )
			{
				Vector3? samplePoint =
					generated ? Scene.NavMesh.GetRandomPoint() : null;

				string sampleText =
					samplePoint is null
						? "NONE"
						: samplePoint.Value.ToString();

				Log.Info(
					$"[OMR BOT] NavMesh generation finished | Layout:{_generatingNavigationLayoutState} | Success:{generated} | Dirty:{Scene.NavMesh.IsDirty} | Sample:{sampleText}"
				);
			}

			if ( generated )
			{
				_navigationHasInitialFullBuild = true;
				_navigationFailureCount = 0;
				_navigationRetryAfter = 0f;
			}
			else
			{
				_lastNavigationLayoutState = ArenaLayoutState.Invalid;
				_navigationHasInitialFullBuild = false;
				_navigationFailureCount = System.Math.Min( 5, _navigationFailureCount + 1 );
				_navigationRetryAfter = Time.Now + System.MathF.Min( 8f, _navigationFailureCount * 1.5f );
				if ( _navigationGenerationTask.IsFaulted )
					Log.Warning( $"[OMR BOT] NavMesh build failed: {_navigationGenerationTask.Exception?.GetBaseException().Message}" );
			}
			_navigationGenerationTask = null;
			_generatingNavigationLayoutState = ArenaLayoutState.Invalid;
		}

		if ( _incrementalNavigationRefreshPending && Time.Now >= _incrementalNavigationQueuedAt + 0.25f && !Scene.NavMesh.IsGenerating )
		{
			if ( LogLifecycle )
			{
				Log.Info(
					$"[OMR BOT] NavMesh incremental refresh settled | Layout:{_incrementalNavigationLayoutState}"
				);
			}

			_incrementalNavigationRefreshPending = false;
			_incrementalNavigationLayoutState = ArenaLayoutState.Invalid;
		}

		if ( layoutState.IsValid && layoutState != _lastNavigationLayoutState )
		{
			_lastNavigationLayoutState = layoutState;

			// Arena pieces are cloned at runtime. Give their physics shapes a few
			// frames to enter the PhysicsWorld before generating Recast data.
			_pendingNavigationLayoutState = layoutState;
			_navigationRebuildPending = true;
			_navigationRebuildAt = System.MathF.Max( Time.Now + 0.40f, _navigationRetryAfter );

			if ( LogLifecycle )
			{
				Log.Info(
					$"[OMR BOT] NavMesh refresh scheduled | Layout:{layoutState} | Mode:{(_navigationHasInitialFullBuild ? "TILES" : "FULL")}"
				);
			}
		}

		if (
			_navigationRebuildPending &&
			Time.Now >= _navigationRebuildAt &&
			_navigationGenerationTask is null &&
			!_incrementalNavigationRefreshPending &&
			!Scene.NavMesh.IsGenerating
		)
		{
			_navigationRebuildPending = false;
			_generatingNavigationLayoutState = _pendingNavigationLayoutState;
			_pendingNavigationLayoutState = ArenaLayoutState.Invalid;

			if ( !_navigationHasInitialFullBuild )
			{
				// The first bot-active layout needs a complete build. Subsequent modular
				// swaps keep the arena bounds and refresh the overlapping tiles instead
				// of repeatedly rebuilding all Recast data between rounds.
				_navigationGenerationTask =
					Scene.NavMesh.Generate( Scene.PhysicsWorld );

				if ( LogLifecycle )
				{
					Log.Info(
						$"[OMR BOT] NavMesh full generation started | Layout:{_generatingNavigationLayoutState}"
					);
				}
			}
			else
			{
				Scene.NavMesh.RequestTilesGeneration( Scene.NavMesh.Bounds );
				_incrementalNavigationQueuedAt = Time.Now;
				_incrementalNavigationRefreshPending = true;
				_incrementalNavigationLayoutState = _generatingNavigationLayoutState;
				_generatingNavigationLayoutState = ArenaLayoutState.Invalid;

				if ( LogLifecycle )
				{
					Log.Info(
						$"[OMR BOT] NavMesh incremental tile refresh queued | Layout:{_incrementalNavigationLayoutState}"
					);
				}
			}
		}
	}

	private void SyncManagedBot()
	{
		RoundManager ??= Components.Get<RoundManager>();
		if ( RoundManager is null || Scene is null )
			return;

		List<OMRBotController> bots = _managedBots;
		List<PlayerState> humans = _managedHumans;
		bots.Clear(); humans.Clear();
		foreach ( OMRBotController bot in Scene.GetAllComponents<OMRBotController>() )
			if ( bot?.IsValid == true && bot.GameObject is not null && !bot.GameObject.IsDestroyed ) bots.Add( bot );
		foreach ( PlayerState human in Scene.GetAllComponents<PlayerState>() )
			if ( human?.IsValid == true && !human.IsBot ) humans.Add( human );

		bool sandboxRequested =
			RoundManager.Mode == SessionMode.Sandbox &&
			RoundManager.SandboxBotEnabled &&
			humans.Count < 2;
		bool matchRequested =
			RoundManager.Mode == SessionMode.Match &&
			RoundManager.MatchBotEnabled &&
			humans.Count == 1;
		bool shouldHaveBot = sandboxRequested || matchRequested;

		if ( !shouldHaveBot )
		{
			if ( bots.Count > 0 ) DestroyAllBots();
			return;
		}

		for ( int index = bots.Count - 1; index >= 1; index-- )
		{
			bots[index].GameObject.Destroy();
			bots.RemoveAt( index );
		}

		OMRBotDifficulty difficulty = matchRequested ? RoundManager.MatchBotDifficulty : RoundManager.SandboxBotDifficulty;
		OMRBotBehaviorMode behavior = matchRequested ? OMRBotBehaviorMode.TacticalCombat : RoundManager.SandboxBotBehavior;
		string name = matchRequested ? RoundManager.MatchBotName : RoundManager.SandboxBotName;

		if ( bots.Count == 0 )
		{
			SpawnManagedBot( name, difficulty, behavior, matchRequested );
			return;
		}

		OMRBotController existing = bots[0];
		existing.Configure( 0, difficulty, behavior );
		PlayerState existingState = existing.Components.Get<PlayerState>( true );
		if ( existingState is not null )
		{
			existingState.BotDisplayName = name;
			int desiredTeam = ResolveSandboxBotTeam();
			if ( desiredTeam >= 0 ) existingState.TeamId = desiredTeam;
		}
	}

	private int ResolveSandboxBotTeam()
	{
		PlayerState human = _managedHumans.FirstOrDefault( x => x.IsValid && !x.IsBot );

		if ( human?.TeamId == 1 )
			return 0;

		// Unassigned/Team A humans default to the conventional Team B bot slot.
		return 1;
	}

	private void SpawnManagedBot( string botName, OMRBotDifficulty difficulty, OMRBotBehaviorMode behavior, bool competitiveMatch )
	{
		if (
			!Networking.IsHost ||
			RoundManager is null ||
			string.IsNullOrWhiteSpace( PlayerPrefabPath )
		)
			return;

		int botTeam =
			ResolveSandboxBotTeam();

		GameObject spawn =
			botTeam == 0
				? RoundManager.SpawnA
				: RoundManager.SpawnB;

		Transform spawnTransform =
			spawn is not null
				? spawn.WorldTransform.WithScale( 1f )
				: WorldTransform.WithScale( 1f );

		GameObject bot =
			GameObject.Clone(
				PlayerPrefabPath,
				new CloneConfig
				{
					Name = $"Bot - {botName}",
					StartEnabled = false,
					Transform = spawnTransform
				}
			);

		if ( bot is null || !bot.IsValid )
		{
			Log.Error(
				$"[OMR BOT] Could not clone player prefab '{PlayerPrefabPath}'."
			);
			return;
		}

		// The clone is intentionally created with StartEnabled = false so none of
		// the human-player components start running before bot ownership/input is
		// configured. ComponentList.Get() ignores inactive components by default,
		// so every pre-NetworkSpawn lookup here must include disabled components.
		PlayerState state =
			bot.Components.Get<PlayerState>( true );

		if ( state is null )
		{
			Log.Error(
				"[OMR BOT] Player prefab has no PlayerState."
			);
			bot.Destroy();
			return;
		}

		state.IsBot = true;
		state.BotDisplayName = botName;
		state.TeamId = botTeam;
		state.PreMatchState =
			PreMatchPlayerState.Lobby;

		PlayerController playerController =
			bot.Components.Get<PlayerController>( true );

		if ( playerController is not null )
		{
			playerController.UseInputControls = false;
			playerController.UseLookControls = false;
			playerController.UseCameraControls = false;

			// A bot is host-owned, but visually it is always another third-person
			// competitor. Never let ownership-based first-person hiding make its
			// Citizen body disappear for the host tester.
			playerController.HideBodyInFirstPerson = false;
		}

		// OMR keeps CalculatePath points as input for the shared PlayerLocomotion
		// pipeline. The historical NavMeshAgent transform bug (#11811) was fixed
		// by S&box on 2026-09-15; retaining input-driven motion is an architectural
		// choice, not a workaround for a current engine defect.

		// These components exist for the human owner experience only. Keeping
		// them disabled on a host-controlled bot prevents a second local camera,
		// settings path, hitmarker path or inventory-input reader from attaching
		// itself to the host.
		PlayerInventoryInput inventoryInput =
			bot.Components.Get<PlayerInventoryInput>( true );

		if ( inventoryInput is not null )
			inventoryInput.Enabled = false;

		PlayerCameraFeel cameraFeel =
			bot.Components.Get<PlayerCameraFeel>( true );

		if ( cameraFeel is not null )
			cameraFeel.Enabled = false;

		PlayerCombatFeedback combatFeedback =
			bot.Components.Get<PlayerCombatFeedback>( true );

		if ( combatFeedback is not null )
			combatFeedback.Enabled = false;

		Dresser dresser =
			bot.Components.Get<Dresser>( true );

		if ( dresser is not null )
		{
			dresser.Source =
				Dresser.ClothingSource.Manual;
			dresser.RemoveUnownedItems = false;
			dresser.Randomize();
		}

		OMRBotController botController =
			bot.Components.Get<OMRBotController>( true );

		botController ??=
			bot.AddComponent<OMRBotController>();

		botController.Configure(
			0,
			difficulty,
			behavior
		);

		// No connection argument: the host owns/simulates this networked player.
		// This is the same player-bot pattern used by released S&box games such
		// as Dropout, while avoiding fake/debug Connection objects.
		if ( !bot.NetworkSpawn() )
		{
			Log.Error(
				"[OMR BOT] NetworkSpawn failed."
			);
			bot.Destroy();
			return;
		}

		if ( LogLifecycle )
		{
			Log.Info(
				$"[OMR BOT] Spawned {state.BotDisplayName} | Team:{botTeam} | Difficulty:{difficulty} | Behavior:{behavior} | Match:{competitiveMatch}"
			);
		}
	}
}