OneMoreRoundWeapon.Validation.cs
using Sandbox;
using System;

/// <summary>
/// Editor/runtime diagnostics for weapon prefab authoring.
///
/// This deliberately validates structure, not balance. A weird damage value may
/// be intentional; a missing muzzle attachment while a muzzle effect is enabled
/// is almost certainly not.
/// </summary>
public partial class OneMoreRoundWeapon
{
	[Button, Title( "Validate Weapon" ), Description( "Validate OMR metadata, mechanics configuration, and view/world-model presentation references." )]
	public void ValidateWeaponSetup()
	{
		int errors = 0;
		int warnings = 0;

		ValidateWeaponSetupInternal(
			ref errors,
			ref warnings,
			GameObject?.Name ?? "Weapon"
		);

		if ( errors == 0 )
		{
			Log.Info(
				$"WEAPON VALIDATION | PASS | {DisplayName} | ID:{WeaponId} | Class:{WeaponArchetype} | Warnings:{warnings}"
			);
		}
		else
		{
			Log.Error(
				$"WEAPON VALIDATION | FAIL | {DisplayName} | Errors:{errors} | Warnings:{warnings}"
			);
		}
	}

	internal void ValidateWeaponSetupInternal(
		ref int errors,
		ref int warnings,
		string context
	)
	{
		string prefix =
			$"WEAPON VALIDATION | {context}";

		if ( UsesWeaponDefinition )
		{
			ValidateDefinition(
				Definition,
				prefix,
				ref errors,
				ref warnings
			);
		}
		else
		{
			warnings++;
			Log.Warning(
				$"{prefix} | No OMRWeaponDefinition assigned. Legacy prefab-authored values are still supported during migration, but this weapon is not yet using the canonical data pipeline."
			);
		}

		if (
			string.IsNullOrWhiteSpace(
				WeaponId
			)
		)
		{
			errors++;

			Log.Error(
				$"{prefix} | WeaponId is empty."
			);
		}

		if (
			WeaponArchetype ==
				OMRWeaponArchetype.Unknown
		)
		{
			warnings++;

			Log.Warning(
				$"{prefix} | WeaponArchetype is Unknown."
			);
		}

		if (
			string.IsNullOrWhiteSpace(
				DisplayName
			)
		)
		{
			warnings++;

			Log.Warning(
				$"{prefix} | DisplayName is empty."
			);
		}

		if ( Ballistics.Damage <= 0f )
		{
			warnings++;

			Log.Warning(
				$"{prefix} | Ballistics.Damage is {Ballistics.Damage}; verify this is intentional."
			);
		}

		if ( Ballistics.Pellets <= 0 )
		{
			errors++;

			Log.Error(
				$"{prefix} | Ballistics.Pellets must be at least 1."
			);
		}

		if ( Ballistics.Range <= 0f )
		{
			errors++;

			Log.Error(
				$"{prefix} | Ballistics.Range must be greater than 0."
			);
		}

		if (
			MovementAccuracyModel == OMRMovementAccuracyModel.ContinuousSpeed &&
			MovementAccuracyFullFraction < MovementAccuracyStartFraction
		)
		{
			errors++;
			Log.Error(
				$"{prefix} | Continuous movement accuracy Full Fraction must be >= Start Fraction."
			);
		}

		bool hasPatternAsset = RecoilPatternAsset is not null && RecoilPatternAsset.IsValid;
		bool hasLegacyPattern = RecoilPattern is not null && RecoilPattern.Count > 0;

		if (
			RecoilModel == OMRGameplayRecoilModel.Pattern &&
			!hasPatternAsset &&
			!hasLegacyPattern
		)
		{
			errors++;
			Log.Error(
				$"{prefix} | RecoilModel is Pattern but neither RecoilPatternAsset nor Legacy Pattern Steps are authored."
			);
		}

		if ( hasPatternAsset )
		{
			ValidateRecoilPatternAsset( RecoilPatternAsset, prefix, ref errors, ref warnings );

			if ( hasLegacyPattern )
			{
				warnings++;
				Log.Warning(
					$"{prefix} | Both RecoilPatternAsset and Legacy Pattern Steps are authored. The .omrcoil asset takes priority."
				);
			}
		}

		if (
			RecoilRecoveryEnabled &&
			RecoilRecoveryFraction <= 0f
		)
		{
			warnings++;
			Log.Warning(
				$"{prefix} | Recoil recovery is enabled but Recovery Fraction is 0, so no gameplay recoil can return."
			);
		}

		if (
			RecoilRecoveryEnabled &&
			RecoilRecoveryPitchSpeed <= 0f &&
			RecoilRecoveryYawSpeed <= 0f
		)
		{
			warnings++;
			Log.Warning(
				$"{prefix} | Recoil recovery is enabled but both recovery speeds are 0."
			);
		}

		if (
			RecoilPitch.x > 0.001f ||
			RecoilPitch.y > 0.001f
		)
		{
			warnings++;

			Log.Warning(
				$"{prefix} | RecoilPitch contains a positive value ({RecoilPitch}). OMR uses negative pitch for upward gameplay recoil; verify this is intentional."
			);
		}

		ValidateOpticProfile( OpticProfile, prefix, ref errors, ref warnings );
		ValidateCycleProfile( CycleProfile, PrimaryAutomatic, prefix, ref errors, ref warnings );

		ValidateWeaponModelPrefab(
			ViewModelPrefab,
			"ViewModel",
			prefix,
			ref errors,
			ref warnings
		);

		ValidateWeaponModelPrefab(
			WorldModelPrefab,
			"WorldModel",
			prefix,
			ref errors,
			ref warnings
		);

		if ( HasCycleProfile )
		{
			ValidateCyclePresentationAdapter( ViewModelPrefab, "ViewModel", prefix, ref warnings );
			ValidateCyclePresentationAdapter( WorldModelPrefab, "WorldModel", prefix, ref warnings );
		}
	}

