Scoring/ScoreKeeper.cs
using System;
using System.Collections.Generic;
using Sandbox;
using Sandbox.Services;
using SquishyTerry.Sim;

namespace SquishyTerry.Scoring;

/// <summary>
/// The only thing that records scores. It watches the simulation itself (not the UI) and submits
/// the leaderboard stats:
/// <list type="bullet">
/// <item><c>stretch</c>: how far a grabbed point was pulled from rest (head units), best per pull.</item>
/// <item><c>combo</c>: squishes chained within <see cref="ComboWindow"/> of each other.</item>
/// <item><c>squishes</c> / <c>stretches</c>: every pull that snaps back, counted by kind, summed.</item>
/// <item><c>efficiency</c>: how well each pull was executed (<see cref="SquishEfficiency"/>, 0-100%).</item>
/// <item><c>wrinkles</c>: the most wrinkles a single pull caused (<see cref="WrinkleMeter"/>).</item>
/// </list>
/// Tamper resistance, in layers:
/// <list type="bullet">
/// <item>Scores are derived from the simulation's own state each step; nothing public can set them.</item>
/// <item>A squish only counts if this component saw the grab, the pull and the release itself.</item>
/// <item>Physical plausibility limits on every value (reach of the pull, squishes per second, efficiency cap).</item>
/// <item>Anti-macro checks on the mouse input (<see cref="HumanInput"/>).</item>
/// <item>Running totals are obfuscated in memory (<see cref="Guarded"/>); any edit flags the session.</item>
/// <item>A flagged session stops submitting; submissions carry a small evidence payload for auditing.</item>
/// </list>
/// Stats are sent by the s&amp;box services for the signed-in Steam account, so nobody can post for
/// someone else. What a client-side game can't do is stop a fully modified client, so pair this with
/// the leaderboard limits on sbox.game (see the summary in the project notes).
/// </summary>
public sealed class ScoreKeeper : Component
{
	public const string StretchStat = "stretch";
	public const string ComboStat = "combo";
	public const string SquishStat = "squishes";
	public const string EfficiencyStat = "efficiency";
	public const string StretchCountStat = "stretches";
	public const string WrinkleStat = "wrinkles";
	public const string FatalityStat = "fatalities";
	public const string BloodStat = "blood";

	/// <summary>Combo that earns a fatality (the knife drops).</summary>
	public const int FatalityCombo = FatalityGate.Combo;

	/// <summary>Seconds you have to land the next squish before the combo drops (paused while pulling).</summary>
	[Property, Range( 0.5f, 6f )] public float ComboWindow { get; set; } = 2.5f;

	// Plausibility limits. The pull target lies on the plane through the face inside the view, so
	// even at the widest zoom a point can't get much more than ~5 head units from rest.
	const float MaxPlausibleStretch = 6f;
	const float MaxSquishesPerSecond = 8f;
	// Every edge fully compressed can't exceed a few thousand; real pulls land around 150-400.
	const float MaxPlausibleWrinkles = 3000f;
	const int MinPullSteps = 2;
	const float Evidence = 1f; // payload version

	/// <summary>Current combo (0 when none). The rules live in <see cref="ComboTracker"/>.</summary>
	public int Combo => _combo.Combo;

	/// <summary>Seconds since the last counted squish.</summary>
	public float SinceLastSquish => _combo.SinceLast;

	/// <summary>The most recent efficiency result, and how long ago it arrived.</summary>
	public SquishResult? LastEfficiency { get; private set; }
	public float EfficiencyAge { get; private set; } = 999f;

	/// <summary>Wrinkles the most recent pull caused (at its worst moment).</summary>
	public int LastWrinkles { get; private set; }

	/// <summary>True once the session is flagged (tampering or repeated macro strikes): nothing more is submitted.</summary>
	public bool Flagged => _tampered || _input.Flagged || _combo.Tampered || _sessionSquishes.Tampered || _validReleases.Tampered || _fatality.Tampered || _bloodTally.Tampered;

	/// <summary>
	/// The player's lifelong save (bests and totals). Loaded when the game starts and merged with
	/// their server-side stats, so returning players pick up where they left off.
	/// </summary>
	public PlayerProfile Profile { get; private set; }

	/// <summary>A counted squish landed (for UI punch effects).</summary>
	public event Action SquishLanded;

