OMRRoundLoadoutDirector.cs
using Sandbox;
using System;
using System.Collections.Generic;

/// <summary>
/// Host-authoritative weapon selection for competitive OMR rounds.
///
/// Responsibilities are deliberately narrow:
/// - CompetitiveWeapons: normal round + pre-match warmup catalog.
/// - HighNoonWeapon: legacy/fallback event-only override for High Noon.
/// - Sandbox remains owned by RoundManager and can expose every weapon.
/// - CompetitiveShuffle uses one shared shuffle bag so every configured weapon
///   appears before the bag is reshuffled, with immediate repeats avoided.
/// - FilteredShuffle builds an independent bag per modular round rule while
///   still selecting from the exact same competitive prefab catalog.
/// - Fixed profiles can force a curated weapon without mutating the pool.
///
/// The director only chooses a prefab. RoundManager still owns inventory,
/// round reset, spawning, ammo restoration and actual modifier gameplay.
/// </summary>
public sealed class OMRRoundLoadoutDirector : Component
{
	[Property, Group( "Competitive" )]
	public List<GameObject> CompetitiveWeapons { get; set; } = new();

	[Property, Group( "Competitive" ), Range( 1, 5 )]
	public int RoundsPerLoadout { get; set; } = 1;

	/// <summary>
	/// Legacy/fallback High Noon reference. Phase 8D prefers the FixedWeapon in
	/// the High Noon OMRRoundRuleProfile, but this stays authored so older scenes
	/// and a missing rule profile still fail safely.
	/// </summary>
	[Property, Group( "Events" )]
	public GameObject HighNoonWeapon { get; set; }

	private readonly List<int> _shuffleBag = new();

	private int _currentCompetitiveIndex = -1;
	private int _standardRoundsOnCurrent = 0;
	private int _lastCompetitiveIndex = -1;

	private readonly Dictionary<RoundModifier, List<int>> _filteredShuffleBags =
		new();

	private readonly Dictionary<RoundModifier, int> _filteredCurrentIndex =
		new();

	private readonly Dictionary<RoundModifier, int> _filteredRoundsOnCurrent =
		new();

	private readonly Dictionary<RoundModifier, int> _filteredLastIndex =
		new();

	private int _cachedRoundNumber = int.MinValue;
	private RoundModifier _cachedModifier = RoundModifier.Standard;
	private GameObject _cachedRoundWeapon;

	public int CompetitiveWeaponCount =>
		CompetitiveWeapons?.Count ?? 0;

	public GameObject CurrentCompetitiveWeapon =>
		GetCompetitiveWeapon( _currentCompetitiveIndex );

	public string CurrentCompetitiveWeaponName =>
		CurrentCompetitiveWeapon is null
			? "NONE"
			: OMRWeaponUtility.GetDisplayName( CurrentCompetitiveWeapon );

	/// <summary>
	/// Clear transient bag/cached-round state at the start of a new match.
	/// Authored weapon lists remain untouched.
	/// </summary>
	public void ResetMatchSequence()
	{
		_shuffleBag.Clear();
		_currentCompetitiveIndex = -1;
		_standardRoundsOnCurrent = 0;
		_lastCompetitiveIndex = -1;

		_filteredShuffleBags.Clear();
		_filteredCurrentIndex.Clear();
		_filteredRoundsOnCurrent.Clear();
		_filteredLastIndex.Clear();

		_cachedRoundNumber = int.MinValue;
		_cachedModifier = RoundModifier.Standard;
		_cachedRoundWeapon = null;
	}

