Player/PlayerPawn.cs
using Sandbox.UI.Player;
using Sandbox.UI.ShipSelect;

/// <summary>
/// Main player component for FP4. Represents a controllable flight ship.
/// Attach to a GameObject that also has a SkinnedModelRenderer and Rigidbody.
/// </summary>
[Title( "Player Pawn" ), Icon( "flight" )]
public sealed partial class PlayerPawn : Component, Component.INetworkListener
{
	// -------------------------------------------------------------------------
	// Sub-components (set up in Respawn)
	// -------------------------------------------------------------------------

	[Property] public SkinnedModelRenderer ModelRenderer { get; set; }
	[Property] public Rigidbody Rigidbody { get; set; }

	// -------------------------------------------------------------------------
	// Ship Data
	// -------------------------------------------------------------------------

	[Sync( SyncFlags.FromHost )] public ShipData Data { get; set; } = ResourceLibrary.Get<ShipData>( "ships/defaultship.ship" );

	// -------------------------------------------------------------------------
	// State
	// -------------------------------------------------------------------------

	// Host-authoritative state — set by Respawn(), TakeDamage(), OnKilled()
	[Sync( SyncFlags.FromHost )] public bool IsAlive { get; set; } = false;
	[Sync( SyncFlags.FromHost )] public bool HasSelectedShip { get; set; } = false;
	[Sync( SyncFlags.FromHost )] public bool IsBot { get; set; } = false;
	[Sync( SyncFlags.FromHost )] public string BotName { get; set; } = "";

	// Bot AI input — set by BotController on host, read by ShootComponent/FlightController
	[Sync( SyncFlags.FromHost )] public bool BotFirePrimary { get; set; } = false;
	[Sync( SyncFlags.FromHost )] public bool BotWantsBoost { get; set; } = false;
	[Sync( SyncFlags.FromHost )] public bool BotWantsBrake { get; set; } = false;

	[Sync( SyncFlags.FromHost )] public float Health { get; set; }
	[Sync( SyncFlags.FromHost )] public float MaxHealth { get; set; }
	[Sync( SyncFlags.FromHost )] public float Shield { get; set; }
	[Sync( SyncFlags.FromHost )] public float MaxShield { get; set; }

	[Sync( SyncFlags.FromHost )] public int Kills { get; set; }
	[Sync( SyncFlags.FromHost )] public int Deaths { get; set; }
	[Sync( SyncFlags.FromHost )] public int Score { get; set; }

	[Sync( SyncFlags.FromHost )] public Color TrailColor { get; set; }
	[Property] TrailRenderer TrailRenderer { get; set; }

	// -------------------------------------------------------------------------
	// Flight Stats — set by host in Respawn(), mutated by owner in FlightController
	// -------------------------------------------------------------------------

	[Sync] public float Speed { get; set; }
	[Sync] public float BoostSpeed { get; set; } = 40f;
	[Sync] public float BoostCoolDown { get; set; } = 100f;
	[Sync] public float BoostAmount { get; set; } = 100f;
	[Sync( SyncFlags.FromHost )] public float BoostRegenRate { get; set; } = 100f;
	[Sync] public float MinSpeed { get; set; } = 10f;
	[Sync] public float MaxSpeed { get; set; } = 20f;
	[Sync( SyncFlags.FromHost )] public float IdleSpeed { get; set; } = 20f;
	[Sync( SyncFlags.FromHost )] public float CappedMaxSpeed { get; set; }
	[Sync] public float Lean { get; set; }
	[Sync] public float BreakLean { get; set; }

	// -------------------------------------------------------------------------
	// Input (replicated from owner to all — owner writes, host reads)
	// -------------------------------------------------------------------------

	[Sync] public Vector3 InputDirection { get; set; }
	[Sync] public Angles ViewAngles { get; set; }

	// -------------------------------------------------------------------------
	// Internal state
	// -------------------------------------------------------------------------

	private TimeSince _timeSinceLastDamage = 10f;
	public float TimeSinceLastDamage => (float)_timeSinceLastDamage;
	private string _primaryWeapon;
	private string _secondaryWeapon;
	private ShipData _pendingShipData;
	private bool _wasAlive = false;
	private bool _pendingRespawn = false;
	private TimeUntil _respawnTime;
	private float _boostStatAccumulator;
	private int _sessionInstagibKills;
	private int _sessionShieldBreaks;
	private float _sessionHullDamage;
	private float _sessionBoostSeconds;
	private int _sessionMatchesCompleted;
	private readonly HashSet<string> _sessionWonShips = new();
	private readonly Dictionary<string, int> _sessionShipWinCounts = new();

	// ── Death screen timer (client-side) ──────────────────────────────────────
	private bool _deathScreenActive = false;
	private TimeUntil _deathScreenHideTime;

	// Death camera — set when killed, cleared on respawn
	[Sync] public PlayerPawn DeathCamTarget { get; set; }