	/// <summary>
	/// A combo ran out of time and is counting down (<see cref="ComboDrain"/>). A squish landed during
	/// the countdown rescues it: the combo continues from the value it had counted down to.
	/// </summary>
	public bool IsDraining => _combo.IsDraining;
	public float DrainElapsed => _combo.DrainElapsed;

	/// <summary>A draining combo was rescued; the argument is the combo it continues at.</summary>
	public event Action<int> Rescued;

	/// <summary>Something was submitted to the stats service (leaderboards may want to refresh).</summary>
	public event Action Submitted;

	/// <summary>Blood so far in the current knife run (ml), and whether one is running.</summary>
	public int BloodMl => _bloodTally.Millilitres;
	public bool Bleeding => _bloodTally.Live;

	/// <summary>A knife run's bleeding ended: the millilitres it scored, and whether that's a new best.</summary>
	public event Action<int, bool> BloodScored;

	SquishyFace _face;
	readonly SquishEfficiency _efficiency = new();
	readonly HumanInput _input = new();

	readonly ComboTracker _combo = new();
	readonly Guarded _sessionSquishes = new();
	readonly Guarded _validReleases = new();
	readonly FatalityGate _fatality = new();
	readonly BloodTally _bloodTally = new();
	bool _tampered;

	// Gesture state.
	bool _mouseWasDown;
	bool _pressHuman = true;
	bool _wasDragging;
	int _dragTriangle = -1;
	int _pullSteps;
	bool _pullValid;
	bool _awaitingSnap;
	Vec3[] _velocityScratch = Array.Empty<Vec3>();

	// Built on the first step from the settled start shape.
	WrinkleMeter _wrinkles;
	Vec3 _centre;
	float _restVolume;
	float _peakWrinkles;
	SquishKind _kind;

	float _saveTimer;
	bool _profileDirty;
	const float SaveInterval = 5f;

	protected override void OnEnabled()
	{
		long steamId = Sandbox.Utility.Steam.SteamId;
		Profile = PlayerProfile.Load( steamId );
		_ = Profile.MergeFromServer();

		_combo.Ended += OnComboEnded;
		_combo.Rescued += OnComboRescued;

		_face = Components.Get<SquishyFace>();
		if ( _face is null ) return;
		_face.Stepped += OnStepped;
		_face.Slapped += OnSnapBack;
	}

	protected override void OnDisabled()
	{
		_combo.End( RealTime.Now );
		_combo.Ended -= OnComboEnded;
		_combo.Rescued -= OnComboRescued;
		Profile?.Save();
		if ( _face is null ) return;
		_face.Stepped -= OnStepped;
		_face.Slapped -= OnSnapBack;
		_face = null;
	}

	protected override void OnUpdate()
	{
		float now = RealTime.Now;

		// Mouse presses feed the anti-macro checks (independent of whether they hit the face).
		bool down = Input.Down( "Attack1" );
		if ( down && !_mouseWasDown ) _pressHuman = _input.Press( now, Mouse.Position.x, Mouse.Position.y );
		else if ( !down && _mouseWasDown ) _input.Release( now, Mouse.Position.x, Mouse.Position.y );
		_mouseWasDown = down;

		// Combo timer runs in real time between squishes (holding a stretch pauses it); when it runs
		// out the combo counts down, and only ends (and is submitted) if nothing rescues it.
		_combo.Window = ComboWindow;
		// The combo clock runs at slow-motion speed, so a slow-mo moment never eats into the window.
		SlowMo.Update( now, RealTime.Delta );
		_combo.Tick( RealTime.Delta * SlowMo.Scale, _face is not null && _face.IsDragging, now );

		EfficiencyAge += RealTime.Delta;

		// Save the profile a few seconds after it changes (and on exit), not every squish.
		if ( _profileDirty && (_saveTimer += RealTime.Delta) >= SaveInterval )
		{
			Profile.Save();
			_profileDirty = false;
			_saveTimer = 0f;
		}
	}

