#include "HyperTwistSimulation/HyperTwistClassicCubeGameMode.h"

#include "Async/Async.h"
#include "HyperTwistDiagnostics/HyperTwistRuntimeDiagnostics.h"
#include "Blueprint/UserWidget.h"
#include "Components/AudioComponent.h"
#include "Components/DirectionalLightComponent.h"
#include "Engine/GameInstance.h"
#include "Engine/DirectionalLight.h"
#include "Engine/World.h"
#include "EngineUtils.h"
#include "GameFramework/PlayerController.h"
#include "HAL/PlatformFileManager.h"
#include "HAL/PlatformMisc.h"
#include "HyperTwistBootstrap/HyperTwistContractLibrary.h"
#include "HyperTwistRecognition/HyperTwistSpeechLibrary.h"
#include "HyperTwistReplay/HyperTwistReplayPersistenceLibrary.h"
#include "HyperTwistSimulation/HyperTwistClassicCubeActor.h"
#include "HyperTwistSimulation/HyperTwistClassicCubeCommandLibrary.h"
#include "HyperTwistSimulation/HyperTwistClassicCubeHUDWidget.h"
#include "HyperTwistSimulation/HyperTwistClassicCubeOrbitPawn.h"
#include "HyperTwistSimulation/HyperTwistClassicCubePlayerController.h"
#include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h"
#include "HyperTwistSolverLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingSubsystem.h"
#include "HyperTwistUX/HyperTwistPlayerSettings.h"
#include "Interfaces/VoiceCapture.h"
#include "JsonObjectConverter.h"
#include "Kismet/GameplayStatics.h"
#include "Misc/CommandLine.h"
#include "Misc/FileHelper.h"
#include "Misc/Parse.h"
#include "Misc/Paths.h"
#include "Sound/SoundWaveProcedural.h"
#include "UObject/UnrealType.h"
#include "VoiceModule.h"

DEFINE_LOG_CATEGORY_STATIC(LogHyperTwistClassicCubeUi, Log, All);

namespace HyperTwistClassicCubeGameModeInternal
{
	constexpr float AudioDuckMultiplier = 0.15f;
	constexpr int32 WavePcm16BitsPerSample = 16;

	FString FormatMilliseconds(const int32 Milliseconds)
	{
		const int32 SafeMilliseconds = FMath::Max(Milliseconds, 0);
		const int32 Minutes = SafeMilliseconds / 60000;
		const int32 Seconds = (SafeMilliseconds / 1000) % 60;
		const int32 MillisRemainder = SafeMilliseconds % 1000;
		return FString::Printf(TEXT("%02d:%02d.%03d"), Minutes, Seconds, MillisRemainder);
	}

	EHyperTwistTrainingPenalty ResolveEffectivePenalty(
		const FHyperTwistTrainingLiveTimerState& TimerState
	)
	{
		if (TimerState.PendingPenalty != EHyperTwistTrainingPenalty::None)
		{
			return TimerState.PendingPenalty;
		}
		if (TimerState.ManualPenalty != EHyperTwistTrainingPenalty::None)
		{
			return TimerState.ManualPenalty;
		}
		return TimerState.InspectionPenalty;
	}

	FString BuildFinalTimeLabel(const FHyperTwistTrainingLiveTimerState& TimerState)
	{
		const EHyperTwistTrainingPenalty EffectivePenalty = ResolveEffectivePenalty(TimerState);
		if (EffectivePenalty == EHyperTwistTrainingPenalty::DNF)
		{
			return TEXT("DNF");
		}

		int32 FinalTimeMs = FMath::Max(TimerState.SolveElapsedMs, 0);
		if (EffectivePenalty == EHyperTwistTrainingPenalty::Plus2)
		{
			FinalTimeMs += 2000;
		}

		return FormatMilliseconds(FinalTimeMs);
	}

	int32 ResolveFinalTimeMs(const FHyperTwistTrainingLiveTimerState& TimerState)
	{
		const EHyperTwistTrainingPenalty EffectivePenalty = ResolveEffectivePenalty(TimerState);
		if (EffectivePenalty == EHyperTwistTrainingPenalty::DNF)
		{
			return 0;
		}

		int32 FinalTimeMs = FMath::Max(TimerState.SolveElapsedMs, 0);
		if (EffectivePenalty == EHyperTwistTrainingPenalty::Plus2)
		{
			FinalTimeMs += 2000;
		}

		return FinalTimeMs;
	}

	TArray<FString> ParseNotationSequence(const FString& SequenceText)
	{
		TArray<FString> ParsedMoves;
		SequenceText.ParseIntoArrayWS(ParsedMoves);
		return ParsedMoves;
	}

	FString BuildSolutionLine(
		const TArray<FString>& SolutionNotation,
		const bool bShowFullSolutionSequence
	)
	{
		if (SolutionNotation.IsEmpty())
		{
			return TEXT("solution: unavailable");
		}

		if (!bShowFullSolutionSequence)
		{
			return FString::Printf(TEXT("solution: %d moves ready"), SolutionNotation.Num());
		}

		return FString::Printf(
			TEXT("solution: %s"),
			*FString::Join(SolutionNotation, TEXT(" "))
		);
	}

	FString BuildFollowAlongSolutionLine(
		const TArray<FHyperTwistClassicCubeMoveDescriptor>& GuidedMoves,
		const int32 StepIndex
	)
	{
		if (GuidedMoves.IsEmpty())
		{
			return TEXT("solution: waiting for guided sequence");
		}

		if (!GuidedMoves.IsValidIndex(StepIndex))
		{
			return TEXT("solution: guided sequence complete");
		}

		return FString::Printf(
			TEXT("solution: step %d/%d -> %s"),
			StepIndex + 1,
			GuidedMoves.Num(),
			*GuidedMoves[StepIndex].Notation
		);
	}

	bool AreMovesEquivalent(
		const FHyperTwistClassicCubeMoveDescriptor& Left,
		const FHyperTwistClassicCubeMoveDescriptor& Right
	)
	{
		return Left.Face == Right.Face
			&& Left.Direction == Right.Direction
			&& FMath::Max(Left.QuarterTurns, 1) == FMath::Max(Right.QuarterTurns, 1);
	}

	template <typename TStruct>
	bool SerializeStruct(const TStruct& Value, FString& OutJson)
	{
		return FJsonObjectConverter::UStructToJsonObjectString(
			TStruct::StaticStruct(),
			&Value,
			OutJson,
			0,
			0
		);
	}

	template <typename TStruct>
	bool DeserializeStruct(const FString& Json, TStruct& OutValue)
	{
		return !Json.IsEmpty() && FJsonObjectConverter::JsonObjectStringToUStruct(
			Json,
			&OutValue,
			0,
			0
		);
	}

	bool TryBuildClassicFaceletSnapshotState(
		const FString& FaceletString,
		FHyperTwistClassicFaceletSnapshotState& OutFaceletState
	)
	{
		if (FaceletString.Len() != 54)
		{
			return false;
		}

		auto FillFaceTokens = [&FaceletString](
			const int32 StartIndex,
			TArray<FString>& OutFaceTokens
		)
		{
			OutFaceTokens.Reset();
			for (int32 FaceletIndex = 0; FaceletIndex < 9; ++FaceletIndex)
			{
				OutFaceTokens.Add(FString::Chr(FaceletString[StartIndex + FaceletIndex]));
			}
		};

		OutFaceletState = FHyperTwistClassicFaceletSnapshotState();
		OutFaceletState.bPreviewState = false;
		FillFaceTokens(0, OutFaceletState.U);
		FillFaceTokens(9, OutFaceletState.R);
		FillFaceTokens(18, OutFaceletState.F);
		FillFaceTokens(27, OutFaceletState.D);
		FillFaceTokens(36, OutFaceletState.L);
		FillFaceTokens(45, OutFaceletState.B);
		return true;
	}

	bool TryBuildReplayMoveDescriptor(
		const FHyperTwistReplayEvent& Event,
		FHyperTwistClassicCubeMoveDescriptor& OutMoveDescriptor
	)
	{
		FHyperTwistReplayMovePayload MovePayload;
		FString MoveNotation = Event.EventId;
		if (DeserializeStruct(Event.PayloadJson, MovePayload))
		{
			if (!MovePayload.Notation.IsEmpty())
			{
				MoveNotation = MovePayload.Notation;
			}
			else if (!MovePayload.TransformationRef.IsEmpty())
			{
				MoveNotation = MovePayload.TransformationRef;
			}
		}

		return !MoveNotation.IsEmpty()
			&& UHyperTwistClassicCubeCommandLibrary::TryParseMoveNotation(
				MoveNotation,
				OutMoveDescriptor
			);
	}

	void ApplySpeechClientPreference(
		UHyperTwistTrainingSubsystem* TrainingSubsystem,
		const bool bUseMockSpeechClient
	)
	{
		if (TrainingSubsystem == nullptr)
		{
			return;
		}

		if (FStrProperty* SpeechClientKindProperty = FindFProperty<FStrProperty>(
				UHyperTwistTrainingSubsystem::StaticClass(),
				TEXT("CompanionSpeechClientKind")))
		{
			SpeechClientKindProperty->SetPropertyValue_InContainer(
				TrainingSubsystem,
				bUseMockSpeechClient ? TEXT("mock") : TEXT("http")
			);
		}
	}

	void AppendWaveHeader(
		TArray<uint8>& WaveBytes,
		const int32 DataSizeBytes,
		const int32 SampleRateHz,
		const int32 ChannelCount
	)
	{
		const uint32 ByteRate =
			static_cast<uint32>(SampleRateHz * ChannelCount * WavePcm16BitsPerSample / 8);
		const uint16 BlockAlign =
			static_cast<uint16>(ChannelCount * WavePcm16BitsPerSample / 8);
		const uint32 ChunkSize = 36u + static_cast<uint32>(DataSizeBytes);

		auto AppendChars = [&WaveBytes](const ANSICHAR* Literal)
		{
			for (int32 Index = 0; Index < 4; ++Index)
			{
				WaveBytes.Add(static_cast<uint8>(Literal[Index]));
			}
		};
		auto AppendUint32 = [&WaveBytes](const uint32 Value)
		{
			WaveBytes.Add(static_cast<uint8>(Value & 0xFFu));
			WaveBytes.Add(static_cast<uint8>((Value >> 8) & 0xFFu));
			WaveBytes.Add(static_cast<uint8>((Value >> 16) & 0xFFu));
			WaveBytes.Add(static_cast<uint8>((Value >> 24) & 0xFFu));
		};
		auto AppendUint16 = [&WaveBytes](const uint16 Value)
		{
			WaveBytes.Add(static_cast<uint8>(Value & 0xFFu));
			WaveBytes.Add(static_cast<uint8>((Value >> 8) & 0xFFu));
		};

		AppendChars("RIFF");
		AppendUint32(ChunkSize);
		AppendChars("WAVE");
		AppendChars("fmt ");
		AppendUint32(16u);
		AppendUint16(1u);
		AppendUint16(static_cast<uint16>(ChannelCount));
		AppendUint32(static_cast<uint32>(SampleRateHz));
		AppendUint32(ByteRate);
		AppendUint16(BlockAlign);
		AppendUint16(WavePcm16BitsPerSample);
		AppendChars("data");
		AppendUint32(static_cast<uint32>(DataSizeBytes));
	}
}