	private static void ValidateDefinition(
		OMRWeaponDefinition definition,
		string prefix,
		ref int errors,
		ref int warnings
	)
	{
		if ( definition is null || !definition.IsValid )
		{
			errors++;
			Log.Error( $"{prefix} | Assigned weapon definition is invalid." );
			return;
		}

		if ( string.IsNullOrWhiteSpace( definition.WeaponId ) )
		{
			errors++;
			Log.Error( $"{prefix} | Definition WeaponId is empty." );
		}

		if ( definition.Archetype == OMRWeaponArchetype.Unknown )
		{
			warnings++;
			Log.Warning( $"{prefix} | Definition Archetype is Unknown." );
		}

		if ( definition.Damage <= 0f )
		{
			warnings++;
			Log.Warning( $"{prefix} | Definition Damage is {definition.Damage}; verify this is intentional." );
		}

		if ( definition.Pellets < 1 )
		{
			errors++;
			Log.Error( $"{prefix} | Definition Pellets must be at least 1." );
		}

		if ( definition.PrimaryDelay <= 0f )
		{
			errors++;
			Log.Error( $"{prefix} | Definition Fire Interval must be greater than 0." );
		}

		if ( definition.ClipMaxSize < 1 )
		{
			errors++;
			Log.Error( $"{prefix} | Definition Magazine Size must be at least 1." );
		}

		if ( definition.Range <= 0f )
		{
			errors++;
			Log.Error( $"{prefix} | Definition Range must be greater than 0." );
		}

		if (
			definition.MovementAccuracyModel == OMRMovementAccuracyModel.ContinuousSpeed &&
			definition.MovementAccuracyFullFraction < definition.MovementAccuracyStartFraction
		)
		{
			errors++;
			Log.Error( $"{prefix} | Definition Continuous Accuracy Full Fraction must be >= Start Fraction." );
		}

		bool hasPatternAsset = definition.RecoilPatternAsset is not null && definition.RecoilPatternAsset.IsValid;
		bool hasLegacyPattern = definition.RecoilPattern is not null && definition.RecoilPattern.Count > 0;

		if (
			definition.RecoilModel == OMRGameplayRecoilModel.Pattern &&
			!hasPatternAsset &&
			!hasLegacyPattern
		)
		{
			errors++;
			Log.Error( $"{prefix} | Definition uses Pattern recoil but neither RecoilPatternAsset nor Legacy Pattern Steps are authored." );
		}

		if ( hasPatternAsset )
		{
			ValidateRecoilPatternAsset( definition.RecoilPatternAsset, prefix, ref errors, ref warnings );

			if ( hasLegacyPattern )
			{
				warnings++;
				Log.Warning( $"{prefix} | Definition has both RecoilPatternAsset and Legacy Pattern Steps. The .omrcoil asset takes priority." );
			}
		}

		if (
			definition.RecoilRecoveryEnabled &&
			definition.RecoilRecoveryFraction <= 0f
		)
		{
			warnings++;
			Log.Warning( $"{prefix} | Definition enables recoil recovery but Recovery Fraction is 0." );
		}

		if (
			definition.RecoilRecoveryEnabled &&
			definition.RecoilRecoveryPitchSpeed <= 0f &&
			definition.RecoilRecoveryYawSpeed <= 0f
		)
		{
			warnings++;
			Log.Warning( $"{prefix} | Definition enables recoil recovery but both recovery speeds are 0." );
		}

		ValidateDefinitionPresentation( definition, prefix, ref errors, ref warnings );
		ValidateOpticProfile( definition.OpticProfile, prefix, ref errors, ref warnings );
		ValidateCycleProfile( definition.CycleProfile, definition.PrimaryAutomatic, prefix, ref errors, ref warnings );
	}