	/// <summary>One simulation step: follow the pull and the settle.</summary>
	void OnStepped()
	{
		var sim = _face?.Sim;
		var rest = _face?.InitialPositions;
		if ( sim is null || rest is null ) return;

		if ( _wrinkles is null )
		{
			_wrinkles = new WrinkleMeter( sim.Indices, rest, sim.Wrinkle );
			_centre = Vec3.Zero;
			foreach ( var p in rest ) _centre += p;
			_centre /= rest.Length;
			_restVolume = MathF.Max( Metrics.Volume( rest, sim.Indices ), 1e-6f );
		}

		bool dragging = _face.IsDragging;

		if ( dragging && !_wasDragging )
		{
			// A new grab: finish scoring the previous pull if the face is still wobbling.
			ReportEfficiency( _efficiency.FinishNow() );

			_dragTriangle = _face.DraggingTriangle;
			_pullSteps = 0;
			_pullValid = _pressHuman && !_input.Suspicious( RealTime.Now );
			_awaitingSnap = false;
			_efficiency.BeginPull( rest[sim.Indices[_dragTriangle * 3]], _centre, 1f );
			_peakWrinkles = 0f;
		}

		if ( dragging )
		{
			_pullSteps++;
			_efficiency.UpdatePull( sim.Pos[sim.Indices[_dragTriangle * 3]], sim.ComputeVolume() / _restVolume );
			_peakWrinkles = MathF.Max( _peakWrinkles, _wrinkles.Measure( sim.Pos ) );
		}
		else if ( _wasDragging )
		{
			_efficiency.Release();
			_kind = _efficiency.Kind;
			float stretch = _efficiency.PeakStretch;
			bool plausible = _pullSteps >= MinPullSteps && stretch <= MaxPlausibleStretch && float.IsFinite( stretch );

			_pullValid &= plausible;
			_awaitingSnap = _pullValid;
			if ( _pullValid )
			{
				_validReleases.Value += 1f;
				float value = MathF.Round( stretch, 3 );
				if ( value > (Profile?.BestStretch ?? 0) && Submit( StretchStat, value, "steps", _pullSteps ) )
					UpdateProfile( p => p.BestStretch = value );
			}
		}
		else if ( _efficiency.IsSettling )
		{
			// The algorithm wants per-second velocities; the sim's are per step (one step = 1/60 s).
			var vel = sim.Vel;
			if ( _velocityScratch.Length != vel.Length ) _velocityScratch = new Vec3[vel.Length];
			for ( int i = 0; i < vel.Length; i++ ) _velocityScratch[i] = vel[i] * 60f;
			// The snap-back is where most wrinkles appear, so keep measuring while it wobbles.
			_peakWrinkles = MathF.Max( _peakWrinkles, _wrinkles.Measure( sim.Pos ) );
			ReportEfficiency( _efficiency.UpdateSettle( _velocityScratch, sim.Pos, rest, 1f / 60f ) );
		}

		_wasDragging = dragging;
	}

	void ReportEfficiency( SquishResult? result )
	{
		if ( result is not { } r ) return;
		bool counted = _pullValid && r.Percent > 0f && r.Percent <= _efficiency.MaxEfficiencyPercent && float.IsFinite( r.Percent );
		LastEfficiency = r;
		EfficiencyAge = 0f;
		LastWrinkles = (int)MathF.Round( _peakWrinkles );

		int wrinkles = LastWrinkles;
		bool wrinklesPlausible = _pullValid && wrinkles > 0 && wrinkles <= MaxPlausibleWrinkles;
		if ( wrinklesPlausible && wrinkles > (Profile?.MostWrinkles ?? 0) && Submit( WrinkleStat, wrinkles, "kind", r.Kind.ToString() ) )
			UpdateProfile( p => p.MostWrinkles = wrinkles );

		if ( counted && Submit( EfficiencyStat, MathF.Round( r.Percent, 1 ), "in", r.InputEnergy ) )
		{
			UpdateProfile( p =>
			{
				p.BestEfficiency = Math.Max( p.BestEfficiency, MathF.Round( r.Percent, 1 ) );
				p.EfficiencySum += MathF.Round( r.Percent, 1 );
				p.EfficiencyCount++;
			} );
		}
	}