AHyperTwistClassicCubeGameMode::AHyperTwistClassicCubeGameMode()
{
	PrimaryActorTick.bCanEverTick = true;
	PrimaryActorTick.bStartWithTickEnabled = true;
	PrimaryActorTick.bTickEvenWhenPaused = true;
	PlayerControllerClass = AHyperTwistClassicCubePlayerController::StaticClass();
	DefaultPawnClass = AHyperTwistClassicCubeOrbitPawn::StaticClass();
	CubeSpawnLocation = FVector(0.0f, 0.0f, 120.0f);
	ReplayViewerComponent = CreateDefaultSubobject<UHyperTwistPuzzleViewerComponent>(
		TEXT("ReplayViewerComponent")
	);
}

void AHyperTwistClassicCubeGameMode::RequestClassicCubeFreshAttempt_Implementation()
{
	StartFreshAttempt();
}

void AHyperTwistClassicCubeGameMode::RequestClassicCubeHint_Implementation()
{
	RequestHint();
}

void AHyperTwistClassicCubeGameMode::RequestClassicCubeSubmitSolve_Implementation()
{
	SubmitCurrentSolve();
}

void AHyperTwistClassicCubeGameMode::RequestClassicCubeToggleFollowAlongMode_Implementation()
{
	ToggleFollowAlongMode();
}

void AHyperTwistClassicCubeGameMode::RequestClassicCubeBeginVoiceCommandCapture_Implementation()
{
	BeginVoiceCommandCapture();
}

void AHyperTwistClassicCubeGameMode::RequestClassicCubeEndVoiceCommandCapture_Implementation()
{
	EndVoiceCommandCapture();
}

void AHyperTwistClassicCubeGameMode::RequestClassicCubeCycleVoiceProfile_Implementation()
{
	CycleVoiceProfile();
}

bool AHyperTwistClassicCubeGameMode::CanClassicCubeAcceptGameplayMoveInput_Implementation() const
{
	return CanAcceptGameplayMoveInput();
}

bool AHyperTwistClassicCubeGameMode::IsClassicCubeAnyActionButtonHovered_Implementation() const
{
	return ActiveHudWidget != nullptr && ActiveHudWidget->IsAnyActionButtonHovered();
}

void AHyperTwistClassicCubeGameMode::BeginPlay()
{
	Super::BeginPlay();
	SetActorTickEnabled(true);

	HyperTwistRuntimeDiagnostics::AppendEvent(
		TEXT("MapReady"),
		FString::Printf(
			TEXT("Runtime map ready: %s."),
			GetWorld() != nullptr ? *GetWorld()->GetPathName() : TEXT("unknown")));
	ApplyLaunchOverridesFromCommandLine();

	if (APlayerController* PlayerController =
			GetWorld() != nullptr ? GetWorld()->GetFirstPlayerController() : nullptr)
	{
		PlayerController->bShowMouseCursor = true;
		PlayerController->bEnableClickEvents = true;
		PlayerController->bEnableMouseOverEvents = true;
	}

	ActiveCubeActor = ResolveOrSpawnCubeActor();
	EnsureRuntimeLighting();
	ActiveOrbitPawn = ResolveOrSpawnOrbitPawn();
	if (ActiveCubeActor != nullptr)
	{
		const bool bRecoveredPresentation = ActiveCubeActor->EnsureRenderablePresentation();
		UE_LOG(
			LogHyperTwistClassicCubeUi,
			Display,
			TEXT("Classic cube presentation initialized with %d renderable pieces and orbit pawn %s%s."),
			ActiveCubeActor->GetRenderablePieceCount(),
			ActiveOrbitPawn != nullptr ? TEXT("ready") : TEXT("pending"),
			bRecoveredPresentation ? TEXT(" after runtime geometry recovery") : TEXT("")
		);
		HyperTwistRuntimeDiagnostics::AppendEvent(
			TEXT("ClassicPresentation"),
			FString::Printf(
				TEXT("Classic cube presentation initialized with %d renderable pieces and orbit pawn %s%s."),
				ActiveCubeActor->GetRenderablePieceCount(),
				ActiveOrbitPawn != nullptr ? TEXT("ready") : TEXT("pending"),
				bRecoveredPresentation ? TEXT(" after runtime geometry recovery") : TEXT("")));
	}
	if (bAutoCreateHud)
	{
		ResolveOrCreateHudWidget();
	}

	RefreshVoiceProfiles();
	LoadLocalLeaderboardState();
	RefreshLocalLeaderboardLine();
	ActiveSessionMode = DefaultSessionMode;

	if (!LaunchOverrideReplayPath.IsEmpty())
	{
		FString ResolvedReplayPath;
		if (!LoadReplayFromFile(LaunchOverrideReplayPath, ResolvedReplayPath))
		{
			ResultLineOverride = TEXT("result: replay load failed");
			ReplayLineOverride = TEXT("replay: command-line replay load failed");
			HintLineOverride = TEXT("hint: verify the replay path and packet format");
			ModeLineOverride = TEXT("mode: replay load failed");
			if (bAutoQuitAfterReplayCompletion)
			{
				QueueReplayAutoExit(0.0);
			}
			RefreshHud();
		}
	}
	else
	{
		StartFreshAttempt();
	}
}

void AHyperTwistClassicCubeGameMode::Tick(const float DeltaSeconds)
{
	Super::Tick(DeltaSeconds);

	ResolveOrSpawnCubeActor();
	ResolveOrSpawnOrbitPawn();
	EnsureRuntimeLighting();
	if (bAutoCreateHud)
	{
		ResolveOrCreateHudWidget();
	}

	TickVoiceCommandCapture();
	TickReplayPlayback(DeltaSeconds);
	TickReplayAutoExit(DeltaSeconds);

	UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem();
	bool bCaptureSettledRuntimeFrame = false;
	if (ActiveCubeActor != nullptr)
	{
		if (bAwaitingScrambleSettlement && IsCubeSettled())
		{
			bAwaitingScrambleSettlement = false;
			const FString RenderableStateError =
				ActiveCubeActor->GetRenderableStateValidationError();
			if (RenderableStateError.IsEmpty())
			{
				UE_LOG(
					LogHyperTwistClassicCubeUi,
					Display,
					TEXT("Classic cube scramble settled with %d completed quarter turns, %d queued rotations, and %d renderable pieces; renderable state valid."),
					ActiveCubeActor->GetTotalCompletedMoveCount(),
					ActiveCubeActor->GetQueuedRotationCount(),
					ActiveCubeActor->GetRenderablePieceCount());
				HyperTwistRuntimeDiagnostics::AppendEvent(
					TEXT("ClassicScrambleSettlement"),
					FString::Printf(
						TEXT("Classic cube scramble settled with %d completed quarter turns, %d queued rotations, and %d renderable pieces; renderable state valid."),
						ActiveCubeActor->GetTotalCompletedMoveCount(),
						ActiveCubeActor->GetQueuedRotationCount(),
						ActiveCubeActor->GetRenderablePieceCount()));
			}
			else
			{
				UE_LOG(
					LogHyperTwistClassicCubeUi,
					Error,
					TEXT("Classic cube scramble settled with %d completed quarter turns, %d queued rotations, and %d renderable pieces; renderable state invalid: %s."),
					ActiveCubeActor->GetTotalCompletedMoveCount(),
					ActiveCubeActor->GetQueuedRotationCount(),
					ActiveCubeActor->GetRenderablePieceCount(),
					*RenderableStateError);
				HyperTwistRuntimeDiagnostics::AppendEvent(
					TEXT("ClassicScrambleSettlementError"),
					FString::Printf(
						TEXT("Classic cube scramble settled with %d completed quarter turns, %d queued rotations, and %d renderable pieces; renderable state invalid: %s."),
						ActiveCubeActor->GetTotalCompletedMoveCount(),
						ActiveCubeActor->GetQueuedRotationCount(),
						ActiveCubeActor->GetRenderablePieceCount(),
						*RenderableStateError));
			}
			bCaptureSettledRuntimeFrame =
				HyperTwistRuntimeDiagnostics::IsReadyFrameCaptureRequested();
			if (TrainingSubsystem != nullptr)
			{
				TrainingSubsystem->StartActiveLiveTimer();
				bInspectionStarted = TrainingSubsystem->HasActiveLiveTimer();
			}

			if (!bScrambleReadyNarrated)
			{
				QueueNarrationEvent(
					TEXT("scramble-ready"),
					TEXT("Scramble ready. Inspection time.")
				);
				bScrambleReadyNarrated = true;
			}
			CaptureInitialReplaySnapshotIfNeeded();
			RefreshSolverGuidance();
		}

		ProcessCompletedMoveHistory();
		FinalizeSolvedAttemptIfNeeded();
	}

	RefreshHud();
	if (bCaptureSettledRuntimeFrame)
	{
		RequestRuntimeReadyDiagnosticsCapture();
	}
}

void AHyperTwistClassicCubeGameMode::RequestRuntimeReadyDiagnosticsCapture()
{
	HyperTwistRuntimeDiagnostics::RequestReadyFrameCapture(
		ActiveSessionMode == EHyperTwistClassicCubeSessionMode::FollowAlong
			? TEXT("classic-cube-follow-along-settled")
			: TEXT("classic-cube-free-play-settled"));
}

void AHyperTwistClassicCubeGameMode::StartFreshAttempt()
{
	ActiveCubeActor = ResolveOrSpawnCubeActor();
	if (ActiveCubeActor == nullptr)
	{
		RefreshHud();
		return;
	}

	if (UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem())
	{
		TrainingSubsystem->CancelActiveLiveTimer();
		TrainingSubsystem->StartSampleClassicTrainingRun(
			DefaultUserId,
			BuildSessionId(),
			EHyperTwistTrainingDeliveryMode::Timer
		);
	}

	ActiveSessionMode = DefaultSessionMode;
	ActiveCubeActor->ClearHintedFace();
	ActiveCubeActor->ResetCube();
	const TArray<FString> ScrambleMoves = ResolveScrambleMovesForCurrentSession();
	ActiveCubeActor->ApplyScramble(ScrambleMoves);

	LastCompletedTimerState = FHyperTwistTrainingLiveTimerState();
	LastAttemptStepResult = FHyperTwistTrainingRunStepResult();
	LastVoiceTranscriptResult = FHyperTwistSpeechTranscriptResult();
	ActiveSolutionNotation.Reset();
	ActiveSolutionQuarterTurns.Reset();
	FollowAlongGuideQuarterTurns.Reset();
	ReplayPlaybackMoves.Reset();
	ReplayPlaybackMoveTimesMs.Reset();
	ResultLineOverride = TEXT("result: scramble loaded");
	HintLineOverride = TEXT("hint: waiting for solver guidance");
	ReplayLineOverride = TEXT("replay: armed for local capture");
	ModeLineOverride = ActiveSessionMode == EHyperTwistClassicCubeSessionMode::FollowAlong
		? TEXT("mode: follow-along arming")
		: TEXT("mode: free play");
	VoiceLineOverride = FString::Printf(TEXT("voice: %s"), *SelectedVoiceProfileId);
	bAwaitingScrambleSettlement = true;
	bInspectionStarted = false;
	bSolveStartedFromGameplayMove = false;
	bAttemptComplete = false;
	bAttemptMarkedDnf = false;
	bInitialReplaySnapshotCaptured = false;
	bReplayPlaybackActive = false;
	bScrambleReadyNarrated = false;
	bSolveStartNarrated = false;
	ObservedCompletedMoveCount = 0;
	FollowAlongStepIndex = 0;
	FollowAlongCorrectMoveCount = 0;
	FollowAlongIncorrectMoveCount = 0;
	ReplayPlaybackNextMoveIndex = 0;
	ReplayPlaybackElapsedSeconds = 0.0;
	bHasReplayMetadata = false;

	WriteActiveReplayMetadata();
	RefreshLocalLeaderboardLine();

	RefreshHud();
}

void AHyperTwistClassicCubeGameMode::RequestHint()
{
	RefreshSolverGuidance();
	RefreshHud();
}