	private static void ValidateDefinitionPresentation(
		OMRWeaponDefinition definition,
		string prefix,
		ref int errors,
		ref int warnings
	)
	{
		bool hasViewModel = IsValidDefinitionPrefab( definition.ViewModelPrefab );
		bool hasWorldModel = IsValidDefinitionPrefab( definition.WorldModelPrefab );

		if ( !hasViewModel )
		{
			warnings++;
			Log.Warning( $"{prefix} | Definition has no valid ViewModelPrefab." );
		}

		if ( !hasWorldModel )
		{
			warnings++;
			Log.Warning( $"{prefix} | Definition has no valid WorldModelPrefab." );
		}

		if ( definition.AttackSound is null )
		{
			warnings++;
			Log.Warning( $"{prefix} | Definition has no AttackSound." );
		}

		if ( definition.DryFireSound is null )
		{
			warnings++;
			Log.Warning( $"{prefix} | Definition has no DryFireSound." );
		}

		ValidateDefinitionCycleAudio( definition, prefix, ref warnings );

		if ( definition.ReloadSoundCues is null )
			return;

		for ( int i = 0; i < definition.ReloadSoundCues.Count; i++ )
		{
			OMRReloadSoundCue cue = definition.ReloadSoundCues[i];

			if ( cue.Time < 0f )
			{
				warnings++;
				Log.Warning( $"{prefix} | Reload audio cue {i} has a negative time ({cue.Time:0.###}s). Runtime clamps it to 0." );
			}

			if ( cue.Sound is null )
			{
				warnings++;
				Log.Warning( $"{prefix} | Reload audio cue {i} has no SoundEvent." );
			}

			if ( definition.ReloadTime > 0f && cue.Time > definition.ReloadTime + 0.05f )
			{
				warnings++;
				Log.Warning( $"{prefix} | Reload audio cue {i} is scheduled at {cue.Time:0.###}s, after ReloadTime {definition.ReloadTime:0.###}s, so it may be cancelled before playback." );
			}
		}
	}

	private static void ValidateDefinitionCycleAudio(
		OMRWeaponDefinition definition,
		string prefix,
		ref int warnings
	)
	{
		if ( definition.CycleSoundCues is null || definition.CycleSoundCues.Count == 0 )
			return;

		if ( definition.CycleProfile is null )
		{
			warnings++;
			Log.Warning( $"{prefix} | Definition has CycleSoundCues but no CycleProfile." );
		}

		float duration = definition.CycleProfile is null
			? 0f
			: MathF.Max( definition.CycleProfile.Duration, 0f );

		for ( int i = 0; i < definition.CycleSoundCues.Count; i++ )
		{
			OMRCycleSoundCue cue = definition.CycleSoundCues[i];

			if ( cue.Sound is null )
			{
				warnings++;
				Log.Warning( $"{prefix} | Cycle audio cue {i} has no SoundEvent." );
			}

			if ( cue.Time < 0f )
			{
				warnings++;
				Log.Warning( $"{prefix} | Cycle audio cue {i} has a negative time ({cue.Time:0.###}s). Runtime clamps it to 0." );
			}

			if ( duration > 0f && cue.Time > duration + 0.05f )
			{
				warnings++;
				Log.Warning(
					$"{prefix} | Cycle audio cue {i} is scheduled at {cue.Time:0.###}s, " +
					$"after cycle Duration {duration:0.###}s, so it may be cancelled before playback."
				);
			}
		}
	}

