#region copyright
// ------------------------------------------------------
// Copyright (C) Dmitriy Yukhanov [https://codestage.net]
// ------------------------------------------------------
#endregion

#if UNITY_EDITOR || DEVELOPMENT_BUILD
#define ACTK_DEBUG_ENABLED
#endif

namespace CodeStage.AntiCheat.Detectors
{
	using Common;

	using System;
	using UnityEngine;
	using Utils;

	/// <summary>
	/// Allows to detect Cheat Engine's speed hack (and maybe some other speed hack tools) usage.
	/// </summary>
	/// Just add it to any GameObject as usual or through the "GameObject > Create Other > Code Stage > Anti-Cheat Toolkit"
	/// menu to get started.<br/>
	/// You can use detector completely from inspector without writing any code except the actual reaction on cheating.
	///
	/// Avoid using detectors from code at the Awake phase.
	[AddComponentMenu(MenuPath + ComponentName)]
	[DisallowMultipleComponent]
	[HelpURL(ACTk.DocsRootUrl + "class_code_stage_1_1_anti_cheat_1_1_detectors_1_1_speed_hack_detector.html")]
	public class SpeedHackDetector : ACTkDetectorBase<SpeedHackDetector>
	{
		public const string ComponentName = "Speed Hack Detector";
		internal const string LogPrefix = ACTk.LogPrefix + ComponentName + ": ";

		#region public fields
		/// <summary>
		/// Time (in seconds) between detector checks.
		/// </summary>
		[Tooltip("Time (in seconds) between detector checks.")]
		public float interval = 1f;

		/// <summary>
		/// Allowed speed multiplier threshold. Do not set to too low values (e.g. 0 or 0.00*) since there are timer fluctuations on different hardware.
		/// </summary>
		[Tooltip("Allowed speed multiplier threshold. Do not set to too low values (e.g. 0 or 0.00*) since there are timer fluctuations on different hardware.")]
		[Range(0.05f, 5f)]
		public float threshold = 0.2f;

		/// <summary>
		/// Maximum false positives count allowed before registering speed hack.
		/// </summary>
		[Tooltip("Maximum false positives count allowed before registering speed hack.")]
		public byte maxFalsePositives = 3;

		/// <summary>
		/// Amount of sequential successful checks before clearing internal false positives counter.<br/>
		/// Set 0 to disable Cool Down feature.
		/// </summary>
		[Tooltip("Amount of sequential successful checks before clearing internal false positives counter.\nSet 0 to disable Cool Down feature.")]
		public int coolDown = 30;
		#endregion
		
		/// <summary>
		/// Controls whether to use DSP Timer to catch speed hacks in sandboxed environments (like WebGL, VMs, etc.).
		/// </summary>
		/// Uses AudioSettings.dspTime under the hood, which can catch some extra speed hacks in sandboxed environments
		/// but can potentially cause false positives on some hardware due to way too high sensitivity.
		/// <strong>\htmlonly<font color="FF4040">WARNING:</font>\endhtmlonly Use at your peril!</strong>
		[field:SerializeField, Tooltip("Uses AudioSettings.dspTime under the hood, which can catch some extra speed hacks " +
									   "in sandboxed environments but can potentially cause false positives on some hardware. " +
									   "Use at your peril!")]
		public bool UseDsp { get; set; }

		#region private fields
		private byte currentFalsePositives;
		private int currentCooldownShots;
		private long previousReliableTicks;

		private long previousEnvironmentTicks;
		private long previousRealtimeTicks;
		private long previousDspTicks;
		
		private bool resetTicksOnNextInterval;
		#endregion

		#region public static methods
		/// <summary>
		/// Creates new instance of the detector at scene if it doesn't exists. Make sure to call NOT from Awake phase.
		/// </summary>
		/// <returns>New or existing instance of the detector.</returns>
		public static SpeedHackDetector AddToSceneOrGetExisting()
		{
			return GetOrCreateInstance;
		}

		/// <summary>
		/// Starts speed hack detection for detector you have in scene.
		/// </summary>
		/// Make sure you have properly configured detector in scene with #autoStart disabled before using this method.
		public static SpeedHackDetector StartDetection()
		{
			if (Instance != null)
				return Instance.StartDetectionInternal(null, Instance.interval, Instance.maxFalsePositives, Instance.coolDown);

			Debug.LogError(LogPrefix + "can't be started since it doesn't exists in scene or not yet initialized!");
			return null;
		}

		/// <summary>
		/// Starts speed hack detection with specified callback.
		/// </summary>
		/// If you have detector in scene make sure it has empty Detection Event.<br/>
		/// Creates a new detector instance if it doesn't exists in scene.
		/// <param name="callback">Method to call after detection.</param>
		public static SpeedHackDetector StartDetection(Action callback)
		{
			return StartDetection(callback, GetOrCreateInstance.interval);
		}