void AHyperTwistClassicCubeGameMode::SubmitCurrentSolve()
{
	ActiveCubeActor = ResolveOrSpawnCubeActor();
	if (ActiveCubeActor == nullptr)
	{
		ResultLineOverride = TEXT("result: no active cube to submit");
		return;
	}

	const bool bSolved = IsCubeSettled() && ActiveCubeActor->IsSolved();
	if (UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem())
	{
		if (TrainingSubsystem->HasActiveLiveTimer())
		{
			LastCompletedTimerState = TrainingSubsystem->GetActiveLiveTimerState();
			FHyperTwistStateSnapshot RuntimeSnapshot;
			if (TryBuildRuntimeReplaySnapshot(RuntimeSnapshot))
			{
				TrainingSubsystem->AppendActiveReplayStateSnapshotEvent(RuntimeSnapshot);
			}
			LastAttemptStepResult = TrainingSubsystem->SubmitActiveLiveTimedAttempt(
				bSolved ? EHyperTwistTrainingAttemptResult::Success : EHyperTwistTrainingAttemptResult::DNF
			);
		}
	}

	bAttemptComplete = true;
	bAttemptMarkedDnf = !bSolved;
	ActiveCubeActor->ClearHintedFace();
	if (bSolved)
	{
		ResultLineOverride = TEXT("result: timed solve submitted");
		QueueNarrationEvent(TEXT("solved"), TEXT("Solved!"));
	}
	else
	{
		ResultLineOverride = TEXT("result: DNF, cube not solved");
	}

	FString ReplayExportPath;
	if (ExportActiveReplayToDefaultPath(ReplayExportPath))
	{
		ReplayLineOverride = FString::Printf(TEXT("replay: exported %s"), *FPaths::GetCleanFilename(ReplayExportPath));
	}
	else if (ReplayLineOverride.IsEmpty())
	{
		ReplayLineOverride = TEXT("replay: export unavailable");
	}

	if (bSolved)
	{
		RecordSolvedAttemptToLocalLeaderboard();
	}
}

void AHyperTwistClassicCubeGameMode::ToggleFollowAlongMode()
{
	DefaultSessionMode =
		DefaultSessionMode == EHyperTwistClassicCubeSessionMode::FollowAlong
			? EHyperTwistClassicCubeSessionMode::FreePlay
			: EHyperTwistClassicCubeSessionMode::FollowAlong;
	StartFreshAttempt();
}

bool AHyperTwistClassicCubeGameMode::ExportActiveReplayToDefaultPath(FString& OutResolvedPath)
{
	OutResolvedPath.Reset();
	UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem();
	if (TrainingSubsystem == nullptr
		|| !TrainingSubsystem->SaveActiveReplayPacketToDefaultPath(OutResolvedPath))
	{
		return false;
	}

	LastReplayExportPath = OutResolvedPath;
	return true;
}

bool AHyperTwistClassicCubeGameMode::LoadReplayFromFile(
	const FString& ReplayPath,
	FString& OutResolvedPath
)
{
	const FString EffectivePath = !ReplayPath.IsEmpty() ? ReplayPath : LastReplayExportPath;
	FHyperTwistReplayPacket ReplayPacket;
	if (!UHyperTwistReplayPersistenceLibrary::LoadReplayPacketFromFile(
			EffectivePath,
			ReplayPacket,
			OutResolvedPath))
	{
		return false;
	}

	LastReplayExportPath = OutResolvedPath;
	if (UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem())
	{
		if (TrainingSubsystem->HasActiveRun())
		{
			TrainingSubsystem->ClearActiveRun();
		}
		else
		{
			TrainingSubsystem->CancelActiveLiveTimer();
		}
	}

	LastCompletedTimerState = FHyperTwistTrainingLiveTimerState();
	LastAttemptStepResult = FHyperTwistTrainingRunStepResult();
	LastVoiceTranscriptResult = FHyperTwistSpeechTranscriptResult();
	ReplayPlaybackMoves.Reset();
	ReplayPlaybackMoveTimesMs.Reset();
	ReplayPlaybackElapsedSeconds = 0.0;
	ReplayPlaybackNextMoveIndex = 0;
	bAwaitingScrambleSettlement = false;
	bInspectionStarted = false;
	bSolveStartedFromGameplayMove = false;
	bAttemptComplete = false;
	bAttemptMarkedDnf = false;
	bInitialReplaySnapshotCaptured = false;
	bReplayPlaybackActive = false;
	bScrambleReadyNarrated = false;
	bSolveStartNarrated = false;
	FollowAlongStepIndex = 0;
	FollowAlongCorrectMoveCount = 0;
	FollowAlongIncorrectMoveCount = 0;

	if (ReplayViewerComponent != nullptr)
	{
		ReplayViewerComponent->LoadReplayPacket(ReplayPacket);
	}

	FHyperTwistClassicCubeReplayMetadata ReplayMetadata;
	bHasReplayMetadata =
		HyperTwistClassicCubeGameModeInternal::DeserializeStruct(
			ReplayPacket.AnnotationsJson,
			ReplayMetadata
		)
		&& ReplayMetadata.IsStructurallyValid();
	if (bHasReplayMetadata)
	{
		ActiveReplayMetadata = ReplayMetadata;
		ActiveSessionMode = ReplayMetadata.SessionMode;
		if (!ReplayMetadata.VoiceProfileId.IsEmpty())
		{
			SelectedVoiceProfileId = ReplayMetadata.VoiceProfileId;
		}
	}
	else
	{
		ActiveReplayMetadata = FHyperTwistClassicCubeReplayMetadata();
	}

	RefreshVoiceProfiles();
	ObservedCompletedMoveCount = 0;
	ActiveSolutionNotation.Reset();
	ActiveSolutionQuarterTurns.Reset();
	FollowAlongGuideQuarterTurns.Reset();

	ActiveCubeActor = ResolveOrSpawnCubeActor();
	if (ActiveCubeActor != nullptr)
	{
		ActiveCubeActor->ClearHintedFace();
		ActiveCubeActor->ResetCube();
		if (!ReplayMetadata.ScrambleNotation.IsEmpty())
		{
			ActiveCubeActor->ApplyScrambleImmediately(
				HyperTwistClassicCubeGameModeInternal::ParseNotationSequence(
					ReplayMetadata.ScrambleNotation
				)
			);
		}
		ObservedCompletedMoveCount = ActiveCubeActor->GetCompletedMoveHistory().Num();
	}

	for (const FHyperTwistReplayEvent& Event : ReplayPacket.Events)
	{
		if (Event.EventType != EHyperTwistReplayEventType::Move)
		{
			continue;
		}

		FHyperTwistClassicCubeMoveDescriptor MoveDescriptor;
		if (!HyperTwistClassicCubeGameModeInternal::TryBuildReplayMoveDescriptor(
				Event,
				MoveDescriptor))
		{
			continue;
		}

		MoveDescriptor.bGameplayMove = false;
		ReplayPlaybackMoves.Add(MoveDescriptor);
		ReplayPlaybackMoveTimesMs.Add(FMath::Max(0, Event.TimeMs));
	}

	bReplayPlaybackActive = ActiveCubeActor != nullptr && ReplayPlaybackMoves.Num() > 0;
	ReplayLineOverride = FString::Printf(
		TEXT("replay: loaded %d moves from %s"),
		ReplayPlaybackMoves.Num(),
		*FPaths::GetCleanFilename(OutResolvedPath)
	);
	ResultLineOverride = TEXT("result: replay loaded");
	HintLineOverride = bReplayPlaybackActive
		? TEXT("hint: replay playback armed")
		: TEXT("hint: replay contains no move stream");
	ModeLineOverride = bReplayPlaybackActive
		? TEXT("mode: replay playback")
		: TEXT("mode: replay snapshot");
	VoiceLineOverride = FString::Printf(TEXT("voice: %s"), *SelectedVoiceProfileId);
	RefreshLocalLeaderboardLine();
	RefreshHud();
	return true;
}

void AHyperTwistClassicCubeGameMode::TickReplayPlayback(const float DeltaSeconds)
{
	if (!bReplayPlaybackActive || ActiveCubeActor == nullptr)
	{
		return;
	}

	ReplayPlaybackElapsedSeconds += FMath::Max(DeltaSeconds, 0.0f);
	const int32 ElapsedMs = FMath::RoundToInt(ReplayPlaybackElapsedSeconds * 1000.0);
	while (ReplayPlaybackMoves.IsValidIndex(ReplayPlaybackNextMoveIndex)
		&& ReplayPlaybackMoveTimesMs.IsValidIndex(ReplayPlaybackNextMoveIndex)
		&& ReplayPlaybackMoveTimesMs[ReplayPlaybackNextMoveIndex] <= ElapsedMs)
	{
		ActiveCubeActor->TryQueueMoveDescriptor(ReplayPlaybackMoves[ReplayPlaybackNextMoveIndex]);
		++ReplayPlaybackNextMoveIndex;
	}

	ReplayLineOverride = FString::Printf(
		TEXT("replay: playing %d/%d"),
		FMath::Clamp(ReplayPlaybackNextMoveIndex, 0, ReplayPlaybackMoves.Num()),
		ReplayPlaybackMoves.Num()
	);
	if (ReplayPlaybackNextMoveIndex >= ReplayPlaybackMoves.Num()
		&& ActiveCubeActor->IsSettled())
	{
		bReplayPlaybackActive = false;
		ReplayLineOverride = FString::Printf(
			TEXT("replay: playback complete (%d moves)"),
			ReplayPlaybackMoves.Num()
		);
		ModeLineOverride = TEXT("mode: replay playback complete");
		RefreshSolverGuidance(true);
		if (bAutoQuitAfterReplayCompletion)
		{
			QueueReplayAutoExit(ReplayAutoQuitTailSeconds);
		}
	}
}

void AHyperTwistClassicCubeGameMode::ApplyLaunchOverridesFromCommandLine()
{
	const TCHAR* CommandLine = FCommandLine::Get();
	bLaunchOverrideDisableHud = FParse::Param(CommandLine, TEXT("HyperTwistDisableHud"));
	bAutoQuitAfterReplayCompletion = FParse::Param(
		CommandLine,
		TEXT("HyperTwistAutoQuitAfterReplay")
	);

	if (bLaunchOverrideDisableHud)
	{
		bAutoCreateHud = false;
	}

	FString ReplayPathOverride;
	if (FParse::Value(CommandLine, TEXT("HyperTwistReplayPath="), ReplayPathOverride))
	{
		ReplayPathOverride.TrimQuotesInline();
		LaunchOverrideReplayPath = ReplayPathOverride;
	}

	double ParsedTailSeconds = ReplayAutoQuitTailSeconds;
	if (FParse::Value(CommandLine, TEXT("HyperTwistReplayAutoQuitTailSeconds="), ParsedTailSeconds))
	{
		ReplayAutoQuitTailSeconds = FMath::Max(ParsedTailSeconds, 0.0);
	}
}

void AHyperTwistClassicCubeGameMode::QueueReplayAutoExit(const double CountdownSeconds)
{
	bReplayAutoQuitPending = true;
	ReplayAutoQuitCountdownSeconds = FMath::Max(CountdownSeconds, 0.0);
	HintLineOverride = FString::Printf(
		TEXT("hint: exiting render lane in %.1fs"),
		ReplayAutoQuitCountdownSeconds
	);
}