	/// <summary>
	/// Phase 8D entry point. The profile decides whether this round consumes the
	/// shared competitive shuffle, an authored filtered pool, or a fixed weapon.
	/// Re-entering countdown for the same logical round remains idempotent.
	/// </summary>
	public GameObject ResolveRoundWeapon(
		int roundNumber,
		OMRRoundRuleProfile rule
	)
	{
		RoundModifier modifier =
			rule?.Modifier ??
			RoundModifier.Standard;

		if (
			_cachedRoundNumber == roundNumber &&
			_cachedModifier == modifier &&
			_cachedRoundWeapon is not null &&
			_cachedRoundWeapon.IsValid
		)
		{
			return _cachedRoundWeapon;
		}

		GameObject resolved;

		if (
			rule is null ||
			!rule.IsValid
		)
		{
			resolved =
				ResolveLegacyModifierWeapon(
					modifier
				);
		}
		else
		{
			resolved =
				rule.WeaponPolicy switch
				{
					OMRRoundWeaponPolicy.Fixed =>
						ResolveFixedWeapon( rule ),

					OMRRoundWeaponPolicy.FilteredShuffle =>
						ResolveFilteredWeapon( rule ),

					_ =>
						ResolveStandardWeapon()
				};
		}

		_cachedRoundNumber = roundNumber;
		_cachedModifier = modifier;
		_cachedRoundWeapon = resolved;

		return resolved;
	}

	/// <summary>
	/// Compatibility overload for older callers/scenes. Modular RoundManager
	/// resolves a profile first and uses the overload above.
	/// </summary>
	public GameObject ResolveRoundWeapon(
		int roundNumber,
		RoundModifier modifier
	)
	{
		if (
			_cachedRoundNumber == roundNumber &&
			_cachedModifier == modifier &&
			_cachedRoundWeapon is not null &&
			_cachedRoundWeapon.IsValid
		)
		{
			return _cachedRoundWeapon;
		}

		GameObject resolved =
			ResolveLegacyModifierWeapon(
				modifier
			);

		_cachedRoundNumber = roundNumber;
		_cachedModifier = modifier;
		_cachedRoundWeapon = resolved;

		return resolved;
	}

	/// <summary>
	/// Resolve the weapon that would be used by a rule without consuming its
	/// shuffle bag. The host uses this to reveal the actual next loadout before
	/// the BANK / ONE MORE ROUND decision. Initializing a shuffle bag is allowed;
	/// the selected round later consumes the exact entry that was previewed.
	/// </summary>
	public GameObject PreviewRoundWeapon(
		OMRRoundRuleProfile rule
	)
	{
		if (
			rule is null ||
			!rule.IsValid
		)
		{
			return PreviewStandardWeapon();
		}

		return rule.WeaponPolicy switch
		{
			OMRRoundWeaponPolicy.Fixed =>
				rule.ResolveFixedWeaponPrefab(),

			OMRRoundWeaponPolicy.FilteredShuffle =>
				PreviewFilteredWeapon( rule ),

			_ =>
				PreviewStandardWeapon()
		};
	}

	private GameObject PreviewStandardWeapon()
	{
		int holdRounds =
			Math.Max(
				RoundsPerLoadout,
				1
			);

		if (
			IsCompetitiveIndexValid( _currentCompetitiveIndex ) &&
			_standardRoundsOnCurrent < holdRounds
		)
		{
			return GetCompetitiveWeapon(
				_currentCompetitiveIndex
			);
		}

		EnsureShuffleBag();

		for ( int i = _shuffleBag.Count - 1; i >= 0; i-- )
		{
			int index =
				_shuffleBag[i];

			if ( IsCompetitiveIndexValid( index ) )
				return GetCompetitiveWeapon( index );
		}

		return null;
	}

	private GameObject PreviewFilteredWeapon(
		OMRRoundRuleProfile rule
	)
	{
		RoundModifier key =
			rule.Modifier;

		int holdRounds =
			Math.Max(
				RoundsPerLoadout,
				1
			);

		int currentIndex =
			_filteredCurrentIndex.TryGetValue(
				key,
				out int storedCurrent
			)
				? storedCurrent
				: -1;

		int roundsOnCurrent =
			_filteredRoundsOnCurrent.TryGetValue(
				key,
				out int storedRounds
			)
				? storedRounds
				: 0;

		if (
			IsFilteredIndexValid(
				currentIndex,
				rule
			) &&
			roundsOnCurrent < holdRounds
		)
		{
			return GetCompetitiveWeapon(
				currentIndex
			);
		}

		EnsureFilteredShuffleBag(
			rule
		);

		if (
			_filteredShuffleBags.TryGetValue(
				key,
				out List<int> bag
			) &&
			bag is not null
		)
		{
			for ( int i = bag.Count - 1; i >= 0; i-- )
			{
				int index =
					bag[i];

				if (
					IsFilteredIndexValid(
						index,
						rule
					)
				)
				{
					return GetCompetitiveWeapon(
						index
					);
				}
			}
		}

		return PreviewStandardWeapon();
	}

