PlayerLoadout.cs
using Sandbox;
using SWB.Base;
using SWB.Player;
using SWB.Shared;
using System;
using System.Collections.Generic;
using System.Linq;

// Which hand a bought weapon goes in. Vendor stock entries carry one of
// these so the shop knows which column to list them under, and so buying
// a second rifle replaces your rifle rather than stacking up.
public enum LoadoutSlot
{
	Primary,
	Secondary
}

// Per-player cash and equipped weapons - the risk/reward core. Buy gear at
// the Hideout vendor, keep it if you extract, lose it if you go down.
//
// Cash lives here rather than in its own wallet component because the two
// are always read and written together (a purchase spends cash AND equips
// a gun), and one component is one thing to wire onto the player prefab
// instead of two.
//
// Put this on the player prefab, alongside PlayerBase.
public sealed class PlayerLoadout : Component, IDamageMitigator
{
	// The sidearm, always given, never lost - so a broke player who just
	// got wiped still has something to shoot with. The string is a weapon
	// ClassName as registered in SWB's WeaponRegistry ("swb_colt",
	// "swb_scarh", ...), not a prefab path.
	//
	// It lives in the SECONDARY slot on purpose. As a default primary it
	// got replaced by the first rifle anyone bought, which meant players
	// were permanently carrying exactly one gun - nothing to switch to,
	// and no reason for a weapon-swap key to exist. As a sidearm it stays
	// put, and the only thing that displaces it is buying the revolver.
	[Property]
	public string DefaultSecondary { get; set; } = "swb_colt";

	[Property]
	public int StartingCash { get; set; } = 2000;

	// How much reserve ammo each equipped weapon spawns with. Reserve is
	// the pool reloads pull from, separate from what's in the magazine.
	[Property]
	public int ReserveAmmo { get; set; } = 360;

	// [Sync] replicates these to every other client, with the owning
	// client as the authority - so other players' HUDs can read them, but
	// only the owner's writes actually stick. That's why AddCash below is
	// an RPC rather than something any machine can just call.
	//
	// Primary/Secondary hold weapon ClassNames; null means "nothing
	// bought", which ApplyToInventory reads as "fall back to default".
	[Sync] public int Cash { get; set; }
	[Sync] public string Primary { get; set; }
	[Sync] public string Secondary { get; set; }

	// --- Armour -----------------------------------------------------------
	//
	// Bought at the vendor, worn until it's shot through or you go down.
	// Unlike a weapon it has no slot and no WeaponRegistry entry.
	//
	// It has its own health pool, separate from the player's. Every hit
	// wears some of it away (ArmorWearFraction of the hit), and while
	// there's any left the damage reduction applies. At zero the vest is
	// gone and has to be bought again.
	//
	// A float rather than an int because the wear per hit is small - 10%
	// of an 8 damage shot is 0.8 - and rounding each one to a whole number
	// would either make the vest last forever (round down to 0) or wear
	// out twice as fast as it should (round up to 1). The HUD rounds it
	// for display; the maths keeps the fractions.
	//
	// [Sync] for the same reason as the rest: other clients read it to
	// display state, and the owner is the authority. It's written inside
	// TakeDamage, which SWB only runs on the owner (it returns early on
	// proxies), so the write always lands on the machine that owns it.
	[Sync] public float ArmorHealth { get; set; }

	// "Wearing a vest" is now just "the vest has anything left". Kept as a
	// property so the vendor and anything else that only cares whether you
	// have armour doesn't need to know about the health pool at all.
	public bool HasArmor => ArmorHealth > 0f;

	// What a freshly bought vest starts at.
	[Property]
	public float MaxArmorHealth { get; set; } = 50f;

	// How much of each hit the vest itself soaks up as wear, as a fraction
	// of the FULL incoming damage (before the reduction is applied). 0.1
	// on a 50 point vest means it breaks after 500 damage has been thrown
	// at you - it takes that much raw fire to wear it out, however much of
	// it actually reached your health.
	[Property]
	public float ArmorWearFraction { get; set; } = 0.1f;

	// Fraction of incoming damage armour absorbs. 0.35 means you take 65%
	// of what you otherwise would - roughly one extra hit in three.
	//
	// Applied AFTER SWB's headshot doubling, so it protects against
	// headshots in proportion rather than cancelling them out.
	[Property]
	public float ArmorDamageReduction { get; set; } = 0.35f;

	/// <summary>What a vest costs at the vendor. Read by Vendor stock entries.</summary>
	[Property]
	public int ArmorCost { get; set; } = 800;