void AHyperTwistClassicCubeGameMode::TickReplayAutoExit(const float DeltaSeconds)
{
	if (!bReplayAutoQuitPending || ReplayAutoQuitCountdownSeconds < 0.0)
	{
		return;
	}

	ReplayAutoQuitCountdownSeconds -= FMath::Max(static_cast<double>(DeltaSeconds), 0.0);
	if (ReplayAutoQuitCountdownSeconds > 0.0)
	{
		return;
	}

	bReplayAutoQuitPending = false;
	ReplayAutoQuitCountdownSeconds = -1.0;
	FGenericPlatformMisc::RequestExit(false);
}

FString AHyperTwistClassicCubeGameMode::ResolveActivePuzzleId() const
{
	if (bHasReplayMetadata
		&& ActiveReplayMetadata.IsStructurallyValid()
		&& !ActiveReplayMetadata.PuzzleId.IsEmpty())
	{
		return ActiveReplayMetadata.PuzzleId;
	}

	if (const UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem())
	{
		const FHyperTwistTrainingRunState RunState = TrainingSubsystem->GetActiveRunState();
		if (RunState.CurrentSelection.TrainingCase.IsStructurallyValid()
			&& !RunState.CurrentSelection.TrainingCase.PuzzleId.IsEmpty())
		{
			return RunState.CurrentSelection.TrainingCase.PuzzleId;
		}
	}

	return TEXT("cube/3x3x3");
}

void AHyperTwistClassicCubeGameMode::WriteActiveReplayMetadata()
{
	FHyperTwistClassicCubeReplayMetadata ReplayMetadata;
	ReplayMetadata.PuzzleId = ResolveActivePuzzleId();
	ReplayMetadata.ScrambleNotation =
		ActiveCubeActor != nullptr ? ActiveCubeActor->GetCurrentScrambleNotation() : FString();
	ReplayMetadata.ScrambleLength =
		HyperTwistClassicCubeGameModeInternal::ParseNotationSequence(
			ReplayMetadata.ScrambleNotation
		).Num();
	ReplayMetadata.SessionMode = ActiveSessionMode;
	ReplayMetadata.VoiceProfileId = SelectedVoiceProfileId;
	ActiveReplayMetadata = ReplayMetadata;
	bHasReplayMetadata = ReplayMetadata.IsStructurallyValid();

	UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem();
	if (!bHasReplayMetadata || TrainingSubsystem == nullptr)
	{
		return;
	}

	FString MetadataJson;
	if (HyperTwistClassicCubeGameModeInternal::SerializeStruct(ReplayMetadata, MetadataJson))
	{
		TrainingSubsystem->SetActiveReplayAnnotationsJson(MetadataJson);
	}
}

void AHyperTwistClassicCubeGameMode::CaptureInitialReplaySnapshotIfNeeded()
{
	if (bInitialReplaySnapshotCaptured)
	{
		return;
	}

	UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem();
	if (TrainingSubsystem == nullptr)
	{
		return;
	}

	FHyperTwistStateSnapshot RuntimeSnapshot;
	if (!TryBuildRuntimeReplaySnapshot(RuntimeSnapshot))
	{
		return;
	}

	if (TrainingSubsystem->AppendActiveReplayStateSnapshotEvent(RuntimeSnapshot, 0))
	{
		bInitialReplaySnapshotCaptured = true;
		ReplayLineOverride = TEXT("replay: capturing current attempt");
	}
}

bool AHyperTwistClassicCubeGameMode::TryBuildRuntimeReplaySnapshot(
	FHyperTwistStateSnapshot& OutSnapshot
) const
{
	OutSnapshot = FHyperTwistStateSnapshot();
	if (ActiveCubeActor == nullptr || !ActiveCubeActor->IsSettled())
	{
		return false;
	}

	const FString FaceletString = ActiveCubeActor->GetSolverFaceletString();
	if (FaceletString.IsEmpty())
	{
		return false;
	}

	FHyperTwistClassicFaceletSnapshotState FaceletState;
	if (!HyperTwistClassicCubeGameModeInternal::TryBuildClassicFaceletSnapshotState(
			FaceletString,
			FaceletState))
	{
		return false;
	}

	FString PayloadJson;
	if (!HyperTwistClassicCubeGameModeInternal::SerializeStruct(FaceletState, PayloadJson))
	{
		return false;
	}

	FString SessionId = TEXT("classic-runtime");
	if (const UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem())
	{
		const FHyperTwistTrainingRunState RunState = TrainingSubsystem->GetActiveRunState();
		if (RunState.Session.IsStructurallyValid()
			&& !RunState.Session.TrainingSessionId.IsEmpty())
		{
			SessionId = RunState.Session.TrainingSessionId;
		}
	}

	FHyperTwistPuzzleDefinitionRef Definition;
	Definition.PuzzleId = ResolveActivePuzzleId();
	Definition.PuzzleFamily = EHyperTwistPuzzleFamily::ClassicCube;
	Definition.Dimension = 3;
	Definition.DefinitionVersion = TEXT("2026.06");
	Definition.NotationProfile = TEXT("classic-wca");
	Definition.SizeVector = {3, 3, 3};

	FHyperTwistPuzzleState RuntimeState;
	RuntimeState.Definition = Definition;
	RuntimeState.StateEncodingKind = EHyperTwistStateEncodingKind::Facelet;
	RuntimeState.StateEncoding.EncodingProfile = FaceletState.EncodingProfile;
	RuntimeState.StateEncoding.PayloadJson = PayloadJson;
	RuntimeState.OrientationFrame.Reference = TEXT("classic-runtime-facelet-v1");
	RuntimeState.bIsSolved = ActiveCubeActor->IsSolved();
	RuntimeState.Source = EHyperTwistStateSource::Runtime;
	RuntimeState.CapturedAtUtc = FDateTime::UtcNow().ToIso8601();
	RuntimeState.SourceConfidence = 1.0f;
	RuntimeState.SourceSessionId = SessionId;
	RuntimeState.Notes = TEXT("Classic-cube runtime replay snapshot.");

	OutSnapshot.SnapshotId = FString::Printf(
		TEXT("classic_runtime_%s_%s"),
		*SessionId,
		*FGuid::NewGuid().ToString(EGuidFormats::Digits)
	);
	OutSnapshot.State = RuntimeState;
	OutSnapshot.DerivedHash = FString::Printf(
		TEXT("classic-runtime-%s-%s"),
		*SessionId,
		*FaceletString
	);
	return OutSnapshot.IsStructurallyValid();
}

void AHyperTwistClassicCubeGameMode::LoadLocalLeaderboardState()
{
	FString ResolvedPath;
	if (!UHyperTwistClassicCubeLeaderboardLibrary::LoadLeaderboardStateFromFile(
			FString(),
			LocalLeaderboardState,
			ResolvedPath))
	{
		LocalLeaderboardState = FHyperTwistClassicCubeLeaderboardState();
		LocalLeaderboardResolvedPath =
			UHyperTwistClassicCubeLeaderboardLibrary::ResolveLeaderboardPath(FString());
		return;
	}

	LocalLeaderboardResolvedPath = ResolvedPath;
}

void AHyperTwistClassicCubeGameMode::RefreshLocalLeaderboardLine()
{
	int32 CurrentScrambleLength = ScrambleLength;
	if (ActiveCubeActor != nullptr)
	{
		const int32 ResolvedLength = HyperTwistClassicCubeGameModeInternal::ParseNotationSequence(
			ActiveCubeActor->GetCurrentScrambleNotation()
		).Num();
		if (ResolvedLength > 0)
		{
			CurrentScrambleLength = ResolvedLength;
		}
		else if (bHasReplayMetadata && ActiveReplayMetadata.ScrambleLength > 0)
		{
			CurrentScrambleLength = ActiveReplayMetadata.ScrambleLength;
		}
	}
	else if (bHasReplayMetadata && ActiveReplayMetadata.ScrambleLength > 0)
	{
		CurrentScrambleLength = ActiveReplayMetadata.ScrambleLength;
	}

	LeaderboardLineOverride =
		UHyperTwistClassicCubeLeaderboardLibrary::BuildLeaderboardStatusLine(
			LocalLeaderboardState,
			ResolveActivePuzzleId(),
			CurrentScrambleLength
		);
}

void AHyperTwistClassicCubeGameMode::RecordSolvedAttemptToLocalLeaderboard()
{
	const int32 FinalTimeMs =
		HyperTwistClassicCubeGameModeInternal::ResolveFinalTimeMs(LastCompletedTimerState);
	if (FinalTimeMs <= 0)
	{
		return;
	}

	FHyperTwistClassicCubeLeaderboardEntry Entry;
	Entry.PuzzleId = ResolveActivePuzzleId();
	Entry.ScrambleNotation =
		ActiveCubeActor != nullptr ? ActiveCubeActor->GetCurrentScrambleNotation() : FString();
	Entry.ScrambleLength = HyperTwistClassicCubeGameModeInternal::ParseNotationSequence(
		Entry.ScrambleNotation
	).Num();
	if (Entry.ScrambleLength <= 0)
	{
		Entry.ScrambleLength = bHasReplayMetadata && ActiveReplayMetadata.ScrambleLength > 0
			? ActiveReplayMetadata.ScrambleLength
			: FMath::Max(ScrambleLength, 0);
	}
	Entry.FinalTimeMs = FinalTimeMs;
	Entry.RecordedAtUtc = FDateTime::UtcNow().ToIso8601();

	if (UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem())
	{
		const FHyperTwistTrainingRunState RunState = TrainingSubsystem->GetActiveRunState();
		Entry.ReplayId = RunState.ReplayPacket.ReplayId;
		Entry.SessionId = RunState.Session.TrainingSessionId;
	}

	LocalLeaderboardState = UHyperTwistClassicCubeLeaderboardLibrary::RecordBestTime(
		LocalLeaderboardState,
		Entry
	);
	FString ResolvedPath;
	if (UHyperTwistClassicCubeLeaderboardLibrary::SaveLeaderboardStateToFile(
			LocalLeaderboardState,
			FString(),
			ResolvedPath))
	{
		LocalLeaderboardResolvedPath = ResolvedPath;
	}
	RefreshLocalLeaderboardLine();
}

bool AHyperTwistClassicCubeGameMode::BeginVoiceCommandCapture()
{
	const FHyperTwistPlayerPreferences Preferences =
		UHyperTwistPlayerSettingsLibrary::LoadPreferences();
	if (!Preferences.bSpeechInputEnabled)
	{
		ResultLineOverride =
			TEXT("result: speech input is disabled in Settings > Voice & AI");
		return false;
	}
	const bool bCloudSpeechProvider =
		Preferences.SpeechProviderId == TEXT("openai")
		|| Preferences.SpeechProviderId == TEXT("groq")
		|| Preferences.SpeechProviderId == TEXT("deepgram")
		|| Preferences.SpeechProviderId == TEXT("elevenlabs");
	if (bCloudSpeechProvider && !Preferences.bAllowCloudProviders)
	{
		ResultLineOverride =
			TEXT("result: enable cloud providers before using this speech route");
		return false;
	}
	if (!bEnableVoiceCommandCapture || bVoiceCommandCaptureActive)
	{
		return bVoiceCommandCaptureActive;
	}

	UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem();
	HyperTwistClassicCubeGameModeInternal::ApplySpeechClientPreference(
		TrainingSubsystem,
		bUseMockSpeechClient
	);

	FString OpenError;
	if (!UHyperTwistSpeechLibrary::StartDictationSession(
			this,
			OpenError,
			true,
			HyperTwistClassicCubeGameModeInternal::AudioDuckMultiplier))
	{
		VoiceLineOverride = FString::Printf(TEXT("voice: %s"), *OpenError);
		return false;
	}

	if (!FVoiceModule::Get().DoesPlatformSupportVoiceCapture())
	{
		FString CloseError;
		UHyperTwistSpeechLibrary::EndDictationSession(this, CloseError, true);
		VoiceLineOverride = TEXT("voice: capture unsupported on this platform");
		return false;
	}

	ActiveVoiceCapture = FVoiceModule::Get().CreateVoiceCapture(
		Preferences.SpeechMicrophoneId.Equals(
			TEXT("system-default"),
			ESearchCase::IgnoreCase)
				? TEXT("")
				: *Preferences.SpeechMicrophoneId,
		VoiceCaptureSampleRateHz,
		VoiceCaptureChannelCount
	);
	if (!ActiveVoiceCapture.IsValid() || !ActiveVoiceCapture->Start())
	{
		FString CloseError;
		UHyperTwistSpeechLibrary::EndDictationSession(this, CloseError, true);
		VoiceLineOverride = TEXT("voice: failed to start microphone capture");
		return false;
	}

	CapturedVoicePcmBytes.Reset();
	ActiveVoiceUtteranceId = FGuid::NewGuid().ToString(EGuidFormats::Digits);
	VoiceCaptureStartedAtSeconds = GetWorld() != nullptr ? GetWorld()->GetTimeSeconds() : 0.0;
	bVoiceCommandCaptureActive = true;
	VoiceLineOverride = FString::Printf(TEXT("voice: listening on %s"), *SelectedVoiceProfileId);
	ResultLineOverride = TEXT("result: voice capture armed");
	return true;
}