	[Sync] public bool HitWall { get; set; }
	[Sync] public Vector3 WallHitNormal { get; set; }

	// Ship prefab instance (model + trail + all visuals)
	private GameObject _shipGo;

	// Wall scrape spark effect (one persistent instance, toggled on/off)
	private GameObject _wallSparkGo;

	// Sounds
	private SoundHandle _engineSound;
	private SoundHandle _boostLoopSound;
	private SoundHandle _wallGrindSound;

	// Screen shake
	public ScreenShake.Charge ChargeEffect { get; set; }

	// -------------------------------------------------------------------------
	// Lifecycle
	// -------------------------------------------------------------------------

	/// <summary>The local client's own pawn. Null if not yet spawned.</summary>
	public static PlayerPawn Local { get; private set; }

	protected override void OnStart()
	{
		// Use Connection.Local — more reliable than !IsProxy which may not be set yet on network receive
		if ( !IsBot && Network.Owner == Connection.Local )
		{
			Local = this;
			FlightCamera = new FlightCamera();
		}
		TrailColor = Color.Random;

		if(TrailRenderer.IsValid())
			TrailRenderer.Color = TrailColor;
	}

	protected override void OnDestroy()
	{
		if ( Local == this ) Local = null;
		ChargeEffect?.Destroy();
		ChargeEffect = null;
		DestroyShip();
		ProjectileSimulator?.Clear();
	}

	protected override void OnUpdate()
	{
		// Keep Local in sync in case OnStart fired before ownership was confirmed
		if ( !IsBot && Network.Owner == Connection.Local && Local != this )
		{
			Local = this;
			FlightCamera ??= new FlightCamera();
		}

		// Detect alive transition — run client-side setup when the host respawns us
		if ( IsAlive && !_wasAlive )
		{
			_wasAlive = true;
			OnBecameAlive();
		}
		else if ( !IsAlive && _wasAlive )
		{
			_wasAlive = false;
			DestroyShip();
			StopAllSounds(); // always stop — proxy/bot pawns have nearby sounds that must be cleaned up
		}

		// Camera runs even while dead (death cam looks at killer)
		if ( !IsProxy && !IsBot )
		{
			FlightCamera?.Update( this );
		}

		if ( !IsAlive ) return;

		// Sounds for ships near the local player — runs for ALL pawns (proxy, bot, self)
		UpdateNearbySounds();

		if ( IsAlive )
		{
			float shieldRatio = MaxShield > 0f ? Shield / MaxShield : 0f;
			float shieldFade  = Shield < MaxShield ? shieldRatio : 0f;

			foreach ( var r in GameObject.Components.GetAll<ModelRenderer>( FindMode.EverythingInSelfAndDescendants ) )
				r.SceneObject?.Attributes.Set( "damagefade", shieldFade );
		}

		if ( IsProxy || IsBot ) return;

		// Death screen hide timer
		if ( _deathScreenActive && _deathScreenHideTime )
		{
			_deathScreenActive = false;
			ShipListHint.Current?.HideAttacker();
		}

		// Read input and update sounds
		BuildInput();

		// Update sounds every render frame so position tracks smoothly
		UpdateSounds( HitWall );

		// Toggle wall-scrape sparks and place them on the wall surface
		if ( _wallSparkGo != null )
		{
			var hitting = HitWall && IsAlive;
			_wallSparkGo.Enabled = hitting;
			if ( hitting && WallHitNormal != Vector3.Zero )
			{
				// Trace from the ship toward the wall to find the exact contact point
				var wallTrace = Scene.Trace.Ray( WorldPosition, WorldPosition - WallHitNormal * 64f )
					.Size( 4f )
					.IgnoreGameObject( GameObject )
					.Run();
				var contactPoint = wallTrace.Hit ? wallTrace.EndPosition : WorldPosition - WallHitNormal * 32f;
				_wallSparkGo.WorldPosition = contactPoint;
				// Face sparks outward along the wall normal
				_wallSparkGo.WorldRotation = Rotation.LookAt( WallHitNormal, Vector3.Up );
			}
		}

		// Ground dust effect
		var trace = Scene.Trace.Ray( WorldPosition, WorldPosition + WorldRotation.Down * 50f )
			.IgnoreGameObject( GameObject )
			.Run();

		if ( trace.Hit )
		{
			// TODO: spawn ground dust particle at trace.EndPosition
		}
	}

	protected override void OnFixedUpdate()
	{
		if ( IsProxy ) return;
		if ( !IsAlive ) return;

		ProjectileSimulator?.Simulate();
		SimulateInventory();

		if ( IsBot ) return;

		if ( _resetTimer )
			RequestSuicide();

		if ( Input.Down( "slot9" ) )
			DebugText();
	}