		/// <summary>
		/// Starts speed hack detection with specified callback using passed interval.<br/>
		/// </summary>
		/// If you have detector in scene make sure it has empty Detection Event.<br/>
		/// Creates a new detector instance if it doesn't exists in scene.
		/// <param name="callback">Method to call after detection.</param>
		/// <param name="interval">Time in seconds between speed hack checks. Overrides #interval property.</param>
		public static SpeedHackDetector StartDetection(Action callback, float interval)
		{
			return StartDetection(callback, interval, GetOrCreateInstance.maxFalsePositives);
		}

		/// <summary>
		/// Starts speed hack detection with specified callback using passed interval and maxFalsePositives.<br/>
		/// </summary>
		/// If you have detector in scene make sure it has empty Detection Event.<br/>
		/// Creates a new detector instance if it doesn't exists in scene.
		/// <param name="callback">Method to call after detection.</param>
		/// <param name="interval">Time in seconds between speed hack checks. Overrides #interval property.</param>
		/// <param name="maxFalsePositives">Amount of possible false positives. Overrides #maxFalsePositives property.</param>
		public static SpeedHackDetector StartDetection(Action callback, float interval, byte maxFalsePositives)
		{
			return StartDetection(callback, interval, maxFalsePositives, GetOrCreateInstance.coolDown);
		}

		/// <summary>
		/// Starts speed hack detection with specified callback using passed interval, maxFalsePositives and coolDown.
		/// </summary>
		/// If you have detector in scene make sure it has empty Detection Event.<br/>
		/// Creates a new detector instance if it doesn't exists in scene.
		/// <param name="callback">Method to call after detection.</param>
		/// <param name="interval">Time in seconds between speed hack checks. Overrides #interval property.</param>
		/// <param name="maxFalsePositives">Amount of possible false positives. Overrides #maxFalsePositives property.</param>
		/// <param name="coolDown">Amount of sequential successful checks before resetting false positives counter. Overrides #coolDown property.</param>
		public static SpeedHackDetector StartDetection(Action callback, float interval, byte maxFalsePositives, int coolDown)
		{
			return GetOrCreateInstance.StartDetectionInternal(callback, interval, maxFalsePositives, coolDown);
		}

		/// <summary>
		/// Stops detector. Detector's component remains in the scene. Use Dispose() to completely remove detector.
		/// </summary>
		public static void StopDetection()
		{
			if (Instance != null)
				Instance.StopDetectionInternal();
		}

