OMRRoundRuleDirector.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// Central authored catalog for modular round rules.
///
/// RoundManager continues to own the actual match state machine. This director
/// only resolves profiles, produces fair/random two-choice modifier offers and
/// provides display metadata to UI. Keeping that responsibility separate avoids
/// growing another modifier switch throughout RoundManager and GameHUD.
/// </summary>
public sealed class OMRRoundRuleDirector : Component
{
	[Property, Group( "Rules" )]
	public List<OMRRoundRuleProfile> Rules { get; set; } =
		new();

	private readonly List<RoundModifier> _offerBag =
		new();


	public void ResetMatchSequence()
	{
		_offerBag.Clear();
	}

	public OMRRoundRuleProfile ResolveRule(
		RoundModifier modifier
	)
	{
		if ( Rules is null )
			return null;

		foreach ( OMRRoundRuleProfile rule in Rules )
		{
			if (
				rule is null ||
				!rule.IsValid ||
				rule.Modifier != modifier
			)
			{
				continue;
			}

			return rule;
		}

		return null;
	}

	public string GetDisplayName(
		RoundModifier modifier
	)
	{
		OMRRoundRuleProfile rule =
			ResolveRule(
				modifier
			);

		return rule is null
			? modifier.ToString().ToUpperInvariant()
			: rule.GetDisplayName();
	}

	public string GetDescription(
		RoundModifier modifier
	)
	{
		OMRRoundRuleProfile rule =
			ResolveRule(
				modifier
			);

		return rule?.GetDescription() ??
			"Special round rules.";
	}

	/// <summary>
	/// Consume two distinct offerable rules from a shuffle bag. Every offerable
	/// modifier is visited before a new bag is created, rather than independently
	/// rolling the same option forever with pure random selection.
	/// </summary>
	public bool TryTakeModifierOffers(
		out RoundModifier optionA,
		out RoundModifier optionB
	)
	{
		optionA = RoundModifier.HighNoon;
		optionB = RoundModifier.Standard;

		List<RoundModifier> offerable =
			GetOfferableModifiers();

		if ( offerable.Count <= 0 )
			return false;

		if ( offerable.Count == 1 )
		{
			optionA = offerable[0];
			optionB = RoundModifier.Standard;
			return true;
		}

		optionA =
			TakeNextOffer(
				offerable,
				null
			);

		optionB =
			TakeNextOffer(
				offerable,
				optionA
			);

		if ( optionB == optionA )
		{
			// optionA is an out parameter, so C# cannot capture it inside a
			// lambda. Resolve the fallback explicitly instead.
			foreach ( RoundModifier candidate in offerable )
			{
				if ( candidate == optionA )
					continue;

				optionB = candidate;
				break;
			}
		}

		// Randomize left/right placement independently of the bag order so one
		// modifier does not become muscle-memory option A.
		if ( Game.Random.Next( 0, 2 ) == 1 )
		{
			(
				optionA,
				optionB
			) = (
				optionB,
				optionA
			);
		}

		return true;
	}