	/// <summary>Called by PilotGame on the host each fixed update to regen shield.</summary>
	public void HostFixedUpdate()
	{
		if ( _pendingRespawn && _respawnTime )
		{
			_pendingRespawn = false;
			Respawn();
		}

		if ( !IsAlive ) return;

		Shield = Shield.Clamp( 0f, Data.Shield );

		if ( _timeSinceLastDamage > Data.ShieldRegenDelay )
			Shield = MathF.Min( Shield + Data.ShieldRegenRate, Data.Shield );
	}

	// -------------------------------------------------------------------------
	// Spawn / Respawn
	// -------------------------------------------------------------------------

	/// <summary>Schedule a respawn after <paramref name="delay"/> seconds (host only).</summary>
	public void TriggerRespawn( float delay = 0.1f )
	{
		_respawnTime    = delay;
		_pendingRespawn = true;
	}

	public void Respawn( Transform? spawnTransform = null )
	{
		if ( _pendingShipData != null )
		{
			Data = _pendingShipData;
			_pendingShipData = null;
		}

		// Determine spawn location
		var spawn = spawnTransform ?? PilotGame.GetRandomSpawnpoint();

		// Tell the owner client to teleport — they own the Rigidbody so only they can move it
		TeleportToSpawn( spawn.Position, spawn.Rotation, Data.IdleSpeed, Data.MinSpeed, Data.MaxSpeed, Data.BoostAmount );

		IsAlive = true;
		DeathCamTarget = null;

		Health = Data.Health;
		MaxHealth = Data.Health;
		Shield = Data.Shield;
		MaxShield = PilotGame.Gamemode == FPGameMode.Instagib ? 0f : Data.Shield;

		Speed = Data.IdleSpeed;
		MinSpeed = Data.MinSpeed;
		MaxSpeed = Data.MaxSpeed;
		IdleSpeed = Data.IdleSpeed;
		BoostAmount = Data.BoostAmount;
		BoostSpeed = Data.BoostSpeed;
		BoostRegenRate = Data.BoostRegenRate;
		CappedMaxSpeed = Data.MaxSpeed;
		BoostCoolDown = Data.BoostAmount;
		TrailColor = Color.Random;

		_primaryWeapon = Data.Primary;
		_secondaryWeapon = Data.Secondary;

		if ( PilotGame.Gamemode == FPGameMode.Instagib )
		{
			_primaryWeapon = "prefabs/guns/railgun.prefab";
			_secondaryWeapon = "prefabs/guns/railgun.prefab";
		}

		GameObject.Tags.Add( "player" );

		Components.GetOrCreate<FlightController>();

		SetUpWeapons();

		CreateShipSkinComponent();

		ProjectileSimulator ??= new ProjectileSimulator( this );
	}

	[Rpc.Owner]
	private void TeleportToSpawn( Vector3 position, Rotation rotation, float speed, float minSpeed, float maxSpeed, float boostCoolDown )
	{
		WorldPosition = position;
		WorldRotation = rotation;
		ViewAngles = rotation.Angles();

		if ( Rigidbody != null )
		{
			Rigidbody.Velocity = Vector3.Zero;
			Rigidbody.AngularVelocity = Vector3.Zero;
		}

		// Initialize owner-synced flight params (host can't write [Sync] on client-owned pawn)
		Speed = speed;
		MinSpeed = minSpeed;
		MaxSpeed = maxSpeed;
		BoostCoolDown = boostCoolDown;
	}

	/// <summary>
	/// Called on ALL clients (including the owner) when IsAlive transitions from false to true.
	/// Handles all client-side setup that can't run via the host's Respawn() call.
	/// </summary>
	private void OnBecameAlive()
	{
		CreateShip();
		CreateShipSkinComponent();

		if ( IsProxy || IsBot ) return;

		FlightCamera ??= new FlightCamera();
		SoundSetup();
		Experience.Current?.Save();
	}

	/// <summary>Called by the owning client to request a new ship on the host.</summary>
	[Rpc.Host]
	public void RequestNewShip( string shipPath )
	{
		if ( Network.Owner != Rpc.Caller ) return;

		var newData = PilotGame.Gamemode == FPGameMode.Instagib
			? ResourceLibrary.Get<ShipData>( "ships/wingsship.ship" )
			: ResourceLibrary.Get<ShipData>( shipPath );

		if ( newData == null ) return;

		var firstShipSelection = !HasSelectedShip;
		_pendingShipData = newData;
		HasSelectedShip = true;
		if ( firstShipSelection )
			UnlockAchievement( AchievementKeys.SignedInLockedIn );

		if ( IsAlive )
		{
			// Always die first, then respawn with the new ship.
			TakeDamage( 1000f, null );
		}
		else
		{
			// Already dead: spawn quickly with queued ship.
			_respawnTime = 0.1f;
			_pendingRespawn = true;
		}
	}

	// -------------------------------------------------------------------------