		/// <summary>
		/// Stops and completely disposes detector component.
		/// </summary>
		/// On dispose Detector follows 2 rules:
		/// - if Game Object's name is "Anti-Cheat Toolkit Detectors": it will be automatically
		/// destroyed if no other Detectors left attached regardless of any other components or children;<br/>
		/// - if Game Object's name is NOT "Anti-Cheat Toolkit Detectors": it will be automatically destroyed only
		/// if it has neither other components nor children attached;
		public static void Dispose()
		{
			if (Instance != null)
				Instance.DisposeInternal();
		}
		#endregion
		
#if UNITY_EDITOR
		// making sure it will reset statics even if domain reload is disabled
		[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
		private static void SubsystemRegistration()
		{
			Instance = null;
		}
#endif
		
		/// <summary>
		/// Manually triggers cheating detection and invokes assigned events.
		/// </summary>
		[ContextMenu("Trigger detection")]
		public void TriggerDetection()
		{
			if (!IsRunning)
			{
				Debug.LogWarning(LogPrefix + "Detector is not running, can't trigger detection.");
				return;
			}
			
			OnCheatingDetected();
		}
		
		private SpeedHackDetector() // prevents direct instantiation
		{
#if UNITY_EDITOR
			// prevents in-editor false positives due to PlayMode pause
			UnityEditor.EditorApplication.pauseStateChanged += state =>
			{
				OnApplicationPause(state == UnityEditor.PauseState.Paused);
			};
#endif
		} 

		#region unity messages

#if ACTK_EXCLUDE_OBFUSCATION
		[System.Reflection.Obfuscation(Exclude = true)]
#endif
		private void OnApplicationPause(bool pause)
		{
			if (!pause && IsStarted)
			{
				ResetLastTicks();
				resetTicksOnNextInterval = true;
			}
		}

#if ACTK_EXCLUDE_OBFUSCATION
		[System.Reflection.Obfuscation(Exclude = true)]
#endif
		private void Update()
		{
			if (!IsRunning)
				return;

			var reliableTicks = TimeUtils.GetReliableTicks();
			var intervalTicks = (long)(interval * TimeUtils.TicksPerSecond);

			// return if configured interval is not passed yet
			if (reliableTicks - previousReliableTicks < intervalTicks)
				return;
			
			if (resetTicksOnNextInterval)
			{
				ResetLastTicks();
				resetTicksOnNextInterval = false;
				previousReliableTicks = reliableTicks;
				return;
			}
			
			var reliableDelta = reliableTicks - previousReliableTicks;

			var cheatedEnvironment = IsTicksCheated(TimeUtils.GetEnvironmentTicks(), ref previousEnvironmentTicks, reliableDelta);
			var cheatedRealtime = IsTicksCheated(TimeUtils.GetRealtimeTicks(), ref previousRealtimeTicks, reliableDelta);
			var cheatedDsp = false;

#if UNITY_AUDIO_MODULE
			if (SystemInfo.supportsAudio && UseDsp)
			{
				var dspTicks = TimeUtils.GetDspTicks();
				if (dspTicks != 0 && !AudioListener.pause)
					cheatedDsp = IsTicksCheated(dspTicks, ref previousDspTicks, reliableDelta);
				else
					previousDspTicks = 0;
			}
			else
			{
				previousDspTicks = 0;
			}
#endif

			if (cheatedEnvironment || cheatedRealtime || cheatedDsp)
			{
#if ACTK_DETECTION_BACKLOGS
				Debug.LogWarning(LogPrefix + "Detection backlog:\n" +
								 $"reliableTicks: {reliableTicks}\n" +
								 $"cheatedEnvironment: {cheatedEnvironment}\n" +
								 $"cheatedRealtime: {cheatedRealtime}\n" +
								 $"cheatedDsp: {cheatedDsp}");
#endif
				currentFalsePositives++;
				if (currentFalsePositives > maxFalsePositives)
				{
#if ACTK_DEBUG_ENABLED
					Debug.LogWarning(LogPrefix + "final detection!", this);
#endif
					OnCheatingDetected();
				}
				else
				{
#if ACTK_DEBUG_ENABLED
					Debug.LogWarning(LogPrefix + "detection! Allowed false positives left: " + (maxFalsePositives - currentFalsePositives), this);
#endif
					currentCooldownShots = 0;
					ResetLastTicks();
				}
			}
			else if (currentFalsePositives > 0 && coolDown > 0)
			{
#if ACTK_DEBUG_ENABLED
				Debug.Log(LogPrefix + "success shot! Shots till cool down: " + (coolDown - currentCooldownShots), this);
#endif
				currentCooldownShots++;
				if (currentCooldownShots >= coolDown)
				{
#if ACTK_DEBUG_ENABLED
					Debug.Log(LogPrefix + "cool down!", this);
#endif
					currentFalsePositives = 0;
				}
			}

			previousReliableTicks = reliableTicks;
		}

		#endregion

		private SpeedHackDetector StartDetectionInternal(Action callback, float checkInterval, byte falsePositives, int shotsTillCooldown)
		{
			if (IsRunning)
			{
				Debug.LogWarning(LogPrefix + "already running!", this);
				return this;
			}

			if (!enabled)
			{
				Debug.LogWarning($"{LogPrefix}disabled but {nameof(StartDetection)} still called from somewhere (see stack trace for this message)!", this);
				return this;
			}

			if (callback != null && DetectorHasListeners())
				Debug.LogWarning($"{LogPrefix}has properly configured Detection Event in the inspector or {nameof(CheatDetected)} event subscriber, but still get started with Action callback." +
								 $"Action will be called at the same time with Detection Event or {nameof(CheatDetected)} on detection." +
								 "Are you sure you wish to do this?", this);

			if (callback == null && !DetectorHasListeners())
				Debug.LogWarning($"{LogPrefix}was started without Detection Event, Callback or {nameof(CheatDetected)} event subscription." +
								 $"Cheat will not be detected until you subscribe to {nameof(CheatDetected)} event.", this);

			if (callback != null)
				CheatDetected += callback;
			
			interval = checkInterval;
			maxFalsePositives = falsePositives;
			coolDown = shotsTillCooldown;

			ResetLastTicks();
			currentFalsePositives = 0;
			currentCooldownShots = 0;

			IsStarted = true;
			IsRunning = true;

			return this;
		}

		protected override void StartDetectionAutomatically()
		{
			StartDetectionInternal(null, interval, maxFalsePositives, coolDown);
		}
		
		private bool IsTicksCheated(long ticks, ref long previousTicks, long reliableDelta)
		{
			var delta = ticks - previousTicks;
			var multiplier = Math.Abs(1 - (double)delta / reliableDelta);

			var cheated = multiplier > threshold;
			if (cheated)
			{
#if ACTK_DETECTION_BACKLOGS
				Debug.LogWarning(LogPrefix + "Detection backlog:\n" +
								 $"reliableDelta: {reliableDelta}\n" +
								 $"delta: {delta}\n" +
								 $"multiplier > threshold: {multiplier} > {threshold}\n" +
								 $"ticks: {ticks}");
#endif
			}

			previousTicks = ticks;
			return cheated;
		}

		private void ResetLastTicks()
		{
			previousReliableTicks = TimeUtils.GetReliableTicks();
			previousEnvironmentTicks = TimeUtils.GetEnvironmentTicks();
			previousRealtimeTicks = TimeUtils.GetRealtimeTicks();
			previousDspTicks = TimeUtils.GetDspTicks();
		}
	}
}