	private static bool IsValidDefinitionPrefab( PrefabFile prefab )
	{
		if ( prefab is null || !prefab.IsValid )
			return false;

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

		if ( template is null || !template.IsValid )
			template = SceneUtility.GetPrefabScene( prefab );

		return template is not null && template.IsValid;
	}

	private static void ValidateOpticProfile(
		OMROpticProfile optic,
		string prefix,
		ref int errors,
		ref int warnings
	)
	{
		if ( optic is null )
			return;

		if ( !optic.IsValid )
		{
			errors++;
			Log.Error( $"{prefix} | Assigned optic profile is invalid." );
			return;
		}

		if ( string.IsNullOrWhiteSpace( optic.OpticId ) )
		{
			warnings++;
			Log.Warning( $"{prefix} | Optic profile '{optic.ResourceName}' has an empty Optic ID." );
		}

		if ( optic.AimFovScale <= 0f || optic.AimFovScale > 1f )
		{
			errors++;
			Log.Error( $"{prefix} | Optic '{optic.ResourceName}' Aim FOV Scale must be > 0 and <= 1." );
		}

		if ( optic.PeripheralAimFovScale < 0.5f || optic.PeripheralAimFovScale > 1.5f )
		{
			errors++;
			Log.Error( $"{prefix} | Optic '{optic.ResourceName}' Peripheral Aim FOV Scale must be within 0.5..1.5." );
		}

		if ( optic.ViewModelAimFovScale < 0.5f || optic.ViewModelAimFovScale > 1.5f )
		{
			errors++;
			Log.Error( $"{prefix} | Optic '{optic.ResourceName}' ViewModel Aim FOV Scale must be within 0.5..1.5." );
		}

		if (
			optic.UseAimProgressForAccuracy &&
			optic.AccuracyFullThreshold < optic.AccuracyBlendStart
		)
		{
			errors++;
			Log.Error( $"{prefix} | Optic '{optic.ResourceName}' Accuracy Full Threshold must be >= Accuracy Blend Start." );
		}

		if (
			optic.Mode == OMROpticMode.MagnifiedScope &&
			optic.AimFovScale > 0.8f
		)
		{
			warnings++;
			Log.Warning( $"{prefix} | Magnified optic '{optic.ResourceName}' has a very mild FOV scale ({optic.AimFovScale:0.###}); verify this is intentional." );
		}

		bool usesPiP =
			optic.PresentationMode == OMROpticPresentationMode.PictureInPicture ||
			optic.PresentationMode == OMROpticPresentationMode.ModelLensPictureInPicture;

		if (
			!usesPiP &&
			MathF.Abs( optic.PeripheralAimFovScale - 1f ) > 0.0001f
		)
		{
			warnings++;
			Log.Warning( $"{prefix} | Optic '{optic.ResourceName}' has Peripheral Aim FOV Scale {optic.PeripheralAimFovScale:0.###}, but this setting is only used by PiP magnified scopes." );
		}

		if ( usesPiP )
		{
			if (
				optic.UseNominalMagnificationForPiP &&
				optic.NominalMagnification <= 1f
			)
			{
				warnings++;
				Log.Warning( $"{prefix} | Optic '{optic.ResourceName}' uses nominal PiP magnification but Nominal Magnification is <= 1x." );
			}

			if (
				optic.EnableLensSimulation &&
				optic.LensClarityFull < optic.LensClarityStart
			)
			{
				errors++;
				Log.Error( $"{prefix} | Optic '{optic.ResourceName}' Lens Clarity Full must be >= Lens Clarity Start." );
			}

			if (
				optic.EnableLensSimulation &&
				optic.LensErectFull < optic.LensErectStart
			)
			{
				errors++;
				Log.Error( $"{prefix} | Optic '{optic.ResourceName}' Lens Erect Full must be >= Lens Erect Start." );
			}

			if ( optic.KeepPiPWarmWhileEquipped )
			{
				if ( optic.PiPPrewarmResolution < 128 || optic.PiPPrewarmResolution > 1024 )
				{
					warnings++;
					Log.Warning( $"{prefix} | Optic '{optic.ResourceName}' PiP prewarm resolution ({optic.PiPPrewarmResolution}) is outside the intended 128-1024 range." );
				}

				if ( optic.PiPFullResolutionAimFraction < 0f || optic.PiPFullResolutionAimFraction > 1f )
				{
					errors++;
					Log.Error( $"{prefix} | Optic '{optic.ResourceName}' PiP Full Resolution Aim Fraction must be within 0..1." );
				}
			}

			if (
				optic.PresentationMode == OMROpticPresentationMode.ModelLensPictureInPicture &&
				optic.UseScreenSpaceHybridOverlay &&
				optic.HybridOverlayFadeFull < optic.HybridOverlayFadeStart
			)
			{
				errors++;
				Log.Error( $"{prefix} | Optic '{optic.ResourceName}' Hybrid Overlay Fade Full must be >= Fade Start." );
			}
			if ( optic.Mode != OMROpticMode.MagnifiedScope )
			{
				warnings++;
				Log.Warning( $"{prefix} | Optic '{optic.ResourceName}' uses PiP presentation while Mode is not MagnifiedScope." );
			}

			if ( optic.PiPResolutionMode == OMRPiPResolutionMode.Fixed )
			{
				if ( optic.PiPRenderResolution < 256 || optic.PiPRenderResolution > 2048 )
				{
					warnings++;
					Log.Warning( $"{prefix} | Optic '{optic.ResourceName}' fixed PiP resolution ({optic.PiPRenderResolution}) is outside the recommended 256-2048 range." );
				}
			}
			else
			{
				if ( optic.PiPMinimumResolution < 256 )
				{
					warnings++;
					Log.Warning( $"{prefix} | Optic '{optic.ResourceName}' PiP minimum resolution is below 256." );
				}

				if ( optic.PiPMaximumResolution < optic.PiPMinimumResolution )
				{
					errors++;
					Log.Error( $"{prefix} | Optic '{optic.ResourceName}' PiP maximum resolution must be >= minimum resolution." );
				}
			}

			if (
				optic.PresentationMode == OMROpticPresentationMode.PictureInPicture &&
				optic.PiPLensDiameterVh <= 0f
			)
			{
				errors++;
				Log.Error( $"{prefix} | Optic '{optic.ResourceName}' screen PiP lens diameter must be greater than zero." );
			}

			if (
				optic.PresentationMode == OMROpticPresentationMode.ModelLensPictureInPicture &&
				optic.PiPCameraPoseMode != OMRPiPCameraPoseMode.MainCamera &&
				string.IsNullOrWhiteSpace( optic.PiPCameraAnchorName )
			)
			{
				errors++;
				Log.Error( $"{prefix} | Optic '{optic.ResourceName}' uses a viewmodel-anchor PiP camera mode but has no camera anchor name." );
			}
		}

		if (
			optic.PresentationMode == OMROpticPresentationMode.SimpleOverlay &&
			optic.Mode != OMROpticMode.MagnifiedScope
		)
		{
			warnings++;
			Log.Warning( $"{prefix} | Optic '{optic.ResourceName}' uses SimpleOverlay presentation while Mode is not MagnifiedScope." );
		}
	}