	[Property, Range( 0.05f, 2f )] public float MouseSensitivity { get; set; } = 0.5f;
	[Property, Range( 0f, 0.5f )] public float ControllerAimAssistStrength { get; set; } = 0.22f;
	[Property, Range( 5f, 30f )] public float ControllerAimAssistCone { get; set; } = 18f;
	[Property, Range( 500f, 6000f )] public float ControllerAimAssistRange { get; set; } = 4200f;
	[Property, Range( 0f, 0.5f )] public float ControllerAimTrackStrength { get; set; } = 0.16f;
	[Property, Range( 0f, 25f )] public float ControllerAimAssistMaxDegreesPerTick { get; set; } = 2.5f;
	private PlayerPawn _controllerTrackedTarget;

	// -------------------------------------------------------------------------

	private void BuildInput()
	{
		InputDirection = Input.AnalogMove;

		var look = Input.AnalogLook * MouseSensitivity;

		if( Input.UsingController )
		{
			look *= 1.75f; // controller look is less responsive, so boost it a bit
			look += GetControllerAimAssist( look );
		}

		// When flying inverted the ship's local-left is world-right, so yaw
		// feels backwards. Negate it whenever the ship's up vector points down.
		if ( Rotation.From( ViewAngles ).Up.z < 0f )
			look = new Angles( look.pitch, -look.yaw, look.roll );

		ViewAngles = ( ViewAngles + look ).Normal;

		BuildInventoryInput();
	}

	private Angles GetControllerAimAssist( Angles lookInput )
	{
		if ( ControllerAimAssistStrength <= 0f ) return Angles.Zero;

		var stickMag = new Vector2( lookInput.yaw, lookInput.pitch ).Length;
		const float StickActivationThreshold = 0.03f;
		if ( stickMag < StickActivationThreshold ) return Angles.Zero;

		var scene = Game.ActiveScene;
		if ( scene == null ) return Angles.Zero;

		var baseAngles = (ViewAngles + lookInput).Normal;
		var baseRot = Rotation.From( baseAngles );

		// Sticky target tracking: keep lock if still valid and inside a slightly wider cone.
		var stickyCone = ControllerAimAssistCone * 1.35f;
		if ( IsValidAimAssistTarget( _controllerTrackedTarget, baseRot, stickyCone, out var stickyDir, out var stickyDist, out var stickyAngle ) )
		{
			var stickyAngles = Rotation.LookAt( stickyDir, Vector3.Up ).Angles();
			var stickyDelta = (stickyAngles - baseAngles).Normal;
			var stickyScore = (1f - (stickyAngle / stickyCone)) * (1f - (stickyDist / ControllerAimAssistRange));
			return BuildAimAssistOutput( stickyDelta, stickyScore, stickMag, true );
		}
		_controllerTrackedTarget = null;

		float bestScore = 0f;
		Angles bestDelta = Angles.Zero;
		PlayerPawn bestTarget = null;

		foreach ( var pawn in scene.GetAllComponents<PlayerPawn>() )
		{
			if ( pawn == null || pawn == this || !pawn.IsAlive ) continue;
			if ( !pawn.IsBot ) continue; // prioritize bots for now

			var toEnemy = pawn.WorldPosition - WorldPosition;
			var dist = toEnemy.Length;
			if ( dist <= 1f || dist > ControllerAimAssistRange ) continue;

			var dir = toEnemy / dist;
			var dot = baseRot.Forward.Dot( dir );
			if ( dot <= 0f ) continue;

			var angle = MathF.Acos( dot.Clamp( -1f, 1f ) ).RadianToDegree();
			if ( angle > ControllerAimAssistCone ) continue;

			var targetAngles = Rotation.LookAt( dir, Vector3.Up ).Angles();
			var delta = (targetAngles - baseAngles).Normal;

			// Prefer targets near center and strongly favor bots.
			var score = (1f - (angle / ControllerAimAssistCone)) * (1f - (dist / ControllerAimAssistRange));
			score *= 1.6f;
			if ( score > bestScore )
			{
				bestScore = score;
				bestDelta = delta;
				bestTarget = pawn;
			}
		}

		if ( bestScore <= 0f ) return Angles.Zero;
		_controllerTrackedTarget = bestTarget;

		return BuildAimAssistOutput( bestDelta, bestScore, stickMag, false );
	}

	private Angles BuildAimAssistOutput( Angles delta, float score, float stickMag, bool tracking )
	{
		var inputDamping = 1f - (stickMag.Clamp( 0f, 1f ) * 0.55f);
		var baseStrength = tracking ? ControllerAimTrackStrength : ControllerAimAssistStrength;
		var scale = baseStrength * score * inputDamping;
		var assist = new Angles(
			delta.pitch * scale,
			delta.yaw * scale,
			0f
		);

		// Cap per-tick assist so player stick input always dominates.
		var maxStep = ControllerAimAssistMaxDegreesPerTick.Clamp( 0f, 25f );
		assist.pitch = assist.pitch.Clamp( -maxStep, maxStep );
		assist.yaw = assist.yaw.Clamp( -maxStep, maxStep );
		return assist;
	}