	public GameObject GetCompetitiveWeapon(
		int index
	)
	{
		if (
			CompetitiveWeapons is null ||
			CompetitiveWeapons.Count <= 0 ||
			index < 0 ||
			index >= CompetitiveWeapons.Count
		)
		{
			return null;
		}

		GameObject prefab = CompetitiveWeapons[index];

		return
			prefab is not null &&
			prefab.IsValid
				? prefab
				: null;
	}

	[Button, Title( "Validate Round Loadouts" ), Description( "Validate competitive and event-only weapon assignments." )]
	public void ValidateRoundLoadouts()
	{
		int errors = 0;
		int warnings = 0;

		if (
			CompetitiveWeapons is null ||
			CompetitiveWeapons.Count <= 0
		)
		{
			Log.Error(
				"ROUND LOADOUTS | FAIL | Competitive weapon pool is empty."
			);

			return;
		}

		HashSet<string> ids =
			new(
				StringComparer.OrdinalIgnoreCase
			);

		for (
			int i = 0;
			i < CompetitiveWeapons.Count;
			i++
		)
		{
			GameObject prefab =
				CompetitiveWeapons[i];

			if (
				prefab is null ||
				!prefab.IsValid
			)
			{
				errors++;
				Log.Error(
					$"ROUND LOADOUTS | Competitive slot {i} has no valid prefab."
				);
				continue;
			}

			OneMoreRoundWeapon weapon =
				OMRWeaponUtility.GetWeapon(
					prefab
				);

			if ( weapon is null )
			{
				errors++;
				Log.Error(
					$"ROUND LOADOUTS | '{prefab.Name}' has no OneMoreRoundWeapon component."
				);
				continue;
			}

			string id =
				OMRWeaponUtility.GetWeaponId(
					prefab
				);

			if ( string.IsNullOrWhiteSpace( id ) )
			{
				warnings++;
				Log.Warning(
					$"ROUND LOADOUTS | '{prefab.Name}' has no stable WeaponId."
				);
			}
			else if ( !ids.Add( id ) )
			{
				warnings++;
				Log.Warning(
					$"ROUND LOADOUTS | Duplicate competitive WeaponId '{id}'."
				);
			}
		}

		if (
			HighNoonWeapon is null ||
			!HighNoonWeapon.IsValid
		)
		{
			warnings++;
			Log.Warning(
				"ROUND LOADOUTS | Legacy High Noon fallback weapon is not assigned. A valid fixed High Noon rule profile can still supply it."
			);
		}
		else
		{
			string highNoonId =
				OMRWeaponUtility.GetWeaponId(
					HighNoonWeapon
				);

			if ( ids.Contains( highNoonId ) )
			{
				warnings++;
				Log.Warning(
					$"ROUND LOADOUTS | High Noon weapon '{highNoonId}' is also in the competitive pool."
				);
			}
		}

		string result =
			errors == 0
				? "PASS"
				: "FAIL";

		Log.Info(
			$"ROUND LOADOUTS | {result} | Competitive:{CompetitiveWeapons.Count} | RoundsPerLoadout:{Math.Max( RoundsPerLoadout, 1 )} | Errors:{errors} | Warnings:{warnings}"
		);
	}

	private GameObject ResolveLegacyModifierWeapon(
		RoundModifier modifier
	)
	{
		return
			modifier == RoundModifier.HighNoon
				? ResolveHighNoonWeapon()
				: ResolveStandardWeapon();
	}