	[Button, Title( "Validate Round Rules" )]
	public void ValidateRoundRules()
	{
		int errors = 0;
		int warnings = 0;

		if (
			Rules is null ||
			Rules.Count <= 0
		)
		{
			Log.Error(
				"ROUND RULES | FAIL | No OMR round rule profiles are assigned."
			);
			return;
		}

		HashSet<RoundModifier> modifiers =
			new();

		OMRRoundLoadoutDirector loadouts =
			Components.Get<OMRRoundLoadoutDirector>();

		int offerableCount = 0;

		foreach ( OMRRoundRuleProfile rule in Rules )
		{
			if (
				rule is null ||
				!rule.IsValid
			)
			{
				errors++;
				Log.Error(
					"ROUND RULES | Missing/invalid profile reference."
				);
				continue;
			}

			if ( !modifiers.Add( rule.Modifier ) )
			{
				errors++;
				Log.Error(
					$"ROUND RULES | Duplicate profile for {rule.Modifier}."
				);
			}

			if ( rule.OfferInModifierDecision )
				offerableCount++;

			if (
				rule.WeaponPolicy ==
					OMRRoundWeaponPolicy.Fixed &&
				OMRWeaponUtility.GetWeapon(
					rule.ResolveFixedWeaponPrefab()
				) is null
			)
			{
				errors++;
				Log.Error(
					$"ROUND RULES | {rule.Modifier} uses Fixed weapon policy but has no valid OneMoreRoundWeapon prefab."
				);
			}

			if (
				rule.Modifier == RoundModifier.Standard &&
				rule.OfferInModifierDecision
			)
			{
				warnings++;
				Log.Warning(
					"ROUND RULES | Standard is offerable. This is valid, but normally Standard is the no-modifier baseline rather than a ONE MORE ROUND choice."
				);
			}

			if (
				rule.WeaponPolicy == OMRRoundWeaponPolicy.FilteredShuffle &&
				loadouts is null
			)
			{
				warnings++;
				Log.Warning(
					$"ROUND RULES | {rule.Modifier} uses FilteredShuffle but OMRRoundLoadoutDirector is missing, so the pool cannot be validated."
				);
			}
			else if (
				rule.WeaponPolicy == OMRRoundWeaponPolicy.FilteredShuffle &&
				loadouts is not null
			)
			{
				int eligible = 0;

				for ( int i = 0; i < loadouts.CompetitiveWeaponCount; i++ )
				{
					GameObject prefab = loadouts.GetCompetitiveWeapon( i );

					if (
						prefab is not null &&
						rule.AllowsWeapon( prefab )
					)
					{
						eligible++;
					}
				}

				if ( eligible <= 0 )
				{
					errors++;
					Log.Error(
						$"ROUND RULES | {rule.Modifier} filtered weapon policy matches no competitive weapons."
					);
				}
			}

			if ( rule.GravityScale <= 0f )
			{
				errors++;
				Log.Error(
					$"ROUND RULES | {rule.Modifier} has invalid GravityScale {rule.GravityScale:0.###}."
				);
			}

			if (
				rule.AdditionalAirJumps < 0 ||
				rule.AdditionalAirJumps > 3
			)
			{
				errors++;
				Log.Error(
					$"ROUND RULES | {rule.Modifier} has invalid AdditionalAirJumps {rule.AdditionalAirJumps}; expected 0..3."
				);
			}

			if ( rule.FightDurationOverride < 0f )
			{
				errors++;
				Log.Error(
					$"ROUND RULES | {rule.Modifier} has invalid FightDurationOverride {rule.FightDurationOverride:0.###}. Use 0 for the default timer."
				);
			}

			if ( rule.WeaponModifiers is not null )
			{
				HashSet<string> modifierIds =
					new(
						StringComparer.OrdinalIgnoreCase
					);

				foreach ( OMRWeaponModifierDefinition modifier in rule.WeaponModifiers )
				{
					if (
						modifier is null ||
						!modifier.IsValid
					)
					{
						warnings++;
						Log.Warning(
							$"ROUND RULES | {rule.Modifier} contains an invalid weapon modifier reference."
						);
						continue;
					}

					string id =
						modifier.ModifierId?.Trim() ??
						string.Empty;

					if ( string.IsNullOrWhiteSpace( id ) )
					{
						warnings++;
						continue;
					}

					if ( !modifierIds.Add( id ) )
					{
						warnings++;
						Log.Warning(
							$"ROUND RULES | {rule.Modifier} repeats weapon modifier ID '{id}'."
						);
					}
				}
			}
		}

		if ( ResolveRule( RoundModifier.Standard ) is null )
		{
			warnings++;
			Log.Warning(
				"ROUND RULES | No Standard profile is assigned; legacy fallback behavior will be used."
			);
		}

		if ( offerableCount < 2 )
		{
			warnings++;
			Log.Warning(
				$"ROUND RULES | Only {offerableCount} modifier rule(s) are offerable; two-choice decisions need at least two for full variety."
			);
		}

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

		Log.Info(
			$"ROUND RULES | {result} | Profiles:{Rules.Count} | Offerable:{offerableCount} | Errors:{errors} | Warnings:{warnings}"
		);
	}

	private List<RoundModifier> GetOfferableModifiers()
	{
		List<RoundModifier> result =
			new();

		if ( Rules is null )
			return result;

		HashSet<RoundModifier> seen =
			new();

		foreach ( OMRRoundRuleProfile rule in Rules )
		{
			if (
				rule is null ||
				!rule.IsValid ||
				!rule.OfferInModifierDecision ||
				!seen.Add( rule.Modifier )
			)
			{
				continue;
			}

			result.Add(
				rule.Modifier
			);
		}

		return result;
	}

	private RoundModifier TakeNextOffer(
		List<RoundModifier> offerable,
		RoundModifier? excluded
	)
	{
		for ( int attempt = 0; attempt < 2; attempt++ )
		{
			EnsureOfferBag(
				offerable
			);

			for ( int i = _offerBag.Count - 1; i >= 0; i-- )
			{
				RoundModifier candidate =
					_offerBag[i];

				if (
					excluded.HasValue &&
					candidate == excluded.Value
				)
				{
					continue;
				}

				_offerBag.RemoveAt( i );
				return candidate;
			}

			_offerBag.Clear();
		}

		return offerable.FirstOrDefault(
			x =>
				!excluded.HasValue ||
				x != excluded.Value
		);
	}

	private void EnsureOfferBag(
		List<RoundModifier> offerable
	)
	{
		// If hotload changed the authored rules, discard stale enum values before
		// continuing the bag.
		_offerBag.RemoveAll(
			x => !offerable.Contains( x )
		);

		if ( _offerBag.Count > 0 )
			return;

		_offerBag.AddRange(
			offerable
		);

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

			(
				_offerBag[i],
				_offerBag[swap]
			) = (
				_offerBag[swap],
				_offerBag[i]
			);
		}
	}

}