	private bool IsValidAimAssistTarget( PlayerPawn target, Rotation baseRot, float cone, out Vector3 dir, out float dist, out float angle )
	{
		dir = Vector3.Zero;
		dist = 0f;
		angle = 0f;

		if ( target == null || !target.IsAlive || !target.IsBot ) return false;

		var toEnemy = target.WorldPosition - WorldPosition;
		dist = toEnemy.Length;
		if ( dist <= 1f || dist > ControllerAimAssistRange ) return false;

		dir = toEnemy / dist;
		var dot = baseRot.Forward.Dot( dir );
		if ( dot <= 0f ) return false;

		angle = MathF.Acos( dot.Clamp( -1f, 1f ) ).RadianToDegree();
		return angle <= cone;
	}

	// -------------------------------------------------------------------------
	// Velocity (used by FlightController) — owner writes, everyone reads
	// -------------------------------------------------------------------------

	[Sync]
	public Vector3 Velocity { get; set; }

	// -------------------------------------------------------------------------
	// Trail / Effects Prefab
	// -------------------------------------------------------------------------

	private void CreateShip()
	{
		DestroyShip();
		if ( string.IsNullOrEmpty( Data?.ShipPrefab ) ) return;
		var prefabFile = ResourceLibrary.Get<PrefabFile>( Data.ShipPrefab );
		if ( prefabFile == null ) return;

		_shipGo = SceneUtility.GetPrefabScene( prefabFile )?.Clone( new CloneConfig
		{
			Parent = GameObject,
			Transform = global::Transform.Zero,
			StartEnabled = true,
		} );

		if ( _shipGo != null )
		{
			ModelRenderer = _shipGo.Components.Get<SkinnedModelRenderer>( FindMode.EnabledInSelfAndDescendants );

			// Ensure shield is hidden until damagefade is driven by OnUpdate
			foreach ( var r in _shipGo.Components.GetAll<ModelRenderer>( FindMode.EverythingInSelfAndDescendants ) )
				r.SceneObject?.Attributes.Set( "damagefade", 0f );
		}

		// Create one wall-scrape spark instance in world space, disabled until we hit a wall
		var sparkPrefab = ResourceLibrary.Get<PrefabFile>( "prefabs/player/wall_spark.prefab" );
		if ( sparkPrefab != null )
		{
			_wallSparkGo = SceneUtility.GetPrefabScene( sparkPrefab )?.Clone( new CloneConfig
			{
				Parent = GameObject,
				Transform = global::Transform.Zero,
				StartEnabled = false,
			} );
		}

		// Re-enable collision when ship is created (respawn)
		foreach ( var col in Components.GetAll<Collider>( FindMode.InSelf ) )
			col.Enabled = true;
	}

	private void DestroyShip()
	{
		_wallSparkGo?.Destroy();
		_wallSparkGo = null;
		_shipGo?.Destroy();
		_shipGo = null;
		ModelRenderer = null;

		// Disable collision — no ship, no hitbox
		foreach ( var col in Components.GetAll<Collider>( FindMode.InSelf ) )
			col.Enabled = false;
	}

	private void CreateShipSkinComponent()
	{
		var skin = Components.GetOrCreate<ShipSkinComponent>();
		skin.Skin = -1;
	}

	// -------------------------------------------------------------------------
	// Sounds
	// -------------------------------------------------------------------------

	private const float SoundHearingRange = 3500f;

	/// <summary>
	/// Manages 3D sounds for ships other than the local player.
	/// Only creates handles when the ship is within hearing range; stops them when it leaves.
	/// For the local player's own pawn, the existing SoundSetup/UpdateSounds path handles everything.
	/// </summary>
	private void UpdateNearbySounds()
	{
		var listener = Local;
		if ( listener == null || listener == this ) return; // local player handled elsewhere

		var dist = WorldPosition.Distance( listener.WorldPosition );

		if ( dist > SoundHearingRange )
		{
			// Out of range — release handles
			if ( _engineSound != null )
			{
				StopAllSounds();
			}
			return;
		}

		var pos = WorldPosition;

		// Create handles lazily when the ship enters range
		if ( _engineSound == null && Data != null )
		{
			_engineSound    = Sound.Play( "jetship_01", pos );
			if ( !string.IsNullOrEmpty( Data.BoostSoundLoop ) )
				_boostLoopSound = Sound.Play( Data.BoostSoundLoop, pos );
		}

		// Volume falls off with distance
		float proximity = 1f - (dist / SoundHearingRange);

		if ( _engineSound != null )
		{
			_engineSound.Position = pos;
			_engineSound.Pitch    = Speed / 11.5f;
			_engineSound.Volume   = 0.4f * proximity;
		}

		var wantsBoost = IsBot ? BotWantsBoost : BoostCoolDown > 0f && Speed > IdleSpeed * 1.3f;
		if ( _boostLoopSound != null )
		{
			_boostLoopSound.Position = pos;
			_boostLoopSound.Volume   = wantsBoost ? 1.5f * proximity : 0f;
			_boostLoopSound.Pitch    = 0.85f;
		}
	}