	/// <summary>The face's snap-back: counts as a squish only for a pull this component validated.</summary>
	void OnSnapBack()
	{
		if ( !_awaitingSnap ) return;
		_awaitingSnap = false;
		if ( _input.Suspicious( RealTime.Now ) ) { _combo.End( RealTime.Now ); return; }

		// Never more squishes than validated releases.
		if ( _sessionSquishes.Value + 1f > _validReleases.Value ) { _tampered = true; return; }
		_sessionSquishes.Value += 1f;

		_combo.Window = ComboWindow;
		_combo.Hit( RealTime.Now );
		_fatality.OnSquish( _combo.Combo, _combo.ChainSeconds( RealTime.Now ), RealTime.Now );
		if ( _bloodTally.Live ) _bloodTally.Squish();

		if ( !Flagged )
		{
			if ( _kind == SquishKind.Stretch )
			{
				Stats.Increment( StretchCountStat, 1 );
				UpdateProfile( p => p.TotalStretches++ );
			}
			else
			{
				Stats.Increment( SquishStat, 1 );
				UpdateProfile( p => p.TotalSquishes++ );
			}
		}
		SquishLanded?.Invoke();
	}

	void OnComboRescued( int combo ) => Rescued?.Invoke( combo );

	/// <summary>
	/// The knife went all the way in: ends the combo and counts a fatality if <see cref="FatalityGate"/>
	/// agrees it was earned (a genuine, humanly paced chain to <see cref="FatalityCombo"/>, killed in a
	/// possible time, with enough squishes behind it, by human input). Returns whether it counted.
	/// </summary>
	public bool RecordFatality()
	{
		float now = RealTime.Now;
		EndBloodRun();
		_combo.End( now );
		if ( !_fatality.TryRecord( now, (int)_sessionSquishes.Value, _input.Suspicious( now ) ) ) return false;
		if ( Flagged ) return false;

		Stats.Increment( FatalityStat, 1 );
		UpdateProfile( p => p.TotalFatalities++ );
		Submitted?.Invoke();
		return true;
	}

	/// <summary>A combo chain ended: submit its best if it's worth recording and physically possible.</summary>
	void OnComboEnded( int combo, float duration )
	{
		if ( combo < 2 ) return;

		// A chain can never be longer than the squishes actually counted, nor faster than a person can squish.
		bool plausible = combo <= _sessionSquishes.Value && (combo - 1) / duration <= MaxSquishesPerSecond;
		if ( plausible && combo > (Profile?.BestCombo ?? 0) && Submit( ComboStat, combo, "secs", MathF.Round( duration, 2 ) ) )
			UpdateProfile( p => p.BestCombo = combo );
	}

	/// <summary>
	/// The knife went into him. Starts a blood run on the first stab of a knife that was genuinely
	/// earned (an armed fatality), and counts every stab after that.
	/// </summary>
	public void BloodStab()
	{
		float now = RealTime.Now;
		if ( !_bloodTally.Live )
		{
			if ( !_fatality.Armed ) return;
			_bloodTally.Start( now );
		}
		_bloodTally.Stab( now, _input.Suspicious( now ) );
	}

	/// <summary>He's bleeding: see <see cref="BloodTally.Tick"/>.</summary>
	public void BloodTick( float dt, int wounds, float depth01, float motion01 )
		=> _bloodTally.Tick( dt, RealTime.Now, wounds, depth01, motion01 );

	/// <summary>The knife run is over: score the blood (if it holds up) and submit a personal best.</summary>
	public void EndBloodRun()
	{
		if ( !_bloodTally.Live ) return;
		float now = RealTime.Now;
		int ml = _bloodTally.End( now );
		bool best = ml > 0 && ml > (Profile?.MostBlood ?? 0) && Submit( BloodStat, ml, "ml", ml );
		if ( best ) UpdateProfile( p => p.MostBlood = ml );
		BloodScored?.Invoke( ml, best );
	}

	void UpdateProfile( Action<PlayerProfile> change )
	{
		if ( Profile is null ) return;
		change( Profile );
		_profileDirty = true;
	}

	/// <summary>Sends one stat value; returns false if the session is flagged and nothing was sent.</summary>
	bool Submit( string stat, double value, string key, object evidence )
	{
		if ( Flagged ) return false;
		Stats.SetValue( stat, value, new Dictionary<string, object>
		{
			["v"] = Evidence,
			[key] = evidence,
			["n"] = (int)_sessionSquishes.Value,
		} );
		Submitted?.Invoke();
		return true;
	}
}