MissionBoard.cs
using Sandbox;
using SWB.Shared;
using System;
using System.Collections.Generic;
using System.Linq;

// One job on the board. Adding a mission later is just adding an entry
// here and pointing it at that mission's scene - no code change.
public sealed class MissionEntry
{
	[Property] public string DisplayName { get; set; } = "Untitled Job";

	// Short pitch shown under the name. Flavour, or a difficulty hint.
	[Property] public string Description { get; set; }

	// The scene this job loads. Each mission scene brings its own
	// MissionSpawner / MissionTimer / EscapeZone setup with it.
	[Property] public SceneFile Scene { get; set; }

	// Read out of the mission scene's own EscapeZone rather than typed in
	// here, so the board can't advertise a figure the map doesn't actually
	// pay. Only read when the board list is rebuilt (on open), so parsing
	// the scene JSON each time is cheaper than keeping a cache correct.
	public int BaseReward => SceneRewardReader.ReadEscapeReward( Scene );

	/// <summary>What this job pays a player who gets out, difficulty included.</summary>
	public int AdvertisedPayout => (int)(BaseReward * PayoutMultiplier);

	// Difficulty lives on the entry rather than being picked separately,
	// so the same scene can be listed more than once at different
	// settings - "Gas Station (Standard)" and "Gas Station (Heavy)" are
	// just two entries pointing at one map. The crew already votes on an
	// entry, so agreeing on difficulty comes free with that.
	[Property] public string DifficultyName { get; set; } = "Standard";

	/// <summary>Scales what escaping pays out. The reason to take a harder job.</summary>
	[Property] public float PayoutMultiplier { get; set; } = 1f;

	/// <summary>Below 1 means cops arrive faster. 0.5 is twice the rate.</summary>
	[Property] public float SpawnIntervalScale { get; set; } = 1f;

	/// <summary>
	/// Multiplies how many cops can be alive at once. 1 is the map's own
	/// setting, 2 is double. 0 means no cops at all, which is handy for
	/// testing the loop without a fight.
	/// </summary>
	[Property] public float MaxCopsMultiplier { get; set; } = 1f;

	/// <summary>Scales cop weapon damage on top of CopBotAI's own DamageScale.</summary>
	[Property] public float CopDamageScale { get; set; } = 1f;
}

// Carries the chosen job's difficulty from the hideout into the mission
// scene. A static because scene loads destroy everything in the old scene,
// exactly like PlayerLoadout's save cache.
//
// Set on every client by MissionBoard.Launch, which runs through a
// broadcast - so each machine has the same values before the map loads.
public static class ActiveMission
{
	public static string DifficultyName { get; private set; } = "Standard";
	public static float PayoutMultiplier { get; private set; } = 1f;
	public static float SpawnIntervalScale { get; private set; } = 1f;
	public static float MaxCopsMultiplier { get; private set; } = 1f;
	public static float CopDamageScale { get; private set; } = 1f;

	public static void SetFrom( MissionEntry entry )
	{
		if ( entry is null )
		{
			Reset();
			return;
		}

		DifficultyName = entry.DifficultyName;

		// Clamped because a zero or negative multiplier anywhere here
		// produces nonsense - free money, or a spawn interval of zero that
		// floods the map in a single frame.
		PayoutMultiplier = MathF.Max( entry.PayoutMultiplier, 0f );
		SpawnIntervalScale = MathF.Max( entry.SpawnIntervalScale, 0.05f );

		// Floored at 0 rather than 1: zero is a legitimate "no cops"
		// setting for testing the loop, and a negative cap would make
		// EffectiveMaxCops negative and stop spawning entirely in a way
		// that looks like a bug.
		MaxCopsMultiplier = MathF.Max( entry.MaxCopsMultiplier, 0f );
		CopDamageScale = MathF.Max( entry.CopDamageScale, 0f );
	}