	private void SoundSetup()
	{
		StopAllSounds();
		_engineSound    = Sound.Play( "jetship_01", WorldPosition );
		if ( !string.IsNullOrEmpty( Data?.BoostSoundLoop ) )
			_boostLoopSound = Sound.Play( Data.BoostSoundLoop, WorldPosition );
		_wallGrindSound = Sound.Play( "metalgriding", WorldPosition );
		if ( _wallGrindSound != null ) _wallGrindSound.Volume = 0;
	}

	private void UpdateSounds( bool hitWall )
	{
		if ( !IsAlive ) return;

		var pos = WorldPosition;

		var boostActive = Input.Down( "run" ) && BoostCoolDown != 0;
		if ( _boostLoopSound != null )
		{
			_boostLoopSound.Position = pos;
			_boostLoopSound.Volume = boostActive ? 2.0f : 0f;
			_boostLoopSound.Pitch = 0.85f;
		}

		if ( _wallGrindSound != null )
		{
			_wallGrindSound.Position = pos;
			_wallGrindSound.Volume = hitWall ? 0.75f : 0f;
		}

		if ( _engineSound != null )
		{
			_engineSound.Position = pos;
			_engineSound.Pitch = Speed / 11.5f;
			_engineSound.Volume = Health > 0 ? 0.5f : 0f;
		}
	}

	private void StopAllSounds()
	{
		_engineSound?.Stop();
		_boostLoopSound?.Stop();
		_wallGrindSound?.Stop();
		_engineSound    = null;
		_boostLoopSound = null;
		_wallGrindSound = null;
	}

	// -------------------------------------------------------------------------
	// Damage
	// -------------------------------------------------------------------------

	public void TakeDamage( float damage, PlayerPawn attacker )
	{
		if ( !PilotGame.CanScoreAndDamage() ) return;
		if ( !IsAlive ) return;

		// Notify the victim of the incoming damage direction (skip bots — they have no HUD)
		if ( attacker != null && attacker != this && !IsBot && Network.Owner != null )
		{
			using ( Rpc.FilterInclude( Network.Owner ) )
				NotifyDamageDirection( attacker.WorldPosition );
		}

		// In instagib any weapon hit is fatal — bypass all shield/health math (wall/environment damage excluded)
		if ( PilotGame.Gamemode == FPGameMode.Instagib && damage > 0f && attacker != null )
		{
			_timeSinceLastDamage = 0f;
			if ( attacker != null && attacker != this && !attacker.IsBot )
			{
				using ( Rpc.FilterInclude( attacker.Network.Owner ) )
					NotifyAttackerHit();
			}
			Health = 0f;
			OnKilled( attacker );
			return;
		}

		_timeSinceLastDamage = 0f;

		if ( attacker != null && attacker != this && !attacker.IsBot )
		{
			// Notify attacker of hit
			using ( Rpc.FilterInclude( attacker.Network.Owner ) )
				NotifyAttackerHit();
		}

		// Shield absorbs damage first
		if ( Shield > 0f && damage > 0f )
		{
			Shield -= damage;
			if ( Shield <= 0f )
			{
				Shield = 0f;
				if ( attacker != null && attacker != this )
				{
					attacker.AddStat( AchievementKeys.ShieldBreaks );
					attacker._sessionShieldBreaks++;
					if ( attacker._sessionShieldBreaks >= 50 )
						attacker.UnlockAchievement( AchievementKeys.ShieldBreaker );
				}
				// TODO: shield break particle
				// TODO: play shield warning sound to owner
			}
		}
		else if ( Shield <= 0f && Health > 0f && damage > 0f )
		{
			Health -= damage;
			if ( attacker != null && attacker != this )
			{
				var hullDamage = Math.Max( 1, (int)MathF.Round( damage ) );
				attacker.AddStat( AchievementKeys.HullDamage, hullDamage );
				attacker._sessionHullDamage += damage;
				if ( attacker._sessionHullDamage >= 10000f )
					attacker.UnlockAchievement( AchievementKeys.HullBreach );
			}
			if ( Health <= 0f )
			{
				Health = 0f;
				OnKilled( attacker );
			}
		}
	}

	[Rpc.Broadcast]
	private void NotifyAttackerHit()
	{
		Sound.Play( "ui.hitmarker" );
		HitMarker.Current?.OnHit();
	}

