OneMoreRoundWeapon.cs
using Sandbox;
using Sandbox.Rendering;
using System;

/// <summary>
/// Marker component placed on a world-model GameObject that represents
/// where the holder's left hand should be positioned and oriented.
///
/// Put this component on a child such as "left_hand_ik" inside the
/// weapon's world-model prefab. The weapon discovers the runtime clone
/// automatically and feeds its world transform into the holder AnimGraph.
/// </summary>
public sealed class WeaponLeftHandIkTarget : Component
{
}

public partial class OneMoreRoundWeapon : BaseCombatWeapon
{
	private const string AimAction = "aim";

	// =========================================================
	// OMR IDENTITY
	// =========================================================

	/// <summary>
	/// Stable, code-facing identity for this exact weapon.
	///
	/// Do not derive gameplay rules from the prefab filename. Prefabs can be
	/// renamed; WeaponId is the durable key future round presets/mutators use.
	/// Examples: "usp", "mp5", "m4a1".
	/// </summary>
	[Property, Group( "OMR Identity" )]
	public string WeaponId { get; set; } = string.Empty;

	/// <summary>
	/// Broad category used by future loadout rules such as "any shotgun".
	/// Exact-weapon rules should use WeaponId instead.
	/// </summary>
	[Property, Group( "OMR Identity" )]
	public OMRWeaponArchetype WeaponArchetype { get; set; } =
		OMRWeaponArchetype.Unknown;

	/// <summary>
	/// Mechanical traits should come from BaseCombatWeapon's real settings
	/// rather than duplicated inspector booleans that can drift out of sync.
	/// </summary>
	public bool IsAutomaticWeapon =>
		PrimaryAutomatic;

	public bool IsPelletWeapon =>
		Ballistics.Pellets > 1;

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

	// =========================================================
	// WORLD MODEL RECOVERY
	// =========================================================

	[Property]
	public bool EnableWorldModelRecovery { get; set; } = true;

	[Property]
	public float WorldModelRecoveryDelay { get; set; } = 0.75f;

	// =========================================================
	// REUSABLE CAPABILITY GATES
	// =========================================================

	[Property, Group( "Capabilities" ), Title( "Allow Aim" )]
	public bool AllowAim { get; set; } = true;

	[Property, Group( "Capabilities" ), Title( "Allow Reload" )]
	public bool AllowReload { get; set; } = true;

	// =========================================================
	// AIM DOWN SIGHTS
	// =========================================================

	[Property, Group( "ADS" )]
	[Range( 0f, 1f )]
	public float IronSightsFireScale { get; set; } = 0.2f;

	[Property, Group( "ADS" )]
	[Range( 0.1f, 1f )]
	public float AimFovScale { get; set; } = 0.8f;

	[Property, Group( "ADS" )]
	[Range( 0f, 1f )]
	public float AimFovTransitionTime { get; set; } = 0.15f;

	/// <summary>
	/// Speed multiplier for the authored Facepunch ironsight transition.
	/// This drives the documented speed_ironsights AnimGraph parameter and
	/// does not move the viewmodel root in code.
	/// </summary>
	[Property, Group( "ADS" )]
	[Range( 0.1f, 4f )]
	public float AimAnimationSpeed { get; set; } = 1.0f;

	[Sync]
	public bool IsAiming { get; set; }

	// =========================================================
	// CROSSHAIR
	// =========================================================

	[Property, Group( "Crosshair" )]
	[Range( 0.1f, 10f )]
	public float CrosshairSpreadVisualScale { get; set; } = 2.5f;

	[Property, Group( "Crosshair" )]
	public float CrosshairMinimumGap { get; set; } = 4f;

	[Property, Group( "Crosshair" )]
	public float CrosshairMaximumGap { get; set; } = 70f;

	[Property, Group( "Crosshair" )]
	public float CrosshairLineLength { get; set; } = 9f;

	[Property, Group( "Crosshair" )]
	public float CrosshairLineWidth { get; set; } = 2f;

	[Property, Group( "Crosshair" )]
	public Color CrosshairColor { get; set; } = Color.White;