	private GameObject ResolveFixedWeapon(
		OMRRoundRuleProfile rule
	)
	{
		GameObject fixedWeapon =
			rule.ResolveFixedWeaponPrefab();

		if (
			fixedWeapon is not null &&
			fixedWeapon.IsValid &&
			OMRWeaponUtility.GetWeapon( fixedWeapon ) is not null
		)
		{
			return fixedWeapon;
		}

		Log.Warning(
			$"ROUND LOADOUTS | {rule.Modifier} requests a fixed weapon but its prefab is invalid. Falling back safely."
		);

		if ( rule.Modifier == RoundModifier.HighNoon )
			return ResolveHighNoonWeapon();

		return ResolveStandardWeapon();
	}

	private GameObject ResolveHighNoonWeapon()
	{
		if (
			HighNoonWeapon is not null &&
			HighNoonWeapon.IsValid
		)
		{
			return HighNoonWeapon;
		}

		Log.Warning(
			"ROUND LOADOUTS | High Noon weapon is missing; falling back to the current competitive weapon."
		);

		return
			CurrentCompetitiveWeapon ??
			ResolveStandardWeapon();
	}

	private GameObject ResolveStandardWeapon()
	{
		int holdRounds =
			Math.Max(
				RoundsPerLoadout,
				1
			);

		if (
			IsCompetitiveIndexValid( _currentCompetitiveIndex ) &&
			_standardRoundsOnCurrent < holdRounds
		)
		{
			_standardRoundsOnCurrent++;
			return GetCompetitiveWeapon( _currentCompetitiveIndex );
		}

		int nextIndex =
			TakeNextCompetitiveIndex();

		if ( nextIndex < 0 )
		{
			Log.Error(
				"ROUND LOADOUTS | No valid competitive weapon could be resolved."
			);

			return null;
		}

		_currentCompetitiveIndex = nextIndex;
		_lastCompetitiveIndex = nextIndex;
		_standardRoundsOnCurrent = 1;

		return GetCompetitiveWeapon( nextIndex );
	}

	private GameObject ResolveFilteredWeapon(
		OMRRoundRuleProfile rule
	)
	{
		RoundModifier key =
			rule.Modifier;

		int holdRounds =
			Math.Max(
				RoundsPerLoadout,
				1
			);

		int currentIndex =
			_filteredCurrentIndex.TryGetValue(
				key,
				out int storedCurrent
			)
				? storedCurrent
				: -1;

		int roundsOnCurrent =
			_filteredRoundsOnCurrent.TryGetValue(
				key,
				out int storedRounds
			)
				? storedRounds
				: 0;

		if (
			IsFilteredIndexValid(
				currentIndex,
				rule
			) &&
			roundsOnCurrent < holdRounds
		)
		{
			_filteredRoundsOnCurrent[key] =
				roundsOnCurrent + 1;

			return GetCompetitiveWeapon(
				currentIndex
			);
		}

		int nextIndex =
			TakeNextFilteredIndex(
				rule
			);

		if ( nextIndex < 0 )
		{
			Log.Warning(
				$"ROUND LOADOUTS | {rule.Modifier} filtered pool resolved no valid weapon; falling back to the shared competitive shuffle."
			);

			return ResolveStandardWeapon();
		}

		_filteredCurrentIndex[key] =
			nextIndex;

		_filteredLastIndex[key] =
			nextIndex;

		_filteredRoundsOnCurrent[key] =
			1;

		return GetCompetitiveWeapon(
			nextIndex
		);
	}

	private int TakeNextCompetitiveIndex()
	{
		EnsureShuffleBag();

		while ( _shuffleBag.Count > 0 )
		{
			int last =
				_shuffleBag.Count - 1;

			int index =
				_shuffleBag[last];

			_shuffleBag.RemoveAt(
				last
			);

			if ( IsCompetitiveIndexValid( index ) )
				return index;
		}

		return -1;
	}