	[Rpc.Broadcast]
	private void NotifyDamageDirection( Vector3 attackerWorldPos )
	{
		// Guard: only show on the client who owns this pawn
		if ( PlayerPawn.Local != this ) return;
		DamageIndicator.Current?.AddHit( attackerWorldPos );
	}

	private void OnKilled( PlayerPawn attacker )
	{
		if ( !PilotGame.CanScoreAndDamage() ) return;
		IsAlive = false;

		// Spawn ragdoll on all clients (must come BEFORE DestroyShip — it reads ModelRenderer)
		CreateDeath();

		DestroyShip();

		// Notify kill — always show in feed
		bool attackerIsBot = attacker?.IsBot ?? false;
		bool showInFeed    = true;

		string attackerName = attackerIsBot ? (attacker?.BotName ?? "Bot") : (attacker?.Network.Owner?.DisplayName ?? "World");
		string victimName   = IsBot ? (BotName ?? "Bot") : (Network.Owner?.DisplayName ?? "Bot");

		if ( attacker != null && attacker != this )
		{
			attacker.Kills++;
			Deaths++;

			int points = IsBot ? 1 : 10;
			attacker.Score += points;

			attacker.AddStat( "kills" );
			attacker.AddStat( "score", points );
			attacker.AddStat( "xp", points );
			attacker.OnScoredKill();
			AddStat( "deaths" );
			AddStat( "xp", -2 );

			if ( showInFeed )
				PilotGame.BroadcastKillMessage( attackerName, victimName, "eliminated" );
		}
		else
		{
			Deaths++;
			AddStat( "deaths" );
			AddStat( "xp", -2 );

			if ( showInFeed )
				PilotGame.BroadcastKillMessage( "", victimName, "suicide" );
		}

		if ( !IsProxy )
		{
			StopAllSounds();
		}

		if ( !IsProxy && !IsBot )
		{
			ShipListHint.Current?.ShowAttacker( (attacker == null || attacker == this) ? "Suicide" : $"{attackerName} killed you" );
			_deathScreenActive   = true;
			_deathScreenHideTime = 3f;
			// Point death cam at killer (or null = stay put)
			DeathCamTarget = (attacker != null && attacker != this) ? attacker : null;
			FlightCamera?.OnDied( WorldPosition );
		}

		// Schedule respawn — bots are managed by BotSpawner instead
		if ( Networking.IsHost && !IsBot )
		{
			_respawnTime    = 3f;
			_pendingRespawn = true;
		}
	}

	[Rpc.Owner]
	private void PlaySoundOnOwner( string eventName )
	{
		Sound.Play( eventName );
	}