void AHyperTwistClassicCubeGameMode::EndVoiceCommandCapture()
{
	if (!bVoiceCommandCaptureActive)
	{
		return;
	}

	FString SubmissionError;
	if (!FinalizeVoiceCommandCaptureSubmission(SubmissionError))
	{
		VoiceLineOverride = FString::Printf(TEXT("voice: %s"), *SubmissionError);
	}
}

void AHyperTwistClassicCubeGameMode::CycleVoiceProfile()
{
	RefreshVoiceProfiles();
	if (AvailableVoiceProfiles.IsEmpty())
	{
		VoiceLineOverride = TEXT("voice: no profiles reported");
		return;
	}

	int32 CurrentIndex = INDEX_NONE;
	for (int32 ProfileIndex = 0; ProfileIndex < AvailableVoiceProfiles.Num(); ++ProfileIndex)
	{
		if (AvailableVoiceProfiles[ProfileIndex].VoiceProfileId.Equals(
				SelectedVoiceProfileId,
				ESearchCase::IgnoreCase))
		{
			CurrentIndex = ProfileIndex;
			break;
		}
	}

	const int32 NextIndex = CurrentIndex == INDEX_NONE
		? 0
		: (CurrentIndex + 1) % AvailableVoiceProfiles.Num();
	SelectedVoiceProfileId = AvailableVoiceProfiles[NextIndex].VoiceProfileId;
	VoiceLineOverride = FString::Printf(TEXT("voice: %s"), *SelectedVoiceProfileId);
	WriteActiveReplayMetadata();
}

bool AHyperTwistClassicCubeGameMode::CanAcceptGameplayMoveInput() const
{
	return ActiveCubeActor != nullptr
		&& !bAwaitingScrambleSettlement
		&& !bAttemptComplete
		&& !bVoiceCommandCaptureActive
		&& IsCubeSettled();
}

bool AHyperTwistClassicCubeGameMode::IsVoiceCommandCaptureActive() const
{
	return bVoiceCommandCaptureActive;
}

UHyperTwistTrainingSubsystem* AHyperTwistClassicCubeGameMode::ResolveTrainingSubsystem() const
{
	if (GetWorld() == nullptr)
	{
		return nullptr;
	}

	if (UGameInstance* GameInstance = GetWorld()->GetGameInstance())
	{
		return GameInstance->GetSubsystem<UHyperTwistTrainingSubsystem>();
	}

	return nullptr;
}

AHyperTwistClassicCubeActor* AHyperTwistClassicCubeGameMode::ResolveOrSpawnCubeActor()
{
	if (ActiveCubeActor != nullptr)
	{
		return ActiveCubeActor;
	}

	if (GetWorld() == nullptr)
	{
		return nullptr;
	}

	for (TActorIterator<AHyperTwistClassicCubeActor> ActorIt(GetWorld()); ActorIt; ++ActorIt)
	{
		ActiveCubeActor = *ActorIt;
		break;
	}

	if (ActiveCubeActor == nullptr && bAutoSpawnCubeActor)
	{
		ActiveCubeActor = GetWorld()->SpawnActor<AHyperTwistClassicCubeActor>(
			AHyperTwistClassicCubeActor::StaticClass(),
			CubeSpawnLocation,
			CubeSpawnRotation
		);
	}

	return ActiveCubeActor;
}

AHyperTwistClassicCubeOrbitPawn* AHyperTwistClassicCubeGameMode::ResolveOrSpawnOrbitPawn()
{
	if (GetWorld() == nullptr)
	{
		return nullptr;
	}

	APlayerController* PlayerController = GetWorld()->GetFirstPlayerController();
	if (PlayerController == nullptr)
	{
		return nullptr;
	}

	if (AHyperTwistClassicCubeOrbitPawn* PossessedOrbitPawn =
			Cast<AHyperTwistClassicCubeOrbitPawn>(PlayerController->GetPawn()))
	{
		ActiveOrbitPawn = PossessedOrbitPawn;
		return ActiveOrbitPawn;
	}

	if (ActiveOrbitPawn == nullptr)
	{
		for (TActorIterator<AHyperTwistClassicCubeOrbitPawn> ActorIt(GetWorld()); ActorIt; ++ActorIt)
		{
			if (ActorIt->GetController() == nullptr || ActorIt->GetController() == PlayerController)
			{
				ActiveOrbitPawn = *ActorIt;
				break;
			}
		}
	}

	if (ActiveOrbitPawn == nullptr && bEnsurePlayableOrbitPawn)
	{
		FActorSpawnParameters SpawnParameters;
		SpawnParameters.Owner = PlayerController;
		SpawnParameters.SpawnCollisionHandlingOverride =
			ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
		const FVector SpawnLocation = ActiveCubeActor != nullptr
			? ActiveCubeActor->GetActorLocation()
			: CubeSpawnLocation;
		ActiveOrbitPawn = GetWorld()->SpawnActor<AHyperTwistClassicCubeOrbitPawn>(
			AHyperTwistClassicCubeOrbitPawn::StaticClass(),
			SpawnLocation,
			FRotator::ZeroRotator,
			SpawnParameters
		);
		if (ActiveOrbitPawn != nullptr)
		{
			UE_LOG(
				LogHyperTwistClassicCubeUi,
				Warning,
				TEXT("Recovered a missing authored PlayerStart/default pawn with a runtime orbit pawn.")
			);
		}
	}

	if (ActiveOrbitPawn != nullptr && PlayerController->GetPawn() != ActiveOrbitPawn)
	{
		PlayerController->Possess(ActiveOrbitPawn);
		PlayerController->SetViewTarget(ActiveOrbitPawn);
	}

	return ActiveOrbitPawn;
}

void AHyperTwistClassicCubeGameMode::EnsureRuntimeLighting()
{
	if (bRuntimeLightingReady || GetWorld() == nullptr)
	{
		return;
	}
	if (!bEnsureRuntimeLighting)
	{
		bRuntimeLightingReady = true;
		return;
	}

	for (TActorIterator<ADirectionalLight> ActorIt(GetWorld()); ActorIt; ++ActorIt)
	{
		if (ActorIt->GetLightComponent() != nullptr
			&& ActorIt->GetLightComponent()->IsVisible())
		{
			bRuntimeLightingReady = true;
			return;
		}
	}

	FActorSpawnParameters SpawnParameters;
	SpawnParameters.SpawnCollisionHandlingOverride =
		ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
	ADirectionalLight* RuntimeLight = GetWorld()->SpawnActor<ADirectionalLight>(
		ADirectionalLight::StaticClass(),
		CubeSpawnLocation + FVector(-240.0f, -180.0f, 320.0f),
		FRotator(-38.0f, -42.0f, 0.0f),
		SpawnParameters
	);
	UDirectionalLightComponent* LightComponent = RuntimeLight != nullptr
		? Cast<UDirectionalLightComponent>(RuntimeLight->GetLightComponent())
		: nullptr;
	if (LightComponent == nullptr)
	{
		return;
	}

	LightComponent->SetMobility(EComponentMobility::Movable);
	LightComponent->SetIntensity(8.0f);
	LightComponent->SetLightColor(FLinearColor(0.80f, 0.90f, 1.0f));
	RuntimeLight->Tags.AddUnique(FName(TEXT("HyperTwistRuntimePresentationLight")));
	bRuntimeLightingReady = true;
	UE_LOG(
		LogHyperTwistClassicCubeUi,
		Warning,
		TEXT("Recovered missing authored lighting with the runtime presentation key light.")
	);
}

UHyperTwistClassicCubeHUDWidget* AHyperTwistClassicCubeGameMode::ResolveOrCreateHudWidget()
{
	if (ActiveHudWidget != nullptr)
	{
		return ActiveHudWidget;
	}

	if (GetWorld() == nullptr)
	{
		return nullptr;
	}

	APlayerController* PlayerController = GetWorld()->GetFirstPlayerController();
	if (PlayerController == nullptr)
	{
		return nullptr;
	}

	const TSubclassOf<UHyperTwistClassicCubeHUDWidget> ResolvedClass =
		HudWidgetClass != nullptr
			? HudWidgetClass
			: TSubclassOf<UHyperTwistClassicCubeHUDWidget>(UHyperTwistClassicCubeHUDWidget::StaticClass());
	ActiveHudWidget = CreateWidget<UHyperTwistClassicCubeHUDWidget>(PlayerController, ResolvedClass);
	if (ActiveHudWidget != nullptr)
	{
		if (!ActiveHudWidget->PrepareClassicCubeHudSurface())
		{
			UE_LOG(
				LogHyperTwistClassicCubeUi,
				Error,
				TEXT("Classic cube HUD creation produced no renderable root widget.")
			);
			ActiveHudWidget = nullptr;
			return nullptr;
		}
		ActiveHudWidget->AddToViewport(HudZOrder);
	}

	return ActiveHudWidget;
}

