MissionBoard.cs
using Sandbox;
using SWB.Player;
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;
	}

	// DIAGNOSTIC - one line per job, once per hideout load. Remove once the
	// bank job is confirmed loading from the board.
	//
	// Says exactly what the board is holding for each job's scene: which
	// file it resolved to, how many root objects that copy has, and what
	// payout it reads out of it. The bank job was advertising $0 and not
	// loading, while the scene file on disk (and its compiled copy) were
	// both correct - so the question is what the board actually has in
	// memory, and this answers it without guessing. A bank line showing a
	// handful of objects and no EscapeZone means the board is holding an
	// old copy of the scene from before it was built out.
	protected override void OnStart()
	{
		foreach ( var mission in Missions )
		{
			var scene = mission?.Scene;

			if ( scene is null )
			{
				Log.Warning( $"[board] '{mission?.DisplayName}': NO SCENE - the reference didn't resolve to anything." );
				continue;
			}

			var roots = scene.GameObjects?.Length ?? -1;

			Log.Info( $"[board] '{mission.DisplayName}' -> '{scene.ResourcePath}' " +
				$"(scene id {scene.Id}, {roots} root objects, valid={scene.IsValid}, error={scene.IsError}) " +
				$"reads payout ${mission.BaseReward}" );
		}
	}

	// DIAGNOSTIC - remove with the [board] lines above.
	//
	// Coming back from the bank shows nothing but the skybox and the
	// objective markers. The markers prove the hideout scene and the local
	// player exist; nothing else rendering means whatever camera is drawing
	// the frame isn't looking at the room. This says which camera that is
	// and where everything actually is, instead of guessing.
	//
	// Logged twice: once shortly after arrival, and again after SWB's
	// one-second camera hand-off (PlayerBase.OverlayUIWorkaround) has had
	// time to finish, so a camera that's wrong only at first can be told
	// apart from one that stays wrong.
	TimeSince _sinceArrival = 0;
	int _cameraReportsDone = 0;

	void ReportCameras()
	{
		var due = _cameraReportsDone == 0 ? 0.5f : 3f;

		if ( _cameraReportsDone >= 2 || _sinceArrival < due )
			return;

		_cameraReportsDone++;

		var local = PlayerBase.Local;
		var players = Scene.GetAllComponents<PlayerBase>().Count();

		Log.Info( $"[cams {_cameraReportsDone}] t={_sinceArrival.Relative:0.0}s players={players} " +
			$"local={(local.IsValid() ? $"'{local.GameObject.Name}' at {local.WorldPosition} alive={local.IsAlive} scene={(local.Scene == Scene ? "this" : "OTHER")}" : "NONE")}" );

		foreach ( var cam in Scene.GetAllComponents<CameraComponent>() )
		{
			Log.Info( $"[cams {_cameraReportsDone}]   '{cam.GameObject.Name}' (root '{cam.GameObject.Root.Name}') " +
				$"enabled={cam.Enabled} active={cam.Active} main={cam.IsMainCamera} priority={cam.Priority} at {cam.WorldPosition}" );
		}
	}

	protected override void OnUpdate()
	{
		ReportCameras();

		// 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;

		// NOTE: options.DeleteEverything is deliberately NOT set.
		//
		// It was tried, on the theory that network-spawned objects were
		// surviving the change. It fixed nothing measurable and made
		// Game.ChangeScene itself throw a NullReferenceException, which
		// is far worse than the problem: a scene change that throws
		// half-way leaves exactly the partially-torn-down state we were
		// trying to avoid. Don't re-add it without a reproducing case.


		// Let go of the outgoing scene before asking the engine to drop
		// it. These caches hold a Scene reference and GameObjects inside
		// it, and anything still referencing them keeps the whole old
		// scene graph reachable - which is what made a second job behave
		// differently from the first.
		Crew.Reset();
		PoliceSpawns.Reset();

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