	// --- Investments ------------------------------------------------------
	//
	// Money parked at the investment table in the hideout. It pays out a
	// share of itself after every job, won or lost.
	//
	// Deliberately NOT lost on death, unlike weapons and armour. That's the
	// whole point of it: gear is the risky place to put your money and this
	// is the safe one, so a crew that keeps getting wiped still has an
	// income and can climb back out instead of being stuck at zero.
	[Sync] public int Invested { get; set; }

	// What one deposit costs. Everything about the table works in these
	// units, so the payout maths can't produce fractions of a dollar.
	[Property]
	public int InvestmentIncrement { get; set; } = 100;

	// Paid per increment, per job. 20 on a 100 deposit is a 20% return a
	// round - it pays for itself after five jobs and is pure profit after,
	// which is a horizon a single sitting can actually reach.
	[Property]
	public int InvestmentReturnPerIncrement { get; set; } = 20;

	/// <summary>What the table will pay after the next job.</summary>
	public int DividendPerJob => Invested / MathF.Max( InvestmentIncrement, 1 ).FloorToInt()
		* InvestmentReturnPerIncrement;

	// Owner-side, called by the investment menu.
	public bool TryInvest()
	{
		if ( !CanAfford( InvestmentIncrement ) )
			return false;

		Cash -= InvestmentIncrement;
		Invested += InvestmentIncrement;
		Save();

		return true;
	}

	// Routed to the owner for the same reason AddCash is: Cash and Invested
	// are [Sync]'d with the owner as the authority, so a write anywhere else
	// is local-only and gets overwritten. MissionTimer calls this on the
	// host once per player when a job ends.
	[Rpc.Owner]
	public void PayDividend()
	{
		var payout = DividendPerJob;

		if ( payout <= 0 )
			return;

		Cash += payout;
		Save();

		Log.Info( $"{GameObject.Name} earned ${payout} from ${Invested} invested (total ${Cash})." );
	}

	// IDamageMitigator. Called from PlayerBase.TakeDamage before the health
	// subtraction - see IDamageMitigator for why it can't be done later.
	public float Mitigate( float damage )
	{
		if ( !HasArmor )
			return damage;

		// Clamped so a silly value in the inspector can't make a player
		// immortal (1.0) or amplify incoming damage (negative).
		var reduction = MathX.Clamp( ArmorDamageReduction, 0f, 0.95f );

		// Wear is worked out from the damage as it ARRIVED, not from what
		// gets through. The vest is what's being hit; how much it saved
		// you doesn't change how hard it was hit.
		var wear = damage * MathX.Clamp( ArmorWearFraction, 0f, 1f );

		ArmorHealth = MathF.Max( ArmorHealth - wear, 0f );

		// The hit that breaks the vest is still reduced - the vest was
		// there when it landed. It's the NEXT hit that comes in full.
		if ( !HasArmor )
		{
			// Written to disk straight away: a broken vest is the one armour
			// change that has to survive a crash or quit, or you'd get it
			// back on the next launch.
			Save();
			Log.Info( $"{GameObject.Name}'s armour has been shot through." );
		}
		else
		{
			// Every other hit only updates the in-memory copy. That's what
			// the next scene reads back from, so the value carries from the
			// job to the hideout correctly - see SaveToCache for why a disk
			// write per bullet isn't worth it.
			SaveToCache();
		}

		return damage * (1f - reduction);
	}

	// Where progress is written. FileSystem.Data is this game's own
	// per-machine storage folder, so the file survives quitting.
	//
	// Not Game.Cookies, which is the other obvious candidate: cookies
	// expire a month after they're set, and a save file that silently
	// evaporates is worse than no save file.
	const string SaveFile = "loadouts.json";

	// In-memory mirror of the save file. It has two jobs:
	//
	//  - Loadouts have to survive the Hideout -> mission scene change, and
	//    GameObjects don't; loading a scene destroys everything in the old
	//    one, this component included. A static lives on the type, so it
	//    outlives the swap.
	//  - It saves re-reading the file on every lookup.
	//
	// Keyed by SteamId so progress follows the person rather than their
	// spawned pawn, and so two accounts sharing a machine don't collide.
	static Dictionary<string, SavedLoadout> _cache;

	// Plain snapshot of what's worth keeping. Deliberately not the
	// component itself - we only want the data, not a reference to a
	// GameObject that's about to be destroyed.
	//
	// Public class with public PROPERTIES because this gets JSON
	// serialized: System.Text.Json ignores public fields by default and
	// can't reach private types, so a private struct of fields would
	// quietly save as {} every time.
	public class SavedLoadout
	{
		public int Cash { get; set; }
		public string Primary { get; set; }
		public string Secondary { get; set; }

