OneMoreRoundWeapon.Definition.cs
using Sandbox;
using System;
using System.Collections.Generic;

/// <summary>
/// Definition + resolved-stat pipeline for OneMoreRoundWeapon.
///
/// Existing prefabs remain valid when Definition is null. A canonical
/// OMRWeaponDefinition becomes the immutable authored baseline when assigned.
/// Runtime modifiers are always applied to a cloned OMRWeaponStats snapshot and
/// never mutate the source asset.
/// </summary>
public partial class OneMoreRoundWeapon
{
	[Property, Group( "Weapon Definition" ), Title( "Definition" )]
	[Description( "Optional canonical weapon asset. When assigned, its authored gameplay/presentation values become the base stats before BaseCombatWeapon starts. Leave empty for legacy prefab values during migration." )]
	public OMRWeaponDefinition Definition { get; set; }

	[Property, Group( "Weapon Definition" ), Title( "Log Definition Apply" )]
	public bool LogDefinitionApply { get; set; } = false;

	public bool UsesWeaponDefinition =>
		Definition is not null &&
		Definition.IsValid;

	/// <summary>
	/// Immutable-by-convention baseline captured from the canonical definition,
	/// or from legacy prefab-authored values while migration is still in progress.
	/// Never modify this instance directly after initialization.
	/// </summary>
	private OMRWeaponStats _baseStats;

	public OMRWeaponStats BaseStats => _baseStats?.Clone();

	/// <summary>
	/// Current effective stats after the deterministic runtime modifier stack.
	/// Callers receive a copy so external systems cannot mutate the weapon's
	/// authoritative local snapshot behind the resolver's back.
	/// </summary>
	private OMRWeaponStats _resolvedStats;

	public OMRWeaponStats ResolvedStats => _resolvedStats?.Clone();

	public int StatRevision { get; private set; }

	private readonly List<IOMRWeaponStatModifier> _activeStatModifiers = new();

	private bool _weaponDefinitionInitialized;

	public IReadOnlyList<IOMRWeaponStatModifier> ActiveStatModifiers => _activeStatModifiers;

	/// <summary>
	/// Fired locally whenever the effective stat snapshot has been rebuilt and
	/// applied to the engine-facing weapon component.
	/// </summary>
	public event Action<OneMoreRoundWeapon> ResolvedStatsChanged;

	/// <summary>
	/// Initializes the authored baseline and applies the first resolved snapshot.
	/// This must execute before BaseCombatWeapon.OnStart so magazine/model setup
	/// sees canonical/resolved values instead of stale legacy prefab values.
	/// </summary>
	private void ApplyWeaponDefinitionIfAssigned()
	{
		if ( _weaponDefinitionInitialized )
			return;

		ApplyDefinitionPresentationIfAssigned();

		_baseStats = UsesWeaponDefinition
			? OMRWeaponStats.FromDefinition( Definition )
			: OMRWeaponStats.Capture( this );

		if ( _baseStats is null )
		{
			Log.Error( $"[OMR WEAPON STATS] Failed to establish base stats for '{GameObject?.Name ?? "Weapon"}'." );
			return;
		}

		_baseStats.Normalize();
		SortStatModifiers();
		RebuildResolvedStats( initializing: true );
		_weaponDefinitionInitialized = true;

		if ( LogDefinitionApply )
		{
			string source = UsesWeaponDefinition
				? Definition.ResourceName
				: "LEGACY PREFAB VALUES";

			Log.Info(
				$"[OMR WEAPON STATS] Initialized '{_resolvedStats.WeaponId}' from {source} | Damage:{_resolvedStats.Damage:0.##} | Delay:{_resolvedStats.PrimaryDelay:0.###} | Clip:{_resolvedStats.ClipMaxSize} | Modifiers:{_activeStatModifiers.Count}"
			);
		}
	}

	/// <summary>
	/// Rebuilds the effective stat copy from the untouched base snapshot, applies
	/// every modifier in deterministic order, normalizes impossible values and
	/// copies the result into BaseCombatWeapon/OMR runtime fields.
	/// </summary>
	public void RebuildResolvedStats()
	{
		RebuildResolvedStats( initializing: false );
	}