	private void EnsureShuffleBag()
	{
		if ( _shuffleBag.Count > 0 )
			return;

		if (
			CompetitiveWeapons is null ||
			CompetitiveWeapons.Count <= 0
		)
			return;

		for (
			int i = 0;
			i < CompetitiveWeapons.Count;
			i++
		)
		{
			if ( IsCompetitiveIndexValid( i ) )
			{
				_shuffleBag.Add( i );
			}
		}

		ShuffleIndices(
			_shuffleBag
		);

		AvoidImmediateBagRepeat(
			_shuffleBag,
			_lastCompetitiveIndex
		);
	}

	private int TakeNextFilteredIndex(
		OMRRoundRuleProfile rule
	)
	{
		RoundModifier key =
			rule.Modifier;

		EnsureFilteredShuffleBag(
			rule
		);

		if (
			!_filteredShuffleBags.TryGetValue(
				key,
				out List<int> bag
			) ||
			bag is null
		)
		{
			return -1;
		}

		while ( bag.Count > 0 )
		{
			int last =
				bag.Count - 1;

			int index =
				bag[last];

			bag.RemoveAt(
				last
			);

			if (
				IsFilteredIndexValid(
					index,
					rule
				)
			)
			{
				return index;
			}
		}

		return -1;
	}

	private void EnsureFilteredShuffleBag(
		OMRRoundRuleProfile rule
	)
	{
		RoundModifier key =
			rule.Modifier;

		if (
			_filteredShuffleBags.TryGetValue(
				key,
				out List<int> existing
			) &&
			existing is not null &&
			existing.Count > 0
		)
		{
			// Remove entries that became invalid due to a hotloaded rule/profile.
			existing.RemoveAll(
				x =>
					!IsFilteredIndexValid(
						x,
						rule
					)
			);

			if ( existing.Count > 0 )
				return;
		}

		List<int> bag =
			new();

		if ( CompetitiveWeapons is not null )
		{
			for (
				int i = 0;
				i < CompetitiveWeapons.Count;
				i++
			)
			{
				if (
					IsFilteredIndexValid(
						i,
						rule
					)
				)
				{
					bag.Add( i );
				}
			}
		}

		ShuffleIndices(
			bag
		);

		int lastIndex =
			_filteredLastIndex.TryGetValue(
				key,
				out int storedLast
			)
				? storedLast
				: -1;

		AvoidImmediateBagRepeat(
			bag,
			lastIndex
		);

		_filteredShuffleBags[key] =
			bag;
	}

	private static void ShuffleIndices(
		List<int> bag
	)
	{
		if ( bag is null )
			return;

		for (
			int i = bag.Count - 1;
			i > 0;
			i--
		)
		{
			int swapIndex =
				Game.Random.Next(
					0,
					i + 1
				);

			(
				bag[i],
				bag[swapIndex]
			) = (
				bag[swapIndex],
				bag[i]
			);
		}
	}

	/// <summary>
	/// Bags are consumed from the end. Keep the previous index away from that
	/// slot so a reshuffle never creates an immediate duplicate when alternatives
	/// exist.
	/// </summary>
	private static void AvoidImmediateBagRepeat(
		List<int> bag,
		int previousIndex
	)
	{
		if (
			bag is null ||
			bag.Count <= 1 ||
			previousIndex < 0
		)
			return;

		int nextBagIndex =
			bag.Count - 1;

		if ( bag[nextBagIndex] != previousIndex )
			return;

		for (
			int i = 0;
			i < nextBagIndex;
			i++
		)
		{
			if ( bag[i] == previousIndex )
				continue;

			(
				bag[i],
				bag[nextBagIndex]
			) = (
				bag[nextBagIndex],
				bag[i]
			);

			break;
		}
	}

	private bool IsCompetitiveIndexValid(
		int index
	)
	{
		GameObject prefab =
			GetCompetitiveWeapon(
				index
			);

		return
			prefab is not null &&
			OMRWeaponUtility.GetWeapon( prefab ) is not null;
	}

	private bool IsFilteredIndexValid(
		int index,
		OMRRoundRuleProfile rule
	)
	{
		if (
			rule is null ||
			!rule.IsValid ||
			!IsCompetitiveIndexValid( index )
		)
		{
			return false;
		}

		return rule.AllowsWeapon(
			GetCompetitiveWeapon( index )
		);
	}
}