Entities/Abilities/MimicAbility.cs
using System.Collections.Generic;
using System.Linq;

namespace BlockParty;

/// <summary>Persistent run-identity controller for Mimic's temporary character forms.</summary>
public sealed class MimicAbility : PlayerAbility
{
	private enum MimicState
	{
		Waiting,
		Choosing,
		Transformed,
		Finished,
	}

	private const float FIRST_WAIT = 8f;
	private const float REPEAT_WAIT = 8f;
	private const float TRANSFORMED_TIME = 15f;
	// The last stretch before a choice / a return drips a sparse, quickening trickle of specks and
	// swells a rising tone (Swapper's hover oscillator, re-pitched) so the swap never lands
	// unannounced. Cosmetic stream only — never touches the sim.
	private const float WARN_TIME = 3f;
	private const float WARN_INTERVAL_START = 0.22f;
	private const float WARN_INTERVAL_END = 0.05f;
	private const float WARN_TONE_VOLUME = 0.3f;
	private const float WARN_TONE_PITCH_START = 1.0f;
	private const float WARN_TONE_PITCH_END = 2.1f;
	// Calibrated with the eased 0.15x scale to keep the choice near 2.75 wall-clock seconds.
	private const int CHOICE_TICKS = 30;
	// Nine fixed ticks land at about 0.4 wall seconds through the 0.2s ease into 0.15x slow motion.
	private const int CHOICE_INPUT_LOCK_TICKS = 9;
	private const float CHOICE_SCALE = 0.15f;
	private const float CHOICE_HAPTIC_STRENGTH = 0.16f;
	private const float CHOICE_EASE_IN = 0.2f;
	private const float CHOICE_EASE_OUT = 0.2f;
	private const float RETURN_TIME_SCALE = 0.5f;
	private const float RETURN_TOTAL_TIME = 0.5f;
	private const float RETURN_EASE_TIME_IN = 0.05f;
	private const float RETURN_EASE_TIME_OUT = 0.2f;
	private const float PREVIEW_OFFSET = 11f;
	// After a crush-proof squash pops back, guarantee at least this long before the next choice
	// begins (the squash itself pauses the whole ability: Player skips ability ticks while flat).
	private const float UNSQUASH_MIN_WAIT = 1f;

	private readonly HashSet<string> _chosenIds = new();
	private MimicState _state;
	private float _timer;
	private float _warnTimer;
	private int _choiceTicks;
	private CharacterDef _leftChoice;
	private CharacterDef _rightChoice;
	private SpriteRenderer _leftPreview;
	private SpriteRenderer _rightPreview;
	private MimicChoicePulse _leftPulse;
	private MimicChoicePulse _rightPulse;
	private GameplayTimeScale.Handle _choiceSlowMotion;
	private GameplayTimeScale.Handle _returnSlowMotion;
	private SoundHandle _warnTone;

	public override bool PersistsAcrossCharacterChanges => true;

	public override void OnSpawn( Player player )
	{
		_state = MimicState.Waiting;
		_timer = 0f;
	}

	public override void OnRemoved( Player player ) => Finish( player );

	public override void OnDeath( Player player ) => Finish( player );

	public override void OnReplayPresentationRebuilt( Player player )
	{
		_choiceSlowMotion = null;
		_returnSlowMotion = null;
		// Dropped, not carried: the next PreTick re-creates it at the level the rebuilt timer implies.
		StopWarnTone();
		if ( _state == MimicState.Choosing )
		{
			_choiceSlowMotion = GameManager.Instance?.GameplayTime.Acquire(
				CHOICE_SCALE, 0f, CHOICE_EASE_OUT, GameplayTimeEasing.SmoothStep );
			ApplyChoiceShake( player );
			ApplyChoiceAnimation( player );
			SetChoiceFadeInProgress( (float)_choiceTicks / CHOICE_INPUT_LOCK_TICKS );
		}
	}

	public override void PreTick( Player player, float dt )
	{
		if ( _state == MimicState.Finished ) return;
		if ( player.Stage.IsGameOver )
		{
			Finish( player );
			return;
		}

		switch ( _state )
		{
			case MimicState.Waiting:
				_timer += dt;
				float wait = _chosenIds.Count == 0 ? FIRST_WAIT : REPEAT_WAIT;
				TickWarning( player, wait - _timer, dt, toChoice: true );
				if ( _timer >= wait )
					BeginChoice( player );
				break;

			case MimicState.Choosing:
				TickChoice( player );
				break;

			case MimicState.Transformed:
				_timer += dt;
				TickWarning( player, TRANSFORMED_TIME - _timer, dt, toChoice: false );
				if ( _timer >= TRANSFORMED_TIME )
					ReturnToMimic( player );
				break;
		}
	}