	public static void Reset()
	{
		DifficultyName = "Standard";
		PayoutMultiplier = 1f;
		SpawnIntervalScale = 1f;
		MaxCopsMultiplier = 1f;
		CopDamageScale = 1f;
	}
}

// The job board in the Hideout. Put this on a GameObject with a trigger
// Collider; walk in and hold Use to open it, hold Use again to close.
// Same interaction shape as the Vendor.
public sealed class MissionBoard : Component, Component.ITriggerListener, IInteractable
{
	[Property]
	public List<MissionEntry> Missions { get; set; } = new();

	[Property]
	public float HoldDuration { get; set; } = 0.4f;

	bool _playerInRange = false;
	float _holdProgress = 0f;
	bool _wasHolding = false;

	public bool IsOpen { get; private set; }

	// Ready-up state, so a job can't start while half the crew is still
	// shopping. Everyone has to pick the SAME job before it launches.
	//
	// Host-authoritative: clients send their pick to the host, the host is
	// the only one that decides when the crew is go and calls Launch.
	// _readyByConnection only ever holds meaningful data on the host.
	readonly Dictionary<Guid, int> _readyByConnection = new();

	// Mirrored to every client for display. The local player's own choice
	// is tracked separately below, since the host doesn't tell each client
	// which specific pick was theirs.
	public int ReadyCount { get; private set; }
	public int CrewSize { get; private set; } = 1;
	public int LocalReadyIndex { get; private set; } = -1;

	// The job the crew is currently voting on, -1 for none. Drives the
	// prompt shown to players who haven't opened the board, so they don't
	// have to walk over to it to find out what's been picked.
	public int PendingMissionIndex { get; private set; } = -1;
	public string ProposedBy { get; private set; }

	public MissionEntry PendingMission =>
		PendingMissionIndex >= 0 && PendingMissionIndex < Missions.Count
			? Missions[PendingMissionIndex]
			: null;

	public bool LocalPlayerIsReady => LocalReadyIndex >= 0;

	// Drives the on-screen prompt - see IInteractable.
	public bool PlayerInRange => _playerInRange;
	public float HoldFraction => HoldDuration <= 0f ? 0f : _holdProgress / HoldDuration;
	public string PromptText => "Hold E to view jobs";
	public bool SuppressPrompt => IsOpen;

	public void OnTriggerEnter( Collider other )
	{
		if ( other.GameObject.Root.Tags.Has( "player" ) )
			_playerInRange = true;
	}

	public void OnTriggerExit( Collider other )
	{
		if ( !other.GameObject.Root.Tags.Has( "player" ) )
			return;

		_playerInRange = false;
		_holdProgress = 0f;
		IsOpen = false;
	}

	protected override void OnUpdate()
	{
		// Checked before the in-range guard on purpose: the whole point of
		// the ready-up prompt is that you can do it from wherever you are in
		// the hideout, without walking back to the board.
		HandleReadyUpKey();

		if ( !_playerInRange )
			return;

		var holding = Input.Down( InputButtonHelper.Use );

		if ( holding )
		{
			_holdProgress += Time.Delta;

			// Toggle once on the hold completing, then wait for release -
			// otherwise holding Use would flap the board open and shut
			// every HoldDuration seconds.
			if ( _holdProgress >= HoldDuration && !_wasHolding )
			{
				_wasHolding = true;
				IsOpen = !IsOpen;
			}
		}
		else
		{
			_holdProgress = 0f;
			_wasHolding = false;
		}
	}

	public void Close()
	{
		IsOpen = false;
	}

	// The ReadyUp key readies you for whatever the crew has already
	// proposed. Does nothing until somebody picks a job, since there'd be
	// nothing to agree to.
	void HandleReadyUpKey()
	{
		if ( PendingMissionIndex < 0 )
			return;

		if ( !Input.Pressed( "ReadyUp" ) )
			return;

		ToggleReady( PendingMissionIndex );
	}