	/// <summary>Called on the owning client to record a stat to s&amp;box services.</summary>
	[Rpc.Broadcast]
	private void RpcAddStat( string identifier, int amount = 1 )
	{
		Sandbox.Services.Stats.Increment( identifier, amount );
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	private void RpcUnlockAchievement( string identifier )
	{
		Sandbox.Services.Achievements.Unlock( identifier );
	}

	/// <summary>Host-only: increment a persistent stat for this player. Skips bots and cheat mode.</summary>
	public void AddStat( string identifier, int amount = 1 )
	{
		if ( !Networking.IsHost ) return;
		if ( IsBot ) return;
		if ( Application.CheatsEnabled ) return;

		var owner = Network.Owner;
		if ( owner == null ) return;

		using ( Rpc.FilterInclude( owner ) )
		{
			RpcAddStat( identifier, amount );
		}
	}

	/// <summary>Host-only: unlock an achievement for this player owner.</summary>
	public void UnlockAchievement( string identifier )
	{
		if ( !Networking.IsHost ) return;
		if ( IsBot ) return;
		if ( Application.CheatsEnabled ) return;
		if ( string.IsNullOrWhiteSpace( identifier ) ) return;

		var owner = Network.Owner;
		if ( owner == null ) return;

		using ( Rpc.FilterInclude( owner ) )
		{
			RpcUnlockAchievement( identifier );
		}
	}

	public void TrackBoostUsage( float seconds )
	{
		if ( !Networking.IsHost ) return;
		if ( IsBot ) return;
		if ( seconds <= 0f ) return;

		_sessionBoostSeconds += seconds;
		_boostStatAccumulator += seconds;

		if ( _boostStatAccumulator >= 1f )
		{
			var wholeSeconds = Math.Max( 1, (int)_boostStatAccumulator );
			AddStat( AchievementKeys.BoostSeconds, wholeSeconds );
			_boostStatAccumulator -= wholeSeconds;
		}

		if ( _sessionBoostSeconds >= 300f )
			UnlockAchievement( AchievementKeys.Afterburner );
	}

	public void OnMatchCompleted( bool wonMatch )
	{
		if ( !Networking.IsHost ) return;
		if ( IsBot ) return;
		if ( !HasSelectedShip ) return;

		AddStat( AchievementKeys.MatchesCompleted );
		UnlockAchievement( AchievementKeys.FirstFlight );
		_sessionMatchesCompleted++;
		if ( _sessionMatchesCompleted >= 100 )
			UnlockAchievement( AchievementKeys.VeteranPilot );

		if ( !wonMatch ) return;

		AddStat( AchievementKeys.WinsTotal );

		if ( Deaths <= 0 )
			UnlockAchievement( AchievementKeys.Unbreakable );

		if ( PilotGame.Gamemode == FPGameMode.Instagib )
			UnlockAchievement( AchievementKeys.InstaKing );

		var shipKey = $"fp4_wins_ship_{SanitizeStatToken( Data?.ResourcePath )}";
		AddStat( shipKey );

		if ( _sessionWonShips.Add( shipKey ) && _sessionWonShips.Count >= 3 )
			UnlockAchievement( AchievementKeys.ShipCollector );

		var shipWins = _sessionShipWinCounts.TryGetValue( shipKey, out var existing ) ? existing + 1 : 1;
		_sessionShipWinCounts[shipKey] = shipWins;
		if ( shipWins >= 5 )
			UnlockAchievement( AchievementKeys.Loyalist );
	}

	private void OnScoredKill()
	{
		UnlockAchievement( AchievementKeys.WeaponsHot );

		if ( Kills >= 5 )
			UnlockAchievement( AchievementKeys.AcePilot );

		if ( Kills >= 10 )
			UnlockAchievement( AchievementKeys.TopGun );

		if ( Health <= 15f )
			UnlockAchievement( AchievementKeys.CloseCall );

		if ( BreakLean > 0.35f )
			UnlockAchievement( AchievementKeys.DriftMaster );

		if ( PilotGame.Gamemode == FPGameMode.Instagib )
		{
			AddStat( AchievementKeys.InstagibKills );
			_sessionInstagibKills++;
			if ( _sessionInstagibKills >= 25 )
				UnlockAchievement( AchievementKeys.RailScholar );
		}
	}

	private static string SanitizeStatToken( string value )
	{
		if ( string.IsNullOrWhiteSpace( value ) )
			return "unknown";

		var chars = value.ToLowerInvariant().Select( c => char.IsLetterOrDigit( c ) ? c : '_' ).ToArray();
		return new string( chars ).Trim( '_' );
	}

	/// <summary>Called by the owning client to request suicide.</summary>
	[Rpc.Host]
	public void RequestSuicide()
	{
		if ( Network.Owner != Rpc.Caller ) return;
		TakeDamage( 1000f, null );
	}

	/// <summary>Called by the owning client when they hit a wall — applies damage on the host.</summary>
	[Rpc.Host]
	public void RequestWallDamage( float damage )
	{
		if ( Network.Owner != Rpc.Caller ) return;
		TakeDamage( damage, null );
	}

	// -------------------------------------------------------------------------
	// Debug
	// -------------------------------------------------------------------------

	private void DebugText()
	{
		DebugOverlay.ScreenText( new Vector2( 40, 40 ), $"Ship: {Data.ShipName}" );
		DebugOverlay.ScreenText( new Vector2( 40, 60 ), $"Speed: {Speed:F1}" );
		DebugOverlay.ScreenText( new Vector2( 40, 80 ), $"Velocity: {Velocity}" );
		DebugOverlay.ScreenText( new Vector2( 40, 100 ), $"Health: {Health:F0} / Shield: {Shield:F0}" );
		DebugOverlay.ScreenText( new Vector2( 40, 120 ), $"Boost: {BoostCoolDown:F0} / {BoostAmount:F0}" );
		DebugOverlay.ScreenText( new Vector2( 40, 140 ), $"Position: {WorldPosition}" );
	}

	// -------------------------------------------------------------------------
	// Out-of-bounds timer
	// -------------------------------------------------------------------------

	private bool _resetTimer = false;
	private TimeSince _timeUntilReset;

	public void SetOutOfBounds( bool outOfBounds )
	{
		_resetTimer = outOfBounds;
		if ( outOfBounds )
			_timeUntilReset = 0;
	}

	// -------------------------------------------------------------------------
	// FlightCamera reference (local only)
	// -------------------------------------------------------------------------

	public FlightCamera FlightCamera { get; private set; }

	// -------------------------------------------------------------------------
	// Aim Ray
	// -------------------------------------------------------------------------

	public Ray AimRay => new Ray( WorldPosition, WorldRotation.Forward );

	// -------------------------------------------------------------------------
	// Projectile simulator
	// -------------------------------------------------------------------------

	public ProjectileSimulator ProjectileSimulator { get; private set; }

	// -------------------------------------------------------------------------
	// INetworkListener
	// -------------------------------------------------------------------------

	public void OnActive( Connection channel ) { }
	public void OnDisconnected( Connection channel ) { }
}