void AHyperTwistClassicCubeGameMode::RefreshHud()
{
	if (ResolveOrCreateHudWidget() == nullptr)
	{
		return;
	}

	FString StatusLine = TEXT("status: waiting for classic cube actor");
	FString ScrambleLine = TEXT("scramble: pending");
	FString TimerLine = TEXT("timer: idle");
	FString InspectionLine = TEXT("inspection: pending");
	FString ResultLine = ResultLineOverride.IsEmpty()
		? TEXT("result: waiting for first attempt")
		: ResultLineOverride;
	FString HintLine = HintLineOverride.IsEmpty()
		? TEXT("hint: request solver guidance")
		: HintLineOverride;
	FString SolutionLine =
		ActiveSessionMode == EHyperTwistClassicCubeSessionMode::FollowAlong
			? HyperTwistClassicCubeGameModeInternal::BuildFollowAlongSolutionLine(
				FollowAlongGuideQuarterTurns,
				FollowAlongStepIndex
			)
			: (ActiveSolutionNotation.IsEmpty()
				? TEXT("solution: unavailable")
				: HyperTwistClassicCubeGameModeInternal::BuildSolutionLine(
					ActiveSolutionNotation,
					bShowFullSolutionSequence
				));
	FString ModeLine = ModeLineOverride.IsEmpty()
		? (ActiveSessionMode == EHyperTwistClassicCubeSessionMode::FollowAlong
			? TEXT("mode: follow-along")
			: TEXT("mode: free play"))
		: ModeLineOverride;
	FString ReplayLine = ReplayLineOverride.IsEmpty()
		? TEXT("replay: armed for local capture")
		: ReplayLineOverride;
	FString LeaderboardLine = LeaderboardLineOverride.IsEmpty()
		? TEXT("leaderboard: no local best yet")
		: LeaderboardLineOverride;
	FString TrainingLine;
	const FString ControlsLine =
		TEXT("controls: LMB clockwise, RMB counter-clockwise, touch clockwise, MMB drag orbit, wheel zoom, R scramble, H hint, Enter submit, F mode, F3 coach, Esc settings");

	UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem();
	FHyperTwistTrainingLiveTimerState LiveTimerState;
	const bool bHasLiveTimer = TrainingSubsystem != nullptr && TrainingSubsystem->HasActiveLiveTimer();
	if (TrainingSubsystem != nullptr)
	{
		const FHyperTwistTrainingRunState RunState = TrainingSubsystem->GetActiveRunState();
		if (RunState.IsStructurallyValid()
			&& RunState.CurrentSelection.TrainingCase.IsStructurallyValid())
		{
			const FHyperTwistTrainingCase& TrainingCase =
				RunState.CurrentSelection.TrainingCase;
			TrainingLine = FString::Printf(
				TEXT("training: %s  |  %s  |  %d case%s remain"),
				*RunState.ActiveDeck.Title,
				TrainingCase.PromptLabel.IsEmpty()
					? *TrainingCase.CaseId
					: *TrainingCase.PromptLabel,
				RunState.RemainingCaseIds.Num(),
				RunState.RemainingCaseIds.Num() == 1 ? TEXT("") : TEXT("s"));
		}
	}
	if (bHasLiveTimer)
	{
		LiveTimerState = TrainingSubsystem->GetActiveLiveTimerState();
	}

	if (ActiveCubeActor != nullptr)
	{
		const FString ScrambleNotation = ActiveCubeActor->GetCurrentScrambleNotation();
		ScrambleLine = ScrambleNotation.IsEmpty()
			? TEXT("scramble: pending")
			: FString::Printf(TEXT("scramble: %s"), *ScrambleNotation);

		if (bAwaitingScrambleSettlement)
		{
			StatusLine = TEXT("status: scrambling cube");
			InspectionLine = TEXT("inspection: arming after scramble playback");
		}
		else if (bAttemptComplete && bAttemptMarkedDnf)
		{
			StatusLine = TEXT("status: DNF");
			InspectionLine = TEXT("inspection: closed");
			TimerLine = FString::Printf(
				TEXT("timer: %s"),
				*HyperTwistClassicCubeGameModeInternal::BuildFinalTimeLabel(LastCompletedTimerState)
			);
		}
		else if (bAttemptComplete)
		{
			StatusLine = TEXT("status: solved");
			InspectionLine = TEXT("inspection: closed on first gameplay turn");
			TimerLine = FString::Printf(
				TEXT("timer: %s"),
				*HyperTwistClassicCubeGameModeInternal::BuildFinalTimeLabel(LastCompletedTimerState)
			);
		}
		else if (bHasLiveTimer && LiveTimerState.Phase == EHyperTwistTrainingLiveTimerPhase::Inspection)
		{
			StatusLine = TEXT("status: inspection running");
			const int32 RemainingInspectionMs =
				FMath::Max(LiveTimerState.InspectionLimitMs - LiveTimerState.InspectionElapsedMs, 0);
			InspectionLine = FString::Printf(
				TEXT("inspection: %s remaining"),
				*HyperTwistClassicCubeGameModeInternal::FormatMilliseconds(RemainingInspectionMs)
			);
		}
		else if (bHasLiveTimer
			&& (LiveTimerState.Phase == EHyperTwistTrainingLiveTimerPhase::Solving
				|| LiveTimerState.Phase == EHyperTwistTrainingLiveTimerPhase::Paused))
		{
			StatusLine = LiveTimerState.Phase == EHyperTwistTrainingLiveTimerPhase::Paused
				? TEXT("status: solve paused")
				: TEXT("status: solving");
			InspectionLine = TEXT("inspection: closed on first gameplay turn");
			TimerLine = FString::Printf(
				TEXT("timer: %s"),
				*HyperTwistClassicCubeGameModeInternal::FormatMilliseconds(
					FMath::Max(LiveTimerState.DisplayedElapsedMs, LiveTimerState.SolveElapsedMs)
				)
			);
		}
		else if (bInspectionStarted)
		{
			StatusLine = TEXT("status: ready for first gameplay turn");
			InspectionLine = TEXT("inspection: ready");
		}
		else
		{
			StatusLine = TEXT("status: preparing attempt");
		}
	}

	ActiveHudWidget->SetHudLines(
		StatusLine,
		ScrambleLine,
		TimerLine,
		InspectionLine,
		ResultLine,
		ControlsLine,
		HintLine,
		SolutionLine,
		ReplayLine,
		LeaderboardLine,
		ModeLine,
		TrainingLine
	);
	ActiveHudWidget->SetModeToggleButtonLabel(
		ActiveSessionMode == EHyperTwistClassicCubeSessionMode::FollowAlong
			? TEXT("Switch To Free Play")
			: TEXT("Switch To Follow Along")
	);
}

FString AHyperTwistClassicCubeGameMode::BuildSessionId() const
{
	return FString::Printf(
		TEXT("%s_%s"),
		*SessionIdPrefix,
		*FGuid::NewGuid().ToString(EGuidFormats::Digits)
	);
}

void AHyperTwistClassicCubeGameMode::RefreshSolverGuidance(const bool bForceClearHint)
{
	ActiveCubeActor = ResolveOrSpawnCubeActor();
	if (ActiveCubeActor == nullptr)
	{
		return;
	}

	if (bForceClearHint || ActiveCubeActor->IsSolved())
	{
		ActiveCubeActor->ClearHintedFace();
	}

	if (!IsCubeSettled())
	{
		++SolverGuidanceRequestSerial;
		PendingSolverFaceletString.Reset();
		bPendingSolverForceClearHint = false;
		HintLineOverride = TEXT("hint: waiting for cube to settle");
		return;
	}

	const FString FaceletString = ActiveCubeActor->GetSolverFaceletString();
	if (FaceletString.IsEmpty() || !UHyperTwistSolverLibrary::VerifyFaceletString(FaceletString))
	{
		++SolverGuidanceRequestSerial;
		PendingSolverFaceletString.Reset();
		bPendingSolverForceClearHint = false;
		ActiveSolutionNotation.Reset();
		ActiveSolutionQuarterTurns.Reset();
		FollowAlongGuideQuarterTurns.Reset();
		HintLineOverride = TEXT("hint: solver state unavailable");
		return;
	}

	if (ActiveCubeActor->IsSolved())
	{
		++SolverGuidanceRequestSerial;
		PendingSolverFaceletString.Reset();
		bPendingSolverForceClearHint = false;
		ActiveSolutionNotation.Reset();
		ActiveSolutionQuarterTurns.Reset();
		FollowAlongGuideQuarterTurns.Reset();
		ActiveCubeActor->ClearHintedFace();
		HintLineOverride = TEXT("hint: cube solved");
		return;
	}

	++SolverGuidanceRequestSerial;
	PendingSolverFaceletString = FaceletString;
	bPendingSolverForceClearHint = bForceClearHint;
	ActiveSolutionNotation.Reset();
	ActiveSolutionQuarterTurns.Reset();
	HintLineOverride = TEXT("hint: preparing solver guidance in background");
	StartPendingSolverGuidanceTask();
}

void AHyperTwistClassicCubeGameMode::StartPendingSolverGuidanceTask()
{
	if (bSolverGuidanceTaskActive || PendingSolverFaceletString.IsEmpty())
	{
		return;
	}

	const uint64 RequestSerial = SolverGuidanceRequestSerial;
	const FString FaceletString = MoveTemp(PendingSolverFaceletString);
	const bool bForceClearHint = bPendingSolverForceClearHint;
	const int32 TimeLimitMs = FMath::Max(SolverTimeLimitMs, 1);
	const int32 MaximumMoveCount = FMath::Max(SolverMaximumMoveCount, 1);
	PendingSolverFaceletString.Reset();
	bPendingSolverForceClearHint = false;
	bSolverGuidanceTaskActive = true;
	ActiveSolverFaceletString = FaceletString;

	UE_LOG(
		LogHyperTwistClassicCubeUi,
		Display,
		TEXT("Classic cube solver guidance request %llu queued on the background worker."),
		RequestSerial);

	const TWeakObjectPtr<AHyperTwistClassicCubeGameMode> WeakGameMode(this);
	Async(
		EAsyncExecution::ThreadPool,
		[WeakGameMode, RequestSerial, FaceletString, bForceClearHint, TimeLimitMs, MaximumMoveCount]()
	{
		TArray<FString> SolutionNotation = UHyperTwistSolverLibrary::SolveClassicState(
			FaceletString,
			TimeLimitMs,
			MaximumMoveCount,
			1);
		AsyncTask(
			ENamedThreads::GameThread,
			[WeakGameMode,
			 RequestSerial,
			 FaceletString,
			 bForceClearHint,
			 SolutionNotation = MoveTemp(SolutionNotation)]() mutable
		{
			if (AHyperTwistClassicCubeGameMode* GameMode = WeakGameMode.Get())
			{
				GameMode->CompleteSolverGuidanceTask(
					RequestSerial,
					FaceletString,
					bForceClearHint,
					MoveTemp(SolutionNotation));
			}
		});
	});
}

void AHyperTwistClassicCubeGameMode::CompleteSolverGuidanceTask(
	const uint64 RequestSerial,
	const FString& FaceletString,
	const bool bForceClearHint,
	TArray<FString>&& SolutionNotation)
{
	bSolverGuidanceTaskActive = false;
	ActiveSolverFaceletString.Reset();

	ActiveCubeActor = ResolveOrSpawnCubeActor();
	const bool bResultMatchesCurrentState =
		RequestSerial == SolverGuidanceRequestSerial
		&& ActiveCubeActor != nullptr
		&& IsCubeSettled()
		&& ActiveCubeActor->GetSolverFaceletString().Equals(FaceletString, ESearchCase::CaseSensitive);
	if (bResultMatchesCurrentState)
	{
		ActiveSolutionNotation = MoveTemp(SolutionNotation);
		ActiveSolutionQuarterTurns =
			UHyperTwistClassicCubeCommandLibrary::ExpandMoveNotationSequence(ActiveSolutionNotation);
		const bool bIsFollowAlong =
			ActiveSessionMode == EHyperTwistClassicCubeSessionMode::FollowAlong;
		if (bIsFollowAlong
			&& (bForceClearHint || FollowAlongGuideQuarterTurns.IsEmpty()))
		{
			FollowAlongGuideQuarterTurns = ActiveSolutionQuarterTurns;
		}

		const TArray<FHyperTwistClassicCubeMoveDescriptor>& GuidanceMoves =
			bIsFollowAlong ? FollowAlongGuideQuarterTurns : ActiveSolutionQuarterTurns;
		if (GuidanceMoves.IsEmpty())
		{
			ActiveCubeActor->ClearHintedFace();
			HintLineOverride = ActiveCubeActor->IsSolved()
				? TEXT("hint: cube solved")
				: TEXT("hint: no solver guidance returned");
		}
		else if (bIsFollowAlong)
		{
			if (!GuidanceMoves.IsValidIndex(FollowAlongStepIndex))
			{
				ActiveCubeActor->ClearHintedFace();
				HintLineOverride = TEXT("hint: guided run complete");
				ModeLineOverride = FString::Printf(
					TEXT("mode: follow-along complete, correct %d, wrong %d"),
					FollowAlongCorrectMoveCount,
					FollowAlongIncorrectMoveCount);
			}
			else
			{
				const FHyperTwistClassicCubeMoveDescriptor& GuidedMove =
					GuidanceMoves[FollowAlongStepIndex];
				ActiveCubeActor->SetHintedFace(GuidedMove.Face);
				HintLineOverride = FString::Printf(
					TEXT("hint: next move %s"),
					*GuidedMove.Notation);
				ModeLineOverride = FString::Printf(
					TEXT("mode: follow-along step %d/%d, correct %d, wrong %d"),
					FMath::Clamp(FollowAlongStepIndex + 1, 1, FMath::Max(GuidanceMoves.Num(), 1)),
					FMath::Max(GuidanceMoves.Num(), 1),
					FollowAlongCorrectMoveCount,
					FollowAlongIncorrectMoveCount);
			}
		}
		else
		{
			ActiveCubeActor->SetHintedFace(GuidanceMoves[0].Face);
			HintLineOverride = FString::Printf(
				TEXT("hint: next move %s"),
				*GuidanceMoves[0].Notation);
		}

		UE_LOG(
			LogHyperTwistClassicCubeUi,
			Display,
			TEXT("Classic cube solver guidance request %llu completed with %d moves."),
			RequestSerial,
			ActiveSolutionNotation.Num());
		RefreshHud();
	}
	else
	{
		UE_LOG(
			LogHyperTwistClassicCubeUi,
			Verbose,
			TEXT("Discarded stale classic cube solver guidance request %llu."),
			RequestSerial);
	}

	StartPendingSolverGuidanceTask();
}