	// Called by MissionBoardMenu when a job is clicked. Toggles this
	// player's readiness for it - clicking the job you're already ready
	// for backs you out, clicking a different one switches your vote.
	public void ToggleReady( int missionIndex )
	{
		LocalReadyIndex = LocalReadyIndex == missionIndex ? -1 : missionIndex;
		SubmitReady( Connection.Local.Id, LocalReadyIndex );
	}

	// Runs on the host no matter who called it, so one machine owns the
	// decision and there's no chance of two clients both launching.
	[Rpc.Host]
	public void SubmitReady( Guid connectionId, int missionIndex )
	{
		if ( missionIndex < 0 )
			_readyByConnection.Remove( connectionId );
		else
			_readyByConnection[connectionId] = missionIndex;

		EvaluateCrew();
	}

	// Host only. Drops anyone who has disconnected, tells everyone where
	// the crew stands, and launches once they all agree.
	void EvaluateCrew()
	{
		if ( !Networking.IsHost )
			return;

		var connections = Connection.All;
		var connected = connections.Select( c => c.Id ).ToHashSet();

		// Someone readied up and then quit - otherwise the crew could
		// never be complete and the job would never start.
		foreach ( var stale in _readyByConnection.Keys.Where( k => !connected.Contains( k ) ).ToList() )
			_readyByConnection.Remove( stale );

		var crewSize = Math.Max( connections.Count, 1 );

		// The job everyone has to agree on is whichever the most people
		// have picked; ties resolve arbitrarily, which is fine because a
		// tie can't be unanimous anyway.
		var leading = _readyByConnection
			.GroupBy( kv => kv.Value )
			.OrderByDescending( g => g.Count() )
			.FirstOrDefault();

		var readyCount = leading?.Count() ?? 0;
		var missionIndex = leading?.Key ?? -1;

		// Whoever's voting for the leading job gets the credit in the
		// prompt, so it reads as a person suggesting something rather than
		// a job appearing out of nowhere.
		var proposer = connections
			.FirstOrDefault( c => _readyByConnection.TryGetValue( c.Id, out var v ) && v == missionIndex );

		BroadcastCrewState( readyCount, crewSize, missionIndex, proposer?.DisplayName ?? "Someone" );

		if ( readyCount >= crewSize && missionIndex >= 0 )
			LaunchBroadcast( missionIndex );
	}

	[Rpc.Broadcast]
	void BroadcastCrewState( int readyCount, int crewSize, int missionIndex, string proposedBy )
	{
		ReadyCount = readyCount;
		CrewSize = crewSize;
		PendingMissionIndex = missionIndex;
		ProposedBy = proposedBy;
	}

	// Broadcast so every client changes scene, not just the host. Whether
	// that actually carries connected clients across is UNTESTED - see the
	// note in Launch.
	[Rpc.Broadcast]
	void LaunchBroadcast( int missionIndex )
	{
		if ( missionIndex < 0 || missionIndex >= Missions.Count )
			return;

		Launch( Missions[missionIndex] );
	}

	// Everything the mission needs lives in its own scene, so launching is
	// just a scene change - the player's cash and loadout survive it
	// because PlayerLoadout keeps them in a static store, not on the
	// GameObject.
	public void Launch( MissionEntry mission )
	{
		if ( mission?.Scene is null )
		{
			Log.Warning( $"MissionBoard: '{mission?.DisplayName}' has no Scene assigned." );
			return;
		}

		IsOpen = false;
		_readyByConnection.Clear();
		LocalReadyIndex = -1;
		PendingMissionIndex = -1;

		// Has to happen before the scene change, and on every client -
		// Launch is called from a broadcast, so each machine records the
		// same difficulty before its map loads.
		ActiveMission.SetFrom( mission );

		var options = new SceneLoadOptions();
		options.SetScene( mission.Scene );
		options.ShowLoadingScreen = true;

		Log.Info( $"Launching job: {mission.DisplayName}" );
		Game.ChangeScene( options );
	}
}