	private void RebuildResolvedStats( bool initializing )
	{
		if ( _baseStats is null )
		{
			// This can happen if an external source registers a modifier before this
			// component's OnStart. The modifier stays queued and initialization will
			// resolve it once the authored baseline exists.
			return;
		}

		OMRWeaponStats resolved = _baseStats.Clone();

		foreach ( IOMRWeaponStatModifier modifier in _activeStatModifiers )
		{
			if ( modifier is null )
				continue;

			try
			{
				modifier.Apply( resolved );
			}
			catch ( Exception exception )
			{
				Log.Error(
					$"[OMR WEAPON STATS] Modifier '{modifier.ModifierId}' failed on '{resolved.WeaponId}': {exception.Message}"
				);
			}
		}

		resolved.Normalize();
		resolved.ApplyTo( this, clampLoadedAmmoToMagazine: !initializing );

		_resolvedStats = resolved;
		StatRevision++;
		ResolvedStatsChanged?.Invoke( this );
	}

	/// <summary>
	/// Adds or replaces one modifier using ModifierId as its stable key. This is
	/// the primary API OMR modes, future attachments and other games should call.
	/// </summary>
	public bool SetStatModifier( IOMRWeaponStatModifier modifier )
	{
		if ( modifier is null )
			return false;

		string modifierId = modifier.ModifierId?.Trim() ?? string.Empty;
		if ( string.IsNullOrWhiteSpace( modifierId ) )
		{
			Log.Warning( $"[OMR WEAPON STATS] Rejected modifier with an empty ID on '{GameObject?.Name ?? "Weapon"}'." );
			return false;
		}

		for ( int i = _activeStatModifiers.Count - 1; i >= 0; i-- )
		{
			IOMRWeaponStatModifier existing = _activeStatModifiers[i];
			if ( existing is null )
			{
				_activeStatModifiers.RemoveAt( i );
				continue;
			}

			if ( string.Equals( existing.ModifierId?.Trim(), modifierId, StringComparison.OrdinalIgnoreCase ) )
			{
				_activeStatModifiers.RemoveAt( i );
			}
		}

		_activeStatModifiers.Add( modifier );
		SortStatModifiers();
		RebuildResolvedStats();
		return true;
	}

	public bool RemoveStatModifier( string modifierId )
	{
		if ( string.IsNullOrWhiteSpace( modifierId ) )
			return false;

		string normalizedId = modifierId.Trim();
		bool removed = false;

		for ( int i = _activeStatModifiers.Count - 1; i >= 0; i-- )
		{
			IOMRWeaponStatModifier existing = _activeStatModifiers[i];
			if ( existing is null )
			{
				_activeStatModifiers.RemoveAt( i );
				removed = true;
				continue;
			}

			if ( string.Equals( existing.ModifierId?.Trim(), normalizedId, StringComparison.OrdinalIgnoreCase ) )
			{
				_activeStatModifiers.RemoveAt( i );
				removed = true;
			}
		}

		if ( removed )
		{
			SortStatModifiers();
			RebuildResolvedStats();
		}

		return removed;
	}

	public void ClearStatModifiers()
	{
		if ( _activeStatModifiers.Count == 0 )
			return;

		_activeStatModifiers.Clear();
		RebuildResolvedStats();
	}

	public bool HasStatModifier( string modifierId )
	{
		if ( string.IsNullOrWhiteSpace( modifierId ) )
			return false;

		string normalizedId = modifierId.Trim();

		foreach ( IOMRWeaponStatModifier modifier in _activeStatModifiers )
		{
			if ( modifier is null )
				continue;

			if ( string.Equals( modifier.ModifierId?.Trim(), normalizedId, StringComparison.OrdinalIgnoreCase ) )
				return true;
		}

		return false;
	}

	private void SortStatModifiers()
	{
		_activeStatModifiers.Sort( CompareStatModifiers );
	}