	// =========================================================
	// INTERNAL STATE
	// =========================================================

	private float _timeAlive;

	private bool _worldModelRecoveryAttempted;

	private float _aimFovBlend;

	private GameObject _lastAimViewModel;

	private bool _viewModelAimStateDirty = true;

	private bool _sprintSuppressesAim;

	/// <summary>
	/// Host-owned bots are non-proxy just like the host's real player. Never use
	/// IsProxy alone to decide whether this weapon should read local human input
	/// or create first-person presentation.
	/// </summary>
	public bool IsBotControlled
	{
		get
		{
			PlayerController owner = Owner;
			return owner?.Components.Get<PlayerState>()?.IsBot == true;
		}
	}


	private GameObject _ikWorldModel;

	private WeaponLeftHandIkTarget _leftHandIkTarget;

	// =========================================================
	// LIFECYCLE
	// =========================================================

	protected override void OnStart()
	{
		// Definition values must be resolved before BaseCombatWeapon initializes
		// magazines, models and other engine-owned weapon state.
		ApplyWeaponDefinitionIfAssigned();

		base.OnStart();

		_timeAlive =
			0f;

		_worldModelRecoveryAttempted =
			false;

		_aimFovBlend =
			0f;

		_lastAimViewModel =
			null;

		_viewModelAimStateDirty =
			true;

		_sprintSuppressesAim =
			false;

		ResetAccuracyState();
		ResetRecoilState();
		ResetCycleState( chambered: true );
		ResetCyclePresentationState();
		ResetWeaponActionState();
		ResetShotValidationState();

		_ikWorldModel =
			null;

		_leftHandIkTarget =
			null;

		ResetViewModelPresentationState();
		InitializePresentationEventPipeline();

		// If the inventory made this item active before OnStart ran, OnEquipped
		// has already fired. Re-check model presence now that all component
		// initialization is complete instead of waiting for a weapon switch.
		if ( IsActive )
		{
			SuppressBotViewModel();
			EnsureEquippedPresentationModels();
		}

		if ( EnableDebugLogging )
		{
			Log.Info(
				$"[OMR WEAPON] Started | Object: {GameObject.Name} | Proxy: {IsProxy}"
			);
		}
	}

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

		// Safety net for host-owned bots. If an engine lifecycle or a future
		// weapon change recreates an owner-local viewmodel after equip, remove it
		// before any OMR first-person presentation code can update/render it.
		SuppressBotViewModel();