		// Named Armor rather than HasArmor so the save file reads as a
		// list of what you own. Older save files simply lack the field and
		// deserialize to false, which is the correct default.
		//
		// Still written alongside ArmorHealth, and only really read for
		// saves made before the vest had health - see Load.
		public bool Armor { get; set; }

		// How much of the vest is left. Missing from older saves, where it
		// deserializes to 0.
		public float ArmorHealth { get; set; }

		public int Invested { get; set; }
	}

	PlayerBase _player;

	// Last tick's alive state, so OnUpdate can spot the moment it flips
	// rather than just knowing the current value. See OnUpdate.
	bool _wasAlive = false;

	// OnAwake runs once when the component is created, before OnStart and
	// before any updates - the right place to cache references to things
	// on the same GameObject.
	protected override void OnAwake()
	{
		_player = Components.Get<PlayerBase>();

		if ( _player is null )
			Log.Warning( $"{GameObject.Name}: PlayerLoadout needs a PlayerBase on the same GameObject." );
	}

	protected override void OnStart()
	{
		// IsProxy means "this object belongs to somebody else and I'm just
		// displaying their copy". Only the owner restores their own saved
		// loadout; everyone else receives it through [Sync].
		if ( IsProxy )
			return;

		Load();
	}

	protected override void OnUpdate()
	{
		if ( IsProxy || _player is null )
			return;

		// SWB's InventoryDisplay equips whatever slot key you press (1-9),
		// which would let you draw a weapon in the hideout despite
		// ApplyToInventory having deliberately left everything holstered.
		// Rather than modify SWB's HUD, just put it back every frame.
		//
		// Same treatment for a player who's out of the run. SWB respawns
		// the dead two seconds later no matter what, and the dead->alive
		// branch below then hands them their sidearm, drawn - which put
		// arms and a gun on a spectator's screen and let them fire from an
		// invisible body sitting on a spawn point. Holstering every frame
		// covers both, and the 1-9 slot keys can't undo it.
		if ( InSafeArea() || IsOutOfTheRun() )
			HolsterEverything();

		// Both branches below watch for a CHANGE in alive state rather
		// than acting on the current value, because each thing should
		// happen once per transition, not every frame while it's true.
		//
		// This polling approach is used instead of overriding PlayerBase's
		// respawn/death methods, which would mean subclassing the player -
		// and swapping a component's type on a prefab loses every wired
		// reference in the s&box editor. A sibling component that watches
		// state avoids that entirely.

		// Dead -> alive. PlayerBase.Respawn() rebuilds the inventory from
		// scratch (and SWB's demo player hands out every gun in the game
		// while it's at it), so the loadout has to be re-applied
		// afterwards. By the time this reads true, Respawn() has already
		// finished, so there's no race to lose.
		if ( _player.IsAlive && !_wasAlive )
			ApplyToInventory();

		// Alive -> dead. The gear they paid for is gone, and the automatic
		// respawn 2 seconds later will re-apply an empty loadout as
		// default kit.
		//
		// Players who escaped never hit this: EscapeZone disables their
		// GameObject, and a disabled object stops updating, so this method
		// isn't running to notice anything. That's precisely why escaping
		// preserves your loadout.
		if ( !_player.IsAlive && _wasAlive )
			LoseLoadout();

		_wasAlive = _player.IsAlive;
	}

	public bool CanAfford( int cost ) => Cash >= cost;

	// Called by the vendor menu when a line is clicked. Returns false and
	// changes nothing if the purchase isn't valid, so the UI can just
	// check the result instead of pre-validating everything itself.
	public bool TryBuy( string className, int cost, LoadoutSlot slot )
	{
		if ( string.IsNullOrWhiteSpace( className ) || !CanAfford( cost ) )
			return false;

		// Verify the gun actually exists before taking any money -
		// otherwise a typo'd ClassName in the vendor's stock list would
		// charge the player for nothing.
		if ( WeaponRegistry.Instance?.Get( className ) is null )
		{
			Log.Warning( $"PlayerLoadout: '{className}' isn't in the WeaponRegistry." );
			return false;
		}

		Cash -= cost;

		// One weapon per slot - buying a new primary replaces the old one
		// rather than accumulating. Keeps loadout selection quick, which
		// is the design goal; there's deliberately no deep inventory here.
		if ( slot == LoadoutSlot.Primary )
			Primary = className;
		else
			Secondary = className;

		Save();

		// Equip immediately so the player can see what they bought in the
		// Hideout, rather than waiting until the next mission spawn.
		ApplyToInventory();
		return true;
	}