void AHyperTwistClassicCubeGameMode::RefreshVoiceProfiles()
{
	AvailableVoiceProfiles = UHyperTwistSpeechLibrary::ListVoiceProfiles(this, bUseMockSpeechClient);
	if (AvailableVoiceProfiles.IsEmpty())
	{
		return;
	}

	if (SelectedVoiceProfileId.IsEmpty())
	{
		SelectedVoiceProfileId = AvailableVoiceProfiles[0].VoiceProfileId;
		return;
	}

	for (const FHyperTwistVoiceProfileSummary& Profile : AvailableVoiceProfiles)
	{
		if (Profile.VoiceProfileId.Equals(SelectedVoiceProfileId, ESearchCase::IgnoreCase))
		{
			return;
		}
	}

	SelectedVoiceProfileId = AvailableVoiceProfiles[0].VoiceProfileId;
}

TArray<FString> AHyperTwistClassicCubeGameMode::ResolveScrambleMovesForCurrentSession() const
{
	if (ActiveSessionMode == EHyperTwistClassicCubeSessionMode::FollowAlong
		&& !FollowAlongScriptedScrambleNotation.IsEmpty())
	{
		const TArray<FString> ScriptedMoves =
			HyperTwistClassicCubeGameModeInternal::ParseNotationSequence(
				FollowAlongScriptedScrambleNotation
			);
		if (!ScriptedMoves.IsEmpty())
		{
			return ScriptedMoves;
		}
	}

	if (const UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem())
	{
		const FHyperTwistTrainingRunState RunState = TrainingSubsystem->GetActiveRunState();
		if (RunState.IsStructurallyValid()
			&& RunState.CurrentSelection.TrainingCase.IsStructurallyValid())
		{
			const TArray<FString> CaseScrambleMoves =
				HyperTwistClassicCubeGameModeInternal::ParseNotationSequence(
					RunState.CurrentSelection.TrainingCase.ScrambleNotation);
			if (!CaseScrambleMoves.IsEmpty())
			{
				return CaseScrambleMoves;
			}
		}
	}

	return ActiveCubeActor != nullptr
		? ActiveCubeActor->GenerateScramble(ScrambleLength)
		: TArray<FString>();
}

void AHyperTwistClassicCubeGameMode::ProcessCompletedMoveHistory()
{
	if (ActiveCubeActor == nullptr)
	{
		return;
	}

	const TArray<FHyperTwistClassicCubeMoveDescriptor> CompletedMoves =
		ActiveCubeActor->GetCompletedMoveHistory();
	if (ObservedCompletedMoveCount > CompletedMoves.Num())
	{
		ObservedCompletedMoveCount = 0;
	}

	const int32 PreviousObservedCount = ObservedCompletedMoveCount;
	for (int32 MoveIndex = ObservedCompletedMoveCount; MoveIndex < CompletedMoves.Num(); ++MoveIndex)
	{
		const FHyperTwistClassicCubeMoveDescriptor& CompletedMove = CompletedMoves[MoveIndex];
		if (CompletedMove.bGameplayMove)
		{
			HandleGameplayMoveDescriptor(CompletedMove);
		}
	}

	ObservedCompletedMoveCount = CompletedMoves.Num();
	if (ObservedCompletedMoveCount != PreviousObservedCount)
	{
		if (bReplayPlaybackActive)
		{
			HintLineOverride = TEXT("hint: replay playback in progress");
		}
		else
		{
			RefreshSolverGuidance();
		}
	}
}

void AHyperTwistClassicCubeGameMode::HandleGameplayMoveDescriptor(
	const FHyperTwistClassicCubeMoveDescriptor& MoveDescriptor
)
{
	static_cast<void>(MoveDescriptor);

	if (!bSolveStartedFromGameplayMove)
	{
		if (UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem())
		{
			const FHyperTwistTrainingLiveTimerState LiveTimerState =
				TrainingSubsystem->GetActiveLiveTimerState();
			if (!TrainingSubsystem->HasActiveLiveTimer())
			{
				TrainingSubsystem->StartActiveLiveTimer();
			}
			else if (LiveTimerState.Phase == EHyperTwistTrainingLiveTimerPhase::Inspection)
			{
				TrainingSubsystem->AdvanceActiveLiveTimerToSolvePhase();
			}
			else if (LiveTimerState.Phase == EHyperTwistTrainingLiveTimerPhase::Idle)
			{
				TrainingSubsystem->StartActiveLiveTimer();
			}
		}

		bSolveStartedFromGameplayMove = true;
		if (!bSolveStartNarrated)
		{
			QueueNarrationEvent(TEXT("solve-go"), TEXT("Go!"));
			bSolveStartNarrated = true;
		}
	}

	CaptureInitialReplaySnapshotIfNeeded();
	if (UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem())
	{
		TrainingSubsystem->AppendActiveReplayMoveEvent(MoveDescriptor.Notation);
	}

	if (ActiveSessionMode == EHyperTwistClassicCubeSessionMode::FollowAlong)
	{
		HandleFollowAlongMove(MoveDescriptor);
	}
}

void AHyperTwistClassicCubeGameMode::HandleFollowAlongMove(
	const FHyperTwistClassicCubeMoveDescriptor& MoveDescriptor
)
{
	if (FollowAlongGuideQuarterTurns.IsEmpty())
	{
		ModeLineOverride = TEXT("mode: follow-along waiting on solver");
		return;
	}

	if (!FollowAlongGuideQuarterTurns.IsValidIndex(FollowAlongStepIndex))
	{
		ModeLineOverride = TEXT("mode: follow-along complete");
		return;
	}

	const FHyperTwistClassicCubeMoveDescriptor& ExpectedMove =
		FollowAlongGuideQuarterTurns[FollowAlongStepIndex];
	if (HyperTwistClassicCubeGameModeInternal::AreMovesEquivalent(MoveDescriptor, ExpectedMove))
	{
		++FollowAlongCorrectMoveCount;
		++FollowAlongStepIndex;
		if (!FollowAlongGuideQuarterTurns.IsValidIndex(FollowAlongStepIndex))
		{
			ActiveCubeActor->ClearHintedFace();
			HintLineOverride = TEXT("hint: guided run complete");
			ModeLineOverride = FString::Printf(
				TEXT("mode: follow-along complete, correct %d, wrong %d"),
				FollowAlongCorrectMoveCount,
				FollowAlongIncorrectMoveCount
			);
		}
		else
		{
			ModeLineOverride = FString::Printf(
				TEXT("mode: follow-along matched %d/%d"),
				FMath::Min(FollowAlongStepIndex, FollowAlongGuideQuarterTurns.Num()),
				FollowAlongGuideQuarterTurns.Num()
			);
		}
	}
	else
	{
		++FollowAlongIncorrectMoveCount;
		ModeLineOverride = FString::Printf(
			TEXT("mode: expected %s, corrected with %s"),
			*ExpectedMove.Notation,
			*UHyperTwistClassicCubeCommandLibrary::InvertMoveDescriptor(MoveDescriptor).Notation
		);

		FHyperTwistClassicCubeMoveDescriptor InvertedMove =
			UHyperTwistClassicCubeCommandLibrary::InvertMoveDescriptor(MoveDescriptor);
		InvertedMove.bGameplayMove = false;
		ActiveCubeActor->TryQueueMoveDescriptor(InvertedMove);
	}
}

void AHyperTwistClassicCubeGameMode::FinalizeSolvedAttemptIfNeeded()
{
	if (ActiveCubeActor == nullptr
		|| bAttemptComplete
		|| !bSolveStartedFromGameplayMove
		|| !IsCubeSettled()
		|| !ActiveCubeActor->IsSolved())
	{
		return;
	}

	SubmitCurrentSolve();
}

bool AHyperTwistClassicCubeGameMode::IsCubeSettled() const
{
	return ActiveCubeActor != nullptr && ActiveCubeActor->IsSettled();
}

void AHyperTwistClassicCubeGameMode::QueueNarrationEvent(
	const FString& EventId,
	const FString& ScriptText
)
{
	const FHyperTwistPlayerPreferences Preferences =
		UHyperTwistPlayerSettingsLibrary::LoadPreferences();
	if (!bEnableCoachNarration
		|| !Preferences.bCoachNarrationEnabled
		|| ScriptText.IsEmpty())
	{
		return;
	}
	const bool bCloudVoiceProvider =
		Preferences.VoiceProviderId == TEXT("openai")
		|| Preferences.VoiceProviderId == TEXT("elevenlabs");
	if (bCloudVoiceProvider && !Preferences.bAllowCloudProviders)
	{
		return;
	}

	RefreshVoiceProfiles();
	if (!Preferences.VoiceId.IsEmpty())
	{
		SelectedVoiceProfileId = Preferences.VoiceId;
	}

	FHyperTwistNarrationSynthesisRequest Request =
		UHyperTwistContractLibrary::MakeSampleNarrationSynthesisRequest();
	Request.RequestId = FString::Printf(
		TEXT("%s_%s"),
		*EventId,
		*FGuid::NewGuid().ToString(EGuidFormats::Digits)
	);
	Request.ScriptText = ScriptText;
	Request.SubtitleSeedText = ScriptText;
	Request.VoiceProfileId = SelectedVoiceProfileId;
	Request.LengthScale = 1.0f / FMath::Max(Preferences.VoiceSpeed, 0.1f);
	Request.OrchestrationProfile.ModelBindingId = Preferences.VoiceModel;
	const FHyperTwistNarrationSynthesisResult NarrationResult =
		UHyperTwistSpeechLibrary::SynthesizeNarration(this, Request, bUseMockSpeechClient);
	if (TryPlayNarrationResult(NarrationResult))
	{
		VoiceLineOverride = FString::Printf(TEXT("voice: narrated %s"), *EventId);
	}
}