	public override void PostTick( Player player, float dt )
	{
		if ( _state != MimicState.Choosing ) return;
		ApplyChoiceAnimation( player );
		UpdatePreviews( player );
		// Held for the whole pick. The choice runs the sim at CHOICE_SCALE, so these asserts are ~7x
		// further apart in real time than usual — Haptics.Sustain measures staleness in sim steps for
		// exactly this case, and the channel stays solid rather than stuttering.
		if ( player.DrivesHaptics )
			Haptics.Sustain( HapticChannel.MimicChoice, CHOICE_HAPTIC_STRENGTH );
	}

	/// <summary>A crush mid-choice cancels the choice cleanly: the previews and the held slow-motion
	/// can't ride out a squash of unknown length (the crusher itself would crawl at choice speed),
	/// so drop back to Waiting and re-roll a fresh pick after the pop's grace period.</summary>
	public override void OnSquashed( Player player )
	{
		// No PreTick while flat, so a live warn tone would hang at its last level; PreTick re-arms it.
		StopWarnTone();
		if ( _state != MimicState.Choosing ) return;
		DestroyPreviews();
		GameManager.Instance?.GameplayTime.Release( _choiceSlowMotion );
		_choiceSlowMotion = null;
		_state = MimicState.Waiting;
		_timer = 0f;
	}

	/// <summary>The squash paused this ability wholesale (no PreTick while flat); on the pop,
	/// guarantee at least a beat of control before the next transformation kicks off.</summary>
	public override void OnUnsquashed( Player player )
	{
		if ( _state != MimicState.Waiting ) return;
		float wait = _chosenIds.Count == 0 ? FIRST_WAIT : REPEAT_WAIT;
		_timer = MathF.Min( _timer, wait - UNSQUASH_MIN_WAIT );
	}

	private void BeginChoice( Player player )
	{
		StopWarnTone();
		PickChoices();
		_state = MimicState.Choosing;
		_choiceTicks = 0;
		_leftPreview = player.Stage.CreateMimicChoiceSprite( _leftChoice, out _leftPulse );
		_rightPreview = player.Stage.CreateMimicChoiceSprite( _rightChoice, out _rightPulse );
		UpdatePreviews( player );
		ApplyChoiceShake( player );
		_choiceSlowMotion = GameManager.Instance?.GameplayTime.Acquire(
			CHOICE_SCALE, CHOICE_EASE_IN, CHOICE_EASE_OUT, GameplayTimeEasing.SmoothStep );
		Audio.PlaySfx( SfxType.Glitter0, player.Position, 0.7f, 1.15f );
		player.Stage.SpawnMimicChoiceSparkles( player.Pos );
		player.ConsumeDirectionalInputForTick();
	}

	private void TickChoice( Player player )
	{
		var input = player.InputSource;
		player.ConsumeDirectionalInputForTick();
		ApplyChoiceShake( player );
		UpdatePreviews( player );

		if ( _choiceTicks < CHOICE_INPUT_LOCK_TICKS )
		{
			_choiceTicks++;
			return;
		}

		// A latched Just edge is always a fresh press: edges are cleared after every sim step (the lock
		// ticks included), so a direction held into or through the lock can never fire one, and a
		// release + re-press that lands entirely between two slow-motion steps still registers.
		if ( input.LeftJust )
		{
			Choose( player, _leftChoice );
			return;
		}
		if ( input.RightJust )
		{
			Choose( player, _rightChoice );
			return;
		}

		_choiceTicks++;
		if ( _choiceTicks >= CHOICE_TICKS )
			Choose( player, TimeoutChoice( player.InputSource ) );
	}

	/// <summary>A side held at the deadline counts as intent even though a held key never fires a choose
	/// edge; only a neutral (or both-held) deadline falls back to a random pick.</summary>
	private CharacterDef TimeoutChoice( IPlayerInput input )
	{
		if ( input.Left && !input.Right ) return _leftChoice;
		if ( input.Right && !input.Left ) return _rightChoice;
		return Rng.Int( 0, 2 ) == 0 ? _leftChoice : _rightChoice;
	}