	// Armour has no slot and no weapon behind it, so it gets its own buy
	// path rather than being forced through TryBuy's ClassName lookup.
	//
	// Buying a second vest does nothing but cost money, so it refuses when
	// you already have one - the vendor greys the line out for the same
	// reason, this is the guard behind it.
	public bool TryBuyArmor( int cost )
	{
		if ( HasArmor || !CanAfford( cost ) )
			return false;

		Cash -= cost;
		ArmorHealth = MaxArmorHealth;
		Save();

		return true;
	}

	// [Rpc.Owner] makes this run on the machine that owns this player,
	// no matter who calls it. Needed because Cash is [Sync] - a write on
	// any other machine is local-only and gets overwritten by the owner's
	// value - and because Save() has to land in the owning player's own
	// process for the static store to have it after the scene change.
	//
	// An EscapeZone trigger can fire on a machine that doesn't own the
	// player who walked in, so the payout has to be routed rather than
	// applied wherever it happened to be detected.
	[Rpc.Owner]
	public void AddCash( int amount )
	{
		Cash += amount;
		Save();

		Log.Info( $"{GameObject.Name} banked ${amount} (total ${Cash})." );
	}

	// Called when this player is eliminated: the gear they paid for is
	// gone and they drop back to default kit. Cash they've already banked
	// is untouched - it's the loadout that carries the risk, so a wipe
	// sets you back but never traps you with nothing to spend.
	public void LoseLoadout()
	{
		Primary = null;
		Secondary = null;

		// Armour goes with the rest. It's part of what you were carrying,
		// so it carries the same risk - buy it, and it's only yours for as
		// long as you keep getting out alive.
		ArmorHealth = 0f;

		Save();
	}

	// Wipes the inventory and re-gives exactly what's equipped, so the
	// player ends up holding their loadout and nothing else.
	public void ApplyToInventory()
	{
		if ( IsProxy || _player?.Inventory is null )
			return;

		// Clear() destroys the existing weapon GameObjects. This is what
		// undoes SWB's demo player handing out all six guns on respawn.
		_player.Inventory.Clear();

		// In the hideout you still carry your gear - you just can't use
		// it. Handing the weapons over without making any of them active
		// means they show up in the inventory HUD (so you can see what
		// you've bought) while Inventory.Add calls OnCarryStop on each,
		// which disables the weapon GameObject outright. A disabled weapon
		// doesn't run its firing loop, so there's nothing to suppress.
		var safe = InSafeArea();

		// The primary is purely what they've bought - there's no fallback
		// for it. Someone who hasn't bought one, or who just lost theirs,
		// goes out on the sidearm alone, which is what makes buying a
		// proper gun feel like it's worth the money.
		var hasPrimary = !string.IsNullOrWhiteSpace( Primary );

		if ( hasPrimary )
			Give( Primary, makeActive: !safe );

		// The sidearm slot is never empty: the bought secondary if there
		// is one, otherwise the default pistol. This is the "never leave
		// anyone unarmed" guarantee that used to sit on the primary.
		var secondary = string.IsNullOrWhiteSpace( Secondary ) ? DefaultSecondary : Secondary;

		// Drawn only when there's no primary to draw instead - you come
		// out of the hideout holding your best gun, not your pistol.
		Give( secondary, makeActive: !safe && !hasPrimary );
	}

	// Puts whatever's in hand away. Mirrors what Inventory.SetActive does
	// to the outgoing weapon: OnCarryStop disables the weapon GameObject,
	// which stops it running its firing loop at all.
	void HolsterEverything()
	{
		var inventory = _player.Inventory;

		if ( inventory?.Active is null )
			return;

		if ( inventory.Active.Components.TryGet<IInventoryItem>( out var item ) )
			item.OnCarryStop();

		inventory.Active = null;
		inventory.ActiveItem = null;
	}

	// Dead or extracted, and spectating - see MissionPlayerState.
	bool IsOutOfTheRun()
	{
		return Components.Get<MissionPlayerState>()?.IsOut ?? false;
	}

	// A scene with no MissionTimer isn't a job, so it's somewhere safe -
	// currently just the hideout. Keyed off the timer rather than a scene
	// name or a flag component so any mission map you add later works
	// without extra wiring, and the hideout needs no marker on it.
	static bool InSafeArea()
	{
		return Game.ActiveScene?.GetAllComponents<MissionTimer>().Any() != true;
	}