bool AHyperTwistClassicCubeGameMode::TryPlayNarrationResult(
	const FHyperTwistNarrationSynthesisResult& NarrationResult
)
{
	if (GetWorld() == nullptr || !NarrationResult.IsStructurallyValid())
	{
		return false;
	}

	const TArray<uint8> PcmBytes = UHyperTwistSpeechLibrary::ExtractPcm16PayloadFromAudioBytes(
		NarrationResult.AudioBytes,
		NarrationResult.AudioEncoding
	);
	if (PcmBytes.IsEmpty())
	{
		return false;
	}

	StopActiveNarration();

	USoundWaveProcedural* SoundWave = NewObject<USoundWaveProcedural>(this);
	if (SoundWave == nullptr)
	{
		return false;
	}

	SoundWave->SetSampleRate(NarrationResult.SampleRateHz);
	SoundWave->NumChannels = NarrationResult.ChannelCount;
	SoundWave->Duration = FMath::Max(NarrationResult.DurationMs, 0) / 1000.0f;
	SoundWave->SampleByteSize = 2;
	SoundWave->QueueAudio(PcmBytes.GetData(), PcmBytes.Num());

	ActiveNarrationAudioComponent = UGameplayStatics::SpawnSound2D(
		GetWorld(),
		SoundWave,
		NarrationVolumeMultiplier
	);
	if (ActiveNarrationAudioComponent == nullptr)
	{
		return false;
	}

	UHyperTwistSpeechLibrary::RegisterManagedAudioComponent(ActiveNarrationAudioComponent, true);
	return true;
}

void AHyperTwistClassicCubeGameMode::StopActiveNarration()
{
	if (ActiveNarrationAudioComponent != nullptr)
	{
		UHyperTwistSpeechLibrary::UnregisterManagedAudioComponent(ActiveNarrationAudioComponent);
		ActiveNarrationAudioComponent->Stop();
		ActiveNarrationAudioComponent = nullptr;
	}
}

void AHyperTwistClassicCubeGameMode::TickVoiceCommandCapture()
{
	if (!bVoiceCommandCaptureActive)
	{
		return;
	}

	FString DrainError;
	if (!DrainVoiceCaptureBuffer(DrainError))
	{
		VoiceLineOverride = FString::Printf(TEXT("voice: %s"), *DrainError);
	}
}

bool AHyperTwistClassicCubeGameMode::DrainVoiceCaptureBuffer(FString& OutError)
{
	OutError.Reset();
	if (!ActiveVoiceCapture.IsValid())
	{
		OutError = TEXT("voice-capture-unavailable");
		return false;
	}

	uint32 AvailableBytes = 0;
	const EVoiceCaptureState::Type CaptureState =
		ActiveVoiceCapture->GetCaptureState(AvailableBytes);
	if (CaptureState == EVoiceCaptureState::NoData
		|| CaptureState == EVoiceCaptureState::Stopping
		|| CaptureState == EVoiceCaptureState::NotCapturing)
	{
		return true;
	}

	if (CaptureState != EVoiceCaptureState::Ok && CaptureState != EVoiceCaptureState::BufferTooSmall)
	{
		OutError = FString::Printf(
			TEXT("voice-capture-state-%s"),
			EVoiceCaptureState::ToString(CaptureState)
		);
		return false;
	}

	if (AvailableBytes == 0)
	{
		return true;
	}

	const int32 BufferOffset = CapturedVoicePcmBytes.Num();
	CapturedVoicePcmBytes.AddUninitialized(static_cast<int32>(AvailableBytes));
	uint32 ReadBytes = 0;
	const EVoiceCaptureState::Type ReadState = ActiveVoiceCapture->GetVoiceData(
		CapturedVoicePcmBytes.GetData() + BufferOffset,
		AvailableBytes,
		ReadBytes
	);
	if (ReadState != EVoiceCaptureState::Ok && ReadState != EVoiceCaptureState::NoData)
	{
		CapturedVoicePcmBytes.SetNum(BufferOffset);
		OutError = FString::Printf(
			TEXT("voice-read-state-%s"),
			EVoiceCaptureState::ToString(ReadState)
		);
		return false;
	}

	CapturedVoicePcmBytes.SetNum(BufferOffset + static_cast<int32>(ReadBytes));
	return true;
}

bool AHyperTwistClassicCubeGameMode::FinalizeVoiceCommandCaptureSubmission(FString& OutError)
{
	OutError.Reset();
	if (!bVoiceCommandCaptureActive)
	{
		return true;
	}

	DrainVoiceCaptureBuffer(OutError);
	if (ActiveVoiceCapture.IsValid())
	{
		ActiveVoiceCapture->Stop();
	}
	DrainVoiceCaptureBuffer(OutError);

	const FHyperTwistTrainingCompanionSpeechSessionState SessionState =
		UHyperTwistSpeechLibrary::GetActiveDictationSessionState(this);
	FString CloseError;

	if (CapturedVoicePcmBytes.IsEmpty())
	{
		UHyperTwistSpeechLibrary::EndDictationSession(this, CloseError, true);
		bVoiceCommandCaptureActive = false;
		ActiveVoiceCapture.Reset();
		OutError = TEXT("voice-capture-empty");
		return false;
	}

	const FString AudioRef = BuildVoiceCaptureFilePath(ActiveVoiceUtteranceId);
	if (!WriteWaveFile(
			AudioRef,
			CapturedVoicePcmBytes,
			VoiceCaptureSampleRateHz,
			VoiceCaptureChannelCount))
	{
		UHyperTwistSpeechLibrary::EndDictationSession(this, CloseError, true);
		bVoiceCommandCaptureActive = false;
		ActiveVoiceCapture.Reset();
		OutError = TEXT("voice-capture-save-failed");
		return false;
	}

	FHyperTwistSpeechUtteranceEnvelope Utterance;
	Utterance.SessionId = SessionState.ActiveSessionId;
	Utterance.UtteranceId = ActiveVoiceUtteranceId;
	Utterance.CapturedAtUtc = FDateTime::UtcNow().ToIso8601();
	Utterance.AudioRef = AudioRef;
	Utterance.SourceTimestampMs =
		FMath::RoundToInt((GetWorld() != nullptr ? GetWorld()->GetTimeSeconds() : 0.0f) * 1000.0f);
	Utterance.SampleCount = CapturedVoicePcmBytes.Num() / 2;
	Utterance.SampleRateHz = VoiceCaptureSampleRateHz;
	Utterance.ChannelCount = VoiceCaptureChannelCount;
	Utterance.SpeechStartMs = 0;
	Utterance.SpeechEndMs = FMath::Max(
		1,
		FMath::RoundToInt(
			((GetWorld() != nullptr ? GetWorld()->GetTimeSeconds() : 0.0f) - VoiceCaptureStartedAtSeconds)
			* 1000.0f
		)
	);
	Utterance.SilenceGapMs = 0;
	Utterance.EnergyThreshold = 0.0f;

	LastVoiceTranscriptResult = UHyperTwistSpeechLibrary::SubmitDictationUtterance(this, Utterance);
	UHyperTwistSpeechLibrary::EndDictationSession(this, CloseError, true);

	bVoiceCommandCaptureActive = false;
	ActiveVoiceCapture.Reset();
	CapturedVoicePcmBytes.Reset();

	if (!CloseError.IsEmpty())
	{
		VoiceLineOverride = FString::Printf(TEXT("voice: %s"), *CloseError);
	}

	if (LastVoiceTranscriptResult.TranscriptText.IsEmpty())
	{
		OutError = TEXT("voice-transcript-empty");
		return false;
	}

	FHyperTwistClassicCubeVoiceCommand VoiceCommand;
	if (!UHyperTwistClassicCubeCommandLibrary::TryParseVoiceCommand(
			LastVoiceTranscriptResult.TranscriptText,
			VoiceCommand))
	{
		VoiceLineOverride = FString::Printf(
			TEXT("voice: heard \"%s\""),
			*LastVoiceTranscriptResult.TranscriptText
		);
		ResultLineOverride = TEXT("result: voice command not understood");
		return true;
	}

	VoiceLineOverride = FString::Printf(
		TEXT("voice: heard \"%s\""),
		*LastVoiceTranscriptResult.TranscriptText
	);
	return TryExecuteVoiceCommand(VoiceCommand);
}

FString AHyperTwistClassicCubeGameMode::BuildVoiceCaptureFilePath(const FString& UtteranceId) const
{
	return FPaths::Combine(
		FPaths::ProjectSavedDir(),
		TEXT("VoiceCaptures"),
		FString::Printf(TEXT("%s.wav"), *UtteranceId)
	);
}

bool AHyperTwistClassicCubeGameMode::WriteWaveFile(
	const FString& FilePath,
	const TArray<uint8>& PcmBytes,
	const int32 SampleRateHz,
	const int32 ChannelCount
) const
{
	IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile();
	PlatformFile.CreateDirectoryTree(*FPaths::GetPath(FilePath));

	TArray<uint8> WaveBytes;
	WaveBytes.Reserve(44 + PcmBytes.Num());
	HyperTwistClassicCubeGameModeInternal::AppendWaveHeader(
		WaveBytes,
		PcmBytes.Num(),
		SampleRateHz,
		ChannelCount
	);
	WaveBytes.Append(PcmBytes);
	return FFileHelper::SaveArrayToFile(WaveBytes, *FilePath);
}

bool AHyperTwistClassicCubeGameMode::TryExecuteVoiceCommand(
	const FHyperTwistClassicCubeVoiceCommand& VoiceCommand
)
{
	ResultLineOverride = VoiceCommand.FeedbackLine;

	switch (VoiceCommand.CommandKind)
	{
	case EHyperTwistClassicCubeVoiceCommandKind::Move:
		if (ActiveCubeActor == nullptr)
		{
			ResultLineOverride = TEXT("result: no active cube for voice move");
			return true;
		}
		if (!CanAcceptGameplayMoveInput())
		{
			ResultLineOverride = TEXT("result: voice move ignored until scramble playback and settle complete");
			return true;
		}
		if (!ActiveCubeActor->TryQueueMoveDescriptor(VoiceCommand.Move))
		{
			ResultLineOverride = TEXT("result: voice move could not be queued");
		}
		return true;
	case EHyperTwistClassicCubeVoiceCommandKind::NewScramble:
		StartFreshAttempt();
		return true;
	case EHyperTwistClassicCubeVoiceCommandKind::RequestHint:
		RequestHint();
		return true;
	case EHyperTwistClassicCubeVoiceCommandKind::SubmitSolve:
		SubmitCurrentSolve();
		return true;
	case EHyperTwistClassicCubeVoiceCommandKind::ToggleFollowAlong:
		ToggleFollowAlongMode();
		return true;
	case EHyperTwistClassicCubeVoiceCommandKind::CycleVoiceProfile:
		CycleVoiceProfile();
		return true;
	case EHyperTwistClassicCubeVoiceCommandKind::ReadyCoach:
		QueueNarrationEvent(TEXT("ready-coach"), TEXT("Coach lane ready."));
		return true;
	case EHyperTwistClassicCubeVoiceCommandKind::Unsupported:
		return true;
	case EHyperTwistClassicCubeVoiceCommandKind::None:
	default:
		return false;
	}
}

void AHyperTwistClassicCubeGameMode::SetResultAndCoachLines(
	const FString& InResultLine,
	const FString& InHintLine,
	const FString& InModeLine,
	const FString& InVoiceLine
)
{
	ResultLineOverride = InResultLine;
	HintLineOverride = InHintLine;
	ModeLineOverride = InModeLine;
	VoiceLineOverride = InVoiceLine;
}

AHyperTwistClassicCubeFollowAlongGameMode::AHyperTwistClassicCubeFollowAlongGameMode()
{
	DefaultSessionMode = EHyperTwistClassicCubeSessionMode::FollowAlong;
	SessionIdPrefix = TEXT("classic_cube_follow_along");
	FollowAlongScriptedScrambleNotation = TEXT("R U R' U' F2 L D B' U2");
}