	private void Choose( Player player, CharacterDef choice )
	{
		_chosenIds.Add( choice.Id );
		player.ClearShakeOffset();
		Audio.PlaySfx( SfxType.TurnInvisible, player.Position, 0.8f, 1.2f );
		player.Stage.SpawnMimicCloudBurst( player.Pos, playSound: false, compact: true );
		DestroyPreviews();
		GameManager.Instance?.GameplayTime.Release( _choiceSlowMotion );
		player.Character = choice;
		_state = MimicState.Transformed;
		_timer = 0f;
	}

	private void ReturnToMimic( Player player )
	{
		StopWarnTone();
		Audio.PlaySfx( SfxType.TurnVisible, player.Position, 0.8f, 1f );
		player.Stage.SpawnMimicCloudBurst( player.Pos, playSound: false, compact: true );
		player.Character = Characters.Mimic;
		_state = MimicState.Waiting;
		_timer = 0f;
		_returnSlowMotion = GameManager.Instance?.GameplayTime.OneShot(
			RETURN_TIME_SCALE,
			RETURN_TOTAL_TIME - RETURN_EASE_TIME_IN - RETURN_EASE_TIME_OUT,
			RETURN_EASE_TIME_IN,
			RETURN_EASE_TIME_OUT,
			GameplayTimeEasing.SmoothStep );
	}

	/// <summary>Drive the pre-swap cues once the deadline is within WARN_TIME: the tone swells from
	/// silence while climbing in pitch, and the speck gaps shrink. Outside the window the tone is off
	/// and the pacing timer pinned at 0 so no backlog is banked.</summary>
	private void TickWarning( Player player, float remaining, float dt, bool toChoice )
	{
		if ( remaining > WARN_TIME )
		{
			_warnTimer = 0f;
			StopWarnTone();
			return;
		}
		float progress = 1f - Math.Clamp( remaining / WARN_TIME, 0f, 1f );
		UpdateWarnTone( progress );

		_warnTimer -= dt;
		if ( _warnTimer > 0f ) return;
		_warnTimer += MathX.Lerp( WARN_INTERVAL_START, WARN_INTERVAL_END, progress );
		EmitWarnParticle( player, toChoice );
	}

	private void UpdateWarnTone( float progress )
	{
		if ( _warnTone is null || !_warnTone.IsValid )
			_warnTone = Audio.PlaySwapperHoverTone( 0f );
		// Eased volume keeps the onset inaudible; pitch climbs linearly so the rise reads as a countdown.
		float volume = WARN_TONE_VOLUME * progress * progress;
		float pitch = MathX.Lerp( WARN_TONE_PITCH_START, WARN_TONE_PITCH_END, progress );
		Audio.SetLoopingSfxProperties( _warnTone, volume, pitch );
	}

	private void StopWarnTone()
	{
		Audio.StopLoopingSfx( _warnTone );
		_warnTone = null;
	}

	/// <summary>One speck drifting slowly off the body in a random direction (no gravity bias, so it
	/// reads the same under reverse/sideways gravity). Choice-bound: the choice sparkle's glints;
	/// return-bound: the return puff's lavender cloud, so each cue previews the swap it precedes.</summary>
	private static void EmitWarnParticle( Player player, bool toChoice )
	{
		var offset = new Vector2(
			Rng.CosmeticFloat( -Player.COLLISION_SIZE.x * 0.5f, Player.COLLISION_SIZE.x * 0.5f ),
			Rng.CosmeticFloat( -Player.COLLISION_SIZE.y * 0.5f, Player.COLLISION_SIZE.y * 0.5f ) );
		float angle = Rng.CosmeticFloat( 0f, MathF.PI * 2f );
		var vel = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * Rng.CosmeticFloat( 5f, 10f );
		float lifetime = Rng.CosmeticFloat( 0.3f, 0.5f );
		if ( toChoice )
		{
			ParticleKind kind = Rng.CosmeticInt( 0, 3 ) == 0 ? ParticleKind.MimicWow0 : ParticleKind.MimicWow1;
			player.Stage.AddFieldParticle( player.Position + offset, vel, 0.94f, 0f, kind, lifetime, 1 );
		}
		else
		{
			ParticleKind kind = Rng.CosmeticValue() < 0.5f ? ParticleKind.MimicCloud0 : ParticleKind.MimicCloud1;
			player.Stage.AddMimicCloud( player.Position + offset, vel, kind, lifetime, Rng.CosmeticInt( 1, 3 ) );
		}
	}