	// makeActive decides whether this becomes the weapon in their hands
	// right now, versus sitting in the inventory to be switched to.
	void Give( string className, bool makeActive )
	{
		// The registry holds one disabled master copy of each weapon,
		// built at scene start from the WeaponPrefabs list.
		var weapon = WeaponRegistry.Instance?.Get( className );

		if ( weapon is null )
		{
			Log.Warning( $"PlayerLoadout: '{className}' isn't in the WeaponRegistry." );
			return;
		}

		// AddClone (not Add) because the registry entry is that shared
		// master copy - every player needs their own instance of it. This
		// also handles network-spawning the clone under this player's
		// ownership.
		_player.Inventory.AddClone( weapon.GameObject, makeActive );

		// Ammo is tracked per ammo TYPE on the player, not per weapon, so
		// two guns sharing a caliber share the same reserve pool.
		_player.SetAmmo( weapon.Primary.AmmoType, ReserveAmmo );
	}

	// Restore whatever this player was carrying, whether that's from
	// earlier this session or from a previous run of the game. First time
	// we've seen them, they get their starting stake instead.
	void Load()
	{
		if ( Cache().TryGetValue( SaveKey(), out var saved ) )
		{
			Cash = saved.Cash;
			Primary = saved.Primary;
			Secondary = saved.Secondary;
			Invested = saved.Invested;

			// A save from before armour had health says Armor = true with
			// no ArmorHealth at all. Those players paid for a whole vest, so
			// they get a whole one rather than having it vanish on them.
			ArmorHealth = saved.ArmorHealth > 0f
				? saved.ArmorHealth
				: saved.Armor ? MaxArmorHealth : 0f;
		}
		else
		{
			Cash = StartingCash;
		}
	}

	// Called after every change rather than at some "end of mission"
	// moment, because there isn't a reliable one - players can die,
	// escape, or disconnect at any point, and a disabled or destroyed
	// object won't get the chance to save on its way out.
	//
	// Writes are small and infrequent (a purchase or a payout), so going
	// to disk each time is cheaper than trying to find a safe moment to
	// flush and getting it wrong.
	void Save()
	{
		SaveToCache();

		try
		{
			FileSystem.Data.WriteJson( SaveFile, _cache );
		}
		catch ( Exception e )
		{
			// A failed write shouldn't take the run down with it - the
			// in-memory cache still has the right values, so play
			// continues and only persistence is lost.
			Log.Warning( $"PlayerLoadout: couldn't write {SaveFile} - {e.Message}" );
		}
	}

	// Updates the in-memory copy only, without touching disk.
	//
	// Exists for armour wear, which changes on every bullet that hits you.
	// A disk write per hit during a firefight is exactly the wrong moment
	// to be doing file I/O, and it buys nothing: the in-memory cache is
	// what the next scene loads from, and something else always writes the
	// file before the run is over - LoseLoadout if you go down, AddCash if
	// you escape, the dividend if you've invested, and Save() itself the
	// moment the vest breaks.
	//
	// The only case this loses is quitting the game mid-job, which puts
	// your vest back to where it was when you left the hideout. That's a
	// fair trade for not stalling the frame on every hit.
	void SaveToCache()
	{
		Cache()[SaveKey()] = new SavedLoadout
		{
			Cash = Cash,
			Primary = Primary,
			Secondary = Secondary,
			Armor = HasArmor,
			ArmorHealth = ArmorHealth,
			Invested = Invested
		};
	}

	// Reads the save file once per session, then serves everything from
	// memory.
	static Dictionary<string, SavedLoadout> Cache()
	{
		if ( _cache is not null )
			return _cache;

		// ReadJsonOrDefault rather than ReadJson: a corrupt or
		// hand-edited save should start people fresh rather than throw on
		// every spawn.
		_cache = FileSystem.Data.ReadJsonOrDefault<Dictionary<string, SavedLoadout>>( SaveFile, null )
			?? new Dictionary<string, SavedLoadout>();

		return _cache;
	}

	// SteamId is stable across scene loads and reconnects, which a
	// GameObject Id isn't. The fallback covers anything without a network
	// owner (a local test pawn dropped straight into the scene); it won't
	// survive a scene change, but nothing else about that case would
	// either.
	string SaveKey()
	{
		return Network.Owner?.SteamId.ToString() ?? GameObject.Id.ToString();
	}
}