	private static int CompareStatModifiers( IOMRWeaponStatModifier a, IOMRWeaponStatModifier b )
	{
		if ( ReferenceEquals( a, b ) )
			return 0;
		if ( a is null )
			return 1;
		if ( b is null )
			return -1;

		int priorityComparison = a.Priority.CompareTo( b.Priority );
		if ( priorityComparison != 0 )
			return priorityComparison;

		return string.Compare(
			a.ModifierId ?? string.Empty,
			b.ModifierId ?? string.Empty,
			StringComparison.OrdinalIgnoreCase
		);
	}

	/// <summary>
	/// Presentation resources remain authored by the canonical definition, but
	/// are intentionally outside the numeric modifier pipeline. A damage buff
	/// should not unexpectedly replace a viewmodel because humans deserve at
	/// least that much predictability.
	/// </summary>
	private void ApplyDefinitionPresentationIfAssigned()
	{
		if ( !UsesWeaponDefinition )
			return;

		OMRWeaponDefinition definition = Definition;

		// Capability and presentation resources are canonical when a definition is
		// assigned. Do not silently retain stale prefab values when the definition
		// intentionally leaves a field empty; null in the .omrwep must mean null at
		// runtime. Legacy prefab-authored presentation remains supported only when no
		// Definition is assigned.
		OpticProfile = definition.OpticProfile;
		CycleProfile = definition.CycleProfile;
		AllowAim = definition.AllowAim;
		AllowReload = definition.AllowReload;

		// Reload behavior is canonical definition data too. These values must be
		// present before BaseCombatWeapon.OnStart initializes its reload state.
		IncrementalReloading = definition.IncrementalReloading;
		ReloadStartTime = MathF.Max( definition.ReloadStartTime, 0f );
		FirstShellReloadTime = MathF.Max( definition.FirstShellReloadTime, 0f );

		ViewModelPrefab = ResolveDefinitionPrefab(
			definition.ViewModelPrefab,
			"ViewModel"
		);

		WorldModelPrefab = ResolveDefinitionPrefab(
			definition.WorldModelPrefab,
			"WorldModel"
		);

		AttackSound = definition.AttackSound;
		DryFireSound = definition.DryFireSound;

		ReloadSoundCues = definition.ReloadSoundCues is null
			? new List<OMRReloadSoundCue>()
			: new List<OMRReloadSoundCue>( definition.ReloadSoundCues );

		IncrementalReloadInsertSound = definition.IncrementalReloadInsertSound;

		CycleSoundCues = definition.CycleSoundCues is null
			? new List<OMRCycleSoundCue>()
			: new List<OMRCycleSoundCue>( definition.CycleSoundCues );
	}

	/// <summary>
	/// Resolve an authored PrefabFile resource to the cached prefab-scene root that
	/// BaseCombatWeapon expects for ViewModelPrefab / WorldModelPrefab. Storing the
	/// asset itself in OMRWeaponDefinition avoids serializing scene GameObject
	/// references inside a GameResource, which can resolve too late or to null.
	/// </summary>
	private GameObject ResolveDefinitionPrefab( PrefabFile prefab, string role )
	{
		if ( prefab is null || !prefab.IsValid )
			return null;

		string path = prefab.ResourcePath;
		GameObject template = string.IsNullOrWhiteSpace( path )
			? null
			: GameObject.GetPrefab( path );

		// Keep the PrefabScene route as a compatibility fallback. Modern s&box
		// recommends GameObject.GetPrefab for runtime prefab access, but both
		// routes resolve to the loaded template expected by BaseCombatWeapon.
		if ( template is null || !template.IsValid )
			template = SceneUtility.GetPrefabScene( prefab );

		if ( template is null || !template.IsValid )
		{
			Log.Warning(
				$"[OMR WEAPON DEFINITION] Failed to resolve {role} prefab '{path}' for '{GameObject?.Name ?? "Weapon"}'."
			);
			return null;
		}

		return template;
	}

	/// <summary>
	/// Kept for existing diagnostic call sites during the migration. Unlike the
	/// previous implementation this rebuilds the full modifier pipeline rather
	/// than merely scraping current component values.
	/// </summary>
	private void RefreshResolvedStatsSnapshot()
	{
		RebuildResolvedStats();
	}
}