		UpdateAimTransitionState();
		UpdateCycleRuntimeState();
		UpdateCyclePresentationState();
		UpdateRecoilRuntimeState();
		UpdateWorldModelRecovery();
		UpdateViewModelPresentationAnimations();
		UpdateWeaponModelPresentation();
	}

	protected override void OnDestroy()
	{
		// World/view models are local, non-networked presentation clones.
		// A replicated inventory item can be destroyed on a remote peer before
		// BaseInventoryComponent gets a chance to call OnHolstered() locally.
		// In that ordering, BaseCombatWeapon's normal holster cleanup is skipped
		// and the world model (parented to the holder bone, not this weapon item)
		// would survive as an orphan. Always tear presentation down here as the
		// final lifecycle safety net.
		_worldModelRecoveryAttempted = true;
		_timeAlive = 0f;

		ClearBodyIk();
		ResetWeaponModelPresentationState();

		DestroyWorldModel();
		DestroyViewModel();

		base.OnDestroy();
	}

	protected override void OnDisabled()
	{
		_worldModelRecoveryAttempted = true;
		_timeAlive = 0f;

		ClearBodyIk();
		ResetAimState();
		ResetAccuracyState();
		ResetRecoilState();
		ResetCycleState( chambered: true );
		ResetCyclePresentationState();
		ResetWeaponActionState();
		ResetViewModelPresentationState();
		ResetWeaponModelPresentationState();
		CancelReloadAudio();
		CancelCycleAudio();

		base.OnDisabled();
	}

	protected override void OnHolstered()
	{
		// BaseCombatWeapon destroys the local view/world models on holster.
		// Prevent our delayed recovery path from resurrecting the world model
		// while this inactive inventory item is still parented under the player.
		_worldModelRecoveryAttempted = true;
		_timeAlive = 0f;

		ClearBodyIk();
		ResetAimState();
		ResetAccuracyState();
		ResetRecoilState();

		// Holstering resolves an unfinished manual cycle as ready. This avoids
		// returning to a permanently unchambered weapon after inventory switching.
		ResetCycleState( chambered: true );
		ResetCyclePresentationState();
		ResetWeaponActionState();
		ResetViewModelPresentationState();
		ResetWeaponModelPresentationState();
		CancelCycleAudio();

		base.OnHolstered();
		RaisePresentationEvent( WeaponPresentationEventKind.Holstered );
	}

	// =========================================================
	// THIRD-PERSON BODY IK
	// =========================================================

	/// <summary>
	/// Extends BaseCombatWeapon's normal holder animation update.
	///
	/// The base implementation still owns HoldType/Handedness. We only
	/// add an optional left-hand IK target supplied by the weapon's
	/// world-model prefab. Because UpdateBodyAnimations runs on every
	/// peer while the weapon is deployed, each peer drives the hand
	/// against its own local WorldModel clone.
	/// </summary>
	protected override void UpdateBodyAnimations(
		SkinnedModelRenderer body
	)
	{
		base.UpdateBodyAnimations(
			body
		);

		if ( body is null )
			return;

		ResolveBodyIkTarget();

		if (
			_leftHandIkTarget is null ||
			_leftHandIkTarget.GameObject is null ||
			!_leftHandIkTarget.GameObject.IsValid
		)
		{
			body.ClearIk(
				"hand_left"
			);

			return;
		}

		GameObject target =
			_leftHandIkTarget.GameObject;

		body.SetIk(
			"hand_left",
			new Transform(
				target.WorldPosition,
				target.WorldRotation
			)
		);
	}

	private void ResolveBodyIkTarget()
	{
		GameObject currentWorldModel =
			WorldModel;

		if (
			currentWorldModel ==
			_ikWorldModel
		)
			return;

		_ikWorldModel =
			currentWorldModel;

		_leftHandIkTarget =
			null;

		if ( currentWorldModel is null )
			return;

		_leftHandIkTarget =
			currentWorldModel
				.Components
				.Get<WeaponLeftHandIkTarget>(
					FindMode.InSelf |
					FindMode.InDescendants
				);
	}

	private void ClearBodyIk()
	{
		HolderRenderer?.ClearIk(
			"hand_left"
		);

		_ikWorldModel =
			null;

		_leftHandIkTarget =
			null;
	}


	// =========================================================
	// WEAPON CONTROL
	// =========================================================

	protected override void OnControl()
	{
		UpdateAccuracyState();

		// Bot combat will call the same authored weapon actions explicitly in a
		// later phase. Until then, and permanently for raw-input sampling, never
		// let a host-owned bot weapon mirror the host's mouse/buttons.
		if ( IsBotControlled )
		{
			return;
		}

		UpdateTemporaryPrimaryGate();

		//
		// This must happen BEFORE BaseCombatWeapon handles attack1 so the first
		// sprint shot can be buffered while the authored sprint-exit animation
		// raises the weapon.
		//
		UpdateSprintToFireGate();

		base.OnControl();

		if (
			Input.Pressed(
				AimAction
			)
		)
		{
			_sprintSuppressesAim =
				false;
		}

		//
		// ADS has its own input action and therefore no longer
		// touches attack2 / secondary fire. While reloading we
		// deliberately keep ADS out of the AnimGraph; holding aim
		// queues it naturally and ADS begins as soon as the reload
		// has actually finished.
		//
		bool wantsAim =
			AllowAim &&
			RoundAllowsAim &&
			Input.Down(
				AimAction
			) &&
			!_sprintSuppressesAim &&
			!IsReloading &&
			CanAimDuringCurrentCycle();

		if (
			wantsAim !=
			IsAiming
		)
		{
			SetAiming(
				wantsAim
			);
		}

		EnsureViewModelAimState();
	}

	// =========================================================
	// CROSSHAIR
	// =========================================================

	public override void DrawHud(
		HudPainter painter,
		Vector2 crosshair
	)
	{
		if ( IsAiming && HideCrosshairWhileAiming )
			return;

		base.DrawHud(
			painter,
			crosshair
		);
	}

	public override void DrawCrosshair(
		HudPainter painter,
		Vector2 center
	)
	{
		Vector2 gap =
			CalculateCrosshairGap(
				CurrentSpread
			);

		float horizontalGap =
			gap.x;

		float verticalGap =
			gap.y;

		float length =
			MathF.Max(
				CrosshairLineLength,
				0f
			);

		float width =
			MathF.Max(
				CrosshairLineWidth,
				0.5f
			);

		painter.DrawLine(
			center +
				Vector2.Left *
				(
					horizontalGap +
					length
				),
			center +
				Vector2.Left *
					horizontalGap,
			width,
			CrosshairColor
		);

		painter.DrawLine(
			center +
				Vector2.Right *
					horizontalGap,
			center +
				Vector2.Right *
					(
						horizontalGap +
						length
					),
			width,
			CrosshairColor
		);

		painter.DrawLine(
			center +
				Vector2.Up *
					(
						verticalGap +
						length
					),
			center +
				Vector2.Up *
					verticalGap,
			width,
			CrosshairColor
		);

		painter.DrawLine(
			center +
				Vector2.Down *
					verticalGap,
			center +
				Vector2.Down *
					(
						verticalGap +
						length
					),
			width,
			CrosshairColor
		);
	}

	private Vector2 CalculateCrosshairGap(
		Vector2 spread
	)
	{
		CameraComponent camera =
			Scene.Camera;

		if (
			camera is null ||
			Screen.Width <= 1f ||
			Screen.Height <= 1f
		)
		{
			return new Vector2(
				CrosshairMinimumGap,
				CrosshairMinimumGap
			);
		}

		float fov =
			camera.View.FieldOfView;

		if ( fov <= 1f )
		{
			fov =
				camera.FieldOfView;
		}

		fov =
			MathX.Clamp(
				fov,
				1f,
				179f
			);

		float aspect =
			Screen.Aspect;

		if ( aspect <= 0.01f )
		{
			aspect =
				Screen.Width /
				Screen.Height;
		}

		float horizontalFovRadians;

		float verticalFovRadians;

		float sourceFovRadians =
			DegreesToRadians(
				fov
			);

		if (
			camera.FovAxis ==
			CameraComponent.Axis.Horizontal
		)
		{
			horizontalFovRadians =
				sourceFovRadians;

			verticalFovRadians =
				2f *
				MathF.Atan(
					MathF.Tan(
						horizontalFovRadians *
							0.5f
					) /
					aspect
				);
		}
		else
		{
			verticalFovRadians =
				sourceFovRadians;

			horizontalFovRadians =
				2f *
				MathF.Atan(
					MathF.Tan(
						verticalFovRadians *
							0.5f
					) *
					aspect
				);
		}

		float spreadXRadians =
			DegreesToRadians(
				MathF.Abs(
					spread.x
				)
			);

		float spreadYRadians =
			DegreesToRadians(
				MathF.Abs(
					spread.y
				)
			);

		float horizontalProjection =
			MathF.Tan(
				horizontalFovRadians *
					0.5f
			);

		float verticalProjection =
			MathF.Tan(
				verticalFovRadians *
					0.5f
			);

		if (
			horizontalProjection <= 0.0001f ||
			verticalProjection <= 0.0001f
		)
		{
			return new Vector2(
				CrosshairMinimumGap,
				CrosshairMinimumGap
			);
		}

		float gapX =
			MathF.Tan(
				spreadXRadians
			) /
			horizontalProjection *
			(
				Screen.Width *
					0.5f
			);

		float gapY =
			MathF.Tan(
				spreadYRadians
			) /
			verticalProjection *
			(
				Screen.Height *
					0.5f
			);

		gapX *=
			CrosshairSpreadVisualScale;

		gapY *=
			CrosshairSpreadVisualScale;

		gapX =
			MathX.Clamp(
				gapX,
				CrosshairMinimumGap,
				CrosshairMaximumGap
			);

		gapY =
			MathX.Clamp(
				gapY,
				CrosshairMinimumGap,
				CrosshairMaximumGap
			);

		return new Vector2(
			gapX,
			gapY
		);
	}

	private static float DegreesToRadians(
		float degrees
	)
	{
		return degrees *
			(
				MathF.PI /
				180f
			);
	}

	// =========================================================
	// ROUND RESET
	// =========================================================

	/// <summary>
	/// Clears owner-local transient weapon state when a new round
	/// is prepared.
	///
	/// BaseCombatWeapon owns reload cancellation and ammo on the
	/// host. This method only resets One More Round presentation
	/// and accuracy state without trying to call the protected
	/// CreateViewModel/DestroyViewModel methods from another
	/// component.
	/// </summary>
	public void ResetForRoundPresentation()
	{
		_sprintSuppressesAim =
			false;

		ResetAimState();
		ResetAccuracyState();
		ResetRecoilState();
		ResetCycleState( chambered: true );
		ResetWeaponActionState();
		ResetShotValidationState();
		ResetViewModelPresentationState();
	}

	// =========================================================
	// ADS / SPRINT NEGOTIATION
	// =========================================================

	public void SuppressAimForSprint()
	{
		if ( _sprintSuppressesAim )
			return;

		_sprintSuppressesAim =
			true;

		if ( IsAiming )
		{
			SetAiming(
				false
			);
		}
	}

	public void ReleaseAimSuppressionForSprint()
	{
		_sprintSuppressesAim =
			false;
	}

	// =========================================================
	// ADS STATE
	// =========================================================

	private void SetAiming(
		bool aiming
	)
	{
		if ( aiming && !AllowAim )
			aiming = false;

		if (
			IsAiming ==
			aiming
		)
			return;

		IsAiming =
			aiming;

		_viewModelAimStateDirty =
			true;

		if ( EnableDebugLogging )
		{
			Log.Info(
				$"[OMR ADS] {(IsAiming ? "ON" : "OFF")} | Weapon: {GameObject.Name}"
			);
		}

		EnsureViewModelAimState();
	}

	private void ResetAimState()
	{
		IsAiming =
			false;

		_aimFovBlend =
			0f;

		_sprintSuppressesAim =
			false;

		_viewModelAimStateDirty =
			true;

		EnsureViewModelAimState();

		_lastAimViewModel =
			null;
	}

	// =========================================================
	// VIEW MODEL ADS
	// =========================================================

	private void EnsureViewModelAimState()
	{
		GameObject currentViewModel =
			ViewModel;

		if (
			currentViewModel is null
		)
		{
			_lastAimViewModel =
				null;

			_viewModelAimStateDirty =
				true;

			return;
		}

		if (
			currentViewModel !=
			_lastAimViewModel
		)
		{
			_lastAimViewModel =
				currentViewModel;

			_viewModelAimStateDirty =
				true;
		}

		if (
			!_viewModelAimStateDirty
		)
			return;

		BaseWeaponModel weaponModel =
			currentViewModel
				.Components
				.Get<BaseWeaponModel>();

		SkinnedModelRenderer renderer =
			weaponModel?.
				Renderer;

		renderer ??=
			currentViewModel
				.Components
				.Get<SkinnedModelRenderer>();

		if (
			renderer is null
		)
			return;

		renderer.Set(
			"ironsights",
			IsAiming ? 1 : 0
		);

		renderer.Set(
			"ironsights_fire_scale",
			GetEffectiveViewModelFireScale()
		);

		renderer.Set(
			"speed_ironsights",
			MathF.Max( AimAnimationSpeed, 0.1f )
		);

		_viewModelAimStateDirty =
			false;
	}

	// =========================================================
	// ADS CAMERA
	// =========================================================

	protected override void ModifyCamera(
		CameraComponent camera,
		ref CameraView view
	)
	{
		// Only the weapon held by the actual local human may modify the local
		// gameplay camera. Host-owned bots are non-proxy on the host, so an
		// IsProxy-only check is insufficient: without this gate, ROOK's ADS state
		// multiplies the host player's FOV whenever the bot aims.
		PlayerState ownerState =
			Owner?.Components.Get<PlayerState>();

		if ( ownerState?.IsLocalHuman != true )
			return;

		float clampedAimFovScale =
			MathX.Clamp(
				AimFovScale,
				0.1f,
				1f
			);

		float finalFovScale =
			MathX.Lerp(
				1f,
				clampedAimFovScale,
				_aimFovBlend
			);

		// Weapon ADS projection belongs only to the real gameplay camera. Secondary
		// cameras (viewmodel, PiP optics, mirrors, future RT cameras) own their own
		// projection and must not inherit this modifier accidentally.
		//
		// PiP scopes keep their optical magnification in OMRScopePiPCamera. The
		// gameplay camera may optionally receive only a separate, presentation-only
		// peripheral focus scale from the optic profile.
		bool isGameplayCamera = camera == Scene?.Camera;

		if ( isGameplayCamera )
		{
			if ( UsesPictureInPictureScope )
			{
				// PiP optics own their real magnification in OMRScopePiPCamera, but
				// may optionally apply a small presentation-only focus zoom to the
				// surrounding world. Keep this separate from AimFovScale so the
				// authored optical magnification remains independent.
				view.FieldOfView *= CurrentPeripheralAimFovScale;
			}
			else
			{
				view.FieldOfView *= finalFovScale;
			}

			view.FieldOfView = MathX.Clamp(
				view.FieldOfView,
				1f,
				179f
			);
		}

		base.ModifyCamera(
			camera,
			ref view
		);
	}

	// =========================================================
	// WORLD MODEL RECOVERY
	// =========================================================

	private void UpdateWorldModelRecovery()
	{
		if (
			!EnableWorldModelRecovery
		)
			return;

		// World models belong only to the deployed weapon. BaseCombatWeapon
		// destroys them in OnHolstered(); IsHeld alone is not sufficient here
		// because inactive inventory items can remain parented to the player.
		if ( !IsActive || !IsHeld )
			return;

		if (
			_worldModelRecoveryAttempted
		)
			return;

		_timeAlive +=
			Time.Delta;

		if (
			_timeAlive <
			WorldModelRecoveryDelay
		)
			return;

		_worldModelRecoveryAttempted =
			true;

		TryRecoverWorldModel();
	}

	private void TryRecoverWorldModel()
	{
		if (
			WorldModel is not null
		)
			return;

		if ( !IsActive || !IsHeld )
			return;

		if (
			WorldModelPrefab is null
		)
		{
			Log.Warning(
				$"[OMR WEAPON] Recovery failed | {GameObject.Name} | WorldModelPrefab NULL"
			);

			return;
		}

		if (
			HolderRenderer is null
		)
		{
			Log.Warning(
				$"[OMR WEAPON] Recovery failed | {GameObject.Name} | HolderRenderer NULL"
			);

			return;
		}

		if (
			string.IsNullOrWhiteSpace(
				HoldBone
			)
		)
		{
			Log.Warning(
				$"[OMR WEAPON] Recovery failed | {GameObject.Name} | HoldBone empty"
			);

			return;
		}

		bool hasBone =
			HolderRenderer.TryGetBoneTransform(
				HoldBone,
				out _
			);

		if (
			!hasBone
		)
		{
			Log.Warning(
				$"[OMR WEAPON] Recovery failed | {GameObject.Name} | Hold bone '{HoldBone}' unavailable"
			);

			return;
		}

		if ( EnableDebugLogging )
		{
			Log.Info(
				$"[OMR WEAPON] Recovering WorldModel | {GameObject.Name} | Proxy: {IsProxy}"
			);
		}

		CreateWorldModel();

		if (
			WorldModel is not null
		)
		{
			if ( EnableDebugLogging )
			{
				Log.Info(
					$"[OMR WEAPON] Recovery SUCCESS | WorldModel: {WorldModel.Name}"
				);
			}
		}
		else
		{
			Log.Warning(
				"[OMR WEAPON] Recovery attempt completed but WorldModel is still NULL"
			);
		}
	}
}