	private void PickChoices()
	{
		List<CharacterDef> all = Characters.All
			.Where( character => character.Id != Characters.Mimic.Id && character.Partner is null )
			.ToList();
		// A runtime form change replaces only this Player, so each twin is a solo Mimic form.
		all.Add( Characters.Twins );
		all.Add( Characters.TwinTwo );
		List<CharacterDef> unseen = all.Where( character => !_chosenIds.Contains( character.Id ) ).ToList();

		if ( unseen.Count >= 2 )
		{
			_leftChoice = TakeRandom( unseen );
			_rightChoice = TakeRandom( unseen );
			return;
		}

		if ( unseen.Count == 1 )
		{
			CharacterDef unseenChoice = unseen[0];
			List<CharacterDef> previous = all.Where( character => character.Id != unseenChoice.Id ).ToList();
			CharacterDef previousChoice = TakeRandom( previous );
			if ( Rng.Int( 0, 2 ) == 0 )
			{
				_leftChoice = unseenChoice;
				_rightChoice = previousChoice;
			}
			else
			{
				_leftChoice = previousChoice;
				_rightChoice = unseenChoice;
			}
			return;
		}

		_leftChoice = TakeRandom( all );
		all.Remove( _leftChoice );
		_rightChoice = TakeRandom( all );
	}

	private static CharacterDef TakeRandom( List<CharacterDef> choices )
	{
		int index = Rng.Int( 0, choices.Count );
		CharacterDef choice = choices[index];
		choices.RemoveAt( index );
		return choice;
	}

	private void UpdatePreviews( Player player )
	{
		SetPreviewPosition( _leftPreview, player.Pos + new Vector2( -PREVIEW_OFFSET, 0f ), player.GravityReversed );
		SetPreviewPosition( _rightPreview, player.Pos + new Vector2( PREVIEW_OFFSET, 0f ), player.GravityReversed );
	}

	private static void SetPreviewPosition( SpriteRenderer preview, Vector2 position, bool flipVertical )
	{
		if ( preview is null ) return;
		preview.FlipVertical = flipVertical;
		preview.GameObject.WorldPosition = new Vector3(
			MathF.Round( position.x ),
			MathF.Round( position.y ),
			Globals.DepthToZ( Globals.DEPTH_MIMIC_CHOICE ) );
	}

	private void ApplyChoiceShake( Player player )
	{
		Vector2 shake = 2f * ((_choiceTicks & 3) switch
		{
			0 => new Vector2( 0.35f, 0.10f ),
			1 => new Vector2( -0.30f, -0.15f ),
			2 => new Vector2( 0.20f, -0.30f ),
			_ => new Vector2( -0.15f, 0.30f ),
		});
		player.SetShakeOffset( shake );
	}

	private void ApplyChoiceAnimation( Player player )
	{
		int frame = _choiceTicks / 2 % 4;
		var sprite = player.BodySpriteRenderer;
		sprite.PlayAnimation( $"transform_{frame}" );
		sprite.FlipHorizontal = _choiceTicks / 8 % 2 == 1;
	}

	private void SetChoiceFadeInProgress( float progress )
	{
		_leftPulse?.SetFadeInProgress( progress );
		_rightPulse?.SetFadeInProgress( progress );
	}

	private void Finish( Player player )
	{
		player.ClearShakeOffset();
		DestroyPreviews();
		StopWarnTone();
		GameManager.Instance?.GameplayTime.Cancel( _choiceSlowMotion );
		GameManager.Instance?.GameplayTime.Cancel( _returnSlowMotion );
		_choiceSlowMotion = null;
		_returnSlowMotion = null;
		_state = MimicState.Finished;
	}

	private void DestroyPreviews()
	{
		GameStage.DestroyOverlaySprite( _leftPreview );
		GameStage.DestroyOverlaySprite( _rightPreview );
		_leftPreview = null;
		_rightPreview = null;
		_leftPulse = null;
		_rightPulse = null;
	}
}