	private static void ValidateCycleProfile(
		OMRWeaponCycleProfile cycle,
		bool primaryAutomatic,
		string prefix,
		ref int errors,
		ref int warnings
	)
	{
		if ( cycle is null )
			return;

		if ( !cycle.IsValid )
		{
			errors++;
			Log.Error( $"{prefix} | Assigned weapon cycle profile is invalid." );
			return;
		}

		if ( string.IsNullOrWhiteSpace( cycle.CycleId ) )
		{
			warnings++;
			Log.Warning( $"{prefix} | Cycle profile '{cycle.ResourceName}' has an empty Cycle ID." );
		}

		if ( cycle.StartDelay < 0f || cycle.Duration < 0f )
		{
			errors++;
			Log.Error( $"{prefix} | Cycle profile '{cycle.ResourceName}' cannot use negative timing values." );
		}

		if ( cycle.AutoCycleAfterPrimaryShot && cycle.TotalLockoutTime <= 0f )
		{
			warnings++;
			Log.Warning( $"{prefix} | Cycle profile '{cycle.ResourceName}' has zero total lockout time." );
		}

		if ( cycle.AutoCycleAfterPrimaryShot && primaryAutomatic )
		{
			warnings++;
			Log.Warning( $"{prefix} | A manual-cycle profile is assigned to an automatic primary fire mode. Holding attack will fire again as soon as the cycle/fire cooldown permits." );
		}
	}

	private static void ValidateRecoilPatternAsset(
		OMRRecoilPattern pattern,
		string prefix,
		ref int errors,
		ref int warnings
	)
	{
		if ( pattern is null || !pattern.IsValid )
		{
			errors++;
			Log.Error( $"{prefix} | Recoil pattern asset is invalid." );
			return;
		}

		if ( pattern.ShotCount < 1 )
		{
			errors++;
			Log.Error( $"{prefix} | Recoil pattern '{pattern.ResourceName}' ShotCount must be at least 1." );
		}

		if ( pattern.VerticalRise.Length < 2 )
		{
			warnings++;
			Log.Warning( $"{prefix} | Recoil pattern '{pattern.ResourceName}' Vertical Rise has fewer than 2 keys." );
		}

		if ( pattern.HorizontalDrift.Length < 2 )
		{
			warnings++;
			Log.Warning( $"{prefix} | Recoil pattern '{pattern.ResourceName}' Horizontal Drift has fewer than 2 keys." );
		}
	}

	private static void ValidateCyclePresentationAdapter(
		GameObject prefab,
		string label,
		string prefix,
		ref int warnings
	)
	{
		if ( prefab is null || !prefab.IsValid )
			return;

		OMRWeaponModelPresentationAdapter adapter =
			prefab.Components.Get<OMRWeaponModelPresentationAdapter>(
				FindMode.InSelf |
				FindMode.InDescendants
			);

		if ( adapter is not null )
			return;

		warnings++;
		Log.Warning(
			$"{prefix} | {label} '{prefab.Name}' has a cycle profile but no OMRWeaponModelPresentationAdapter. Mechanical cycling will work, but this model will not receive b_cycle/cycle_progress presentation parameters."
		);
	}

	private static void ValidateWeaponModelPrefab(
		GameObject prefab,
		string label,
		string prefix,
		ref int errors,
		ref int warnings
	)
	{
		if (
			prefab is null ||
			!prefab.IsValid
		)
		{
			errors++;

			Log.Error(
				$"{prefix} | {label} prefab is missing."
			);

			return;
		}

		BaseWeaponModel model =
			prefab.Components.Get<BaseWeaponModel>(
				FindMode.InSelf |
				FindMode.InDescendants
			);

		if ( model is null )
		{
			errors++;

			Log.Error(
				$"{prefix} | {label} '{prefab.Name}' has no BaseWeaponModel."
			);

			return;
		}

		bool needsMuzzle =
			model.MuzzleEffect is not null ||
			model.TracerEffect is not null;

		if (
			needsMuzzle &&
			(
				model.MuzzleGameObject is null ||
				!model.MuzzleGameObject.IsValid
			)
		)
		{
			warnings++;

			Log.Warning(
				$"{prefix} | {label} '{prefab.Name}' uses muzzle/tracer effects but MuzzleGameObject is not assigned."
			);
		}

		if (
			model.EjectBrass is not null &&
			(
				model.ShellEjectGameObject is null ||
				!model.ShellEjectGameObject.IsValid
			)
		)
		{
			warnings++;

			Log.Warning(
				$"{prefix} | {label} '{prefab.Name}' ejects brass but ShellEjectGameObject is not assigned."
			);
		}

		SkinnedModelRenderer skinnedRenderer =
			prefab.Components.Get<SkinnedModelRenderer>(
				FindMode.InSelf |
				FindMode.InDescendants
			);

		if (
			skinnedRenderer is not null &&
			model.Renderer is null
		)
		{
			warnings++;

			Log.Warning(
				$"{prefix} | {label} '{prefab.Name}' contains a SkinnedModelRenderer but BaseWeaponModel.Renderer is not assigned."
			);
		}
	}
}