// Copyright HyperTwist, Inc. All Rights Reserved.

#include "Misc/AutomationTest.h"

#include "HAL/FileManager.h"
#include "Engine/GameInstance.h"
#include "HyperTwistBootstrap/HyperTwistContractLibrary.h"
#include "HyperTwistReplay/HyperTwistReplayLibrary.h"
#include "HyperTwistReplay/HyperTwistReplayPersistenceLibrary.h"
#include "HyperTwistSimulation/HyperTwistClassicCubeActor.h"
#include "HyperTwistSimulation/HyperTwistClassicCubeCommandLibrary.h"
#include "HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.h"
#include "HyperTwistSimulation/HyperTwistClassicCubeTypes.h"
#include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h"
#include "HyperTwistTraining/HyperTwistTrainingMediaExportLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingSubsystem.h"
#include "JsonObjectConverter.h"
#include "Misc/FileHelper.h"
#include "Misc/Guid.h"
#include "Misc/Paths.h"

#if WITH_AUTOMATION_TESTS

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

	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);
	}

	FHyperTwistReplayPacket BuildReplayPacket()
	{
		FHyperTwistReplayPacket Packet;
		Packet.ReplayId = TEXT("classic-cube-integration-replay");
		Packet.SessionId = TEXT("classic-cube-integration-session");
		Packet.PuzzleDefinition = UHyperTwistContractLibrary::MakeSampleClassicPuzzleDefinition();
		Packet.CaptureMode = EHyperTwistCaptureMode::Runtime;
		Packet.StartedAtUtc = TEXT("2026-06-12T10:00:00Z");
		Packet.EndedAtUtc = TEXT("2026-06-12T10:00:12Z");

		FHyperTwistReplayEvent TimerStartEvent;
		TimerStartEvent.EventId = TEXT("timer-start");
		TimerStartEvent.Sequence = 1;
		TimerStartEvent.TimeMs = 0;
		TimerStartEvent.EventType = EHyperTwistReplayEventType::TimerStart;
		Packet = UHyperTwistReplayLibrary::AppendReplayEvent(Packet, TimerStartEvent);

		FHyperTwistStateSnapshot Snapshot;
		Snapshot.SnapshotId = TEXT("classic-cube-start");
		Snapshot.State = UHyperTwistContractLibrary::MakeSampleClassicPuzzleState();
		Snapshot.DerivedHash = TEXT("classic-cube-start-hash");

		FHyperTwistReplayEvent SnapshotEvent;
		SnapshotEvent.TimeMs = 0;
		SnapshotEvent.EventType = EHyperTwistReplayEventType::StateSnapshot;
		SnapshotEvent.PayloadJson = SerializeStruct(Snapshot);
		Packet = UHyperTwistReplayLibrary::AppendReplayEvent(Packet, SnapshotEvent);

		const TArray<FString> ReplayMoves = {
			TEXT("R"),
			TEXT("U"),
			TEXT("R'"),
			TEXT("U'"),
			TEXT("F"),
			TEXT("L"),
			TEXT("D"),
			TEXT("B'"),
			TEXT("R2"),
			TEXT("U2")
		};
		int32 TimeMs = 120;
		for (const FString& MoveNotation : ReplayMoves)
		{
			FHyperTwistReplayMovePayload MovePayload;
			MovePayload.Notation = MoveNotation;
			MovePayload.TransformationRef = MoveNotation;
			MovePayload.Source = TEXT("integration-test");

			FHyperTwistReplayEvent MoveEvent;
			MoveEvent.TimeMs = TimeMs;
			MoveEvent.EventType = EHyperTwistReplayEventType::Move;
			MoveEvent.PayloadJson = SerializeStruct(MovePayload);
			Packet = UHyperTwistReplayLibrary::AppendReplayEvent(Packet, MoveEvent);
			TimeMs += 120;
		}

		FHyperTwistReplayEvent SolveEndEvent;
		SolveEndEvent.TimeMs = TimeMs;
		SolveEndEvent.EventType = EHyperTwistReplayEventType::SolveEnd;
		Packet = UHyperTwistReplayLibrary::AppendReplayEvent(Packet, SolveEndEvent);

		FHyperTwistClassicCubeReplayMetadata ReplayMetadata;
		ReplayMetadata.PuzzleId = TEXT("cube/3x3x3");
		ReplayMetadata.ScrambleNotation = TEXT("R U R' U' F2 L D B' U2");
		ReplayMetadata.ScrambleLength = 9;
		ReplayMetadata.SessionMode = EHyperTwistClassicCubeSessionMode::FreePlay;
		ReplayMetadata.VoiceProfileId = TEXT("en_US-lessac-medium");
		Packet.AnnotationsJson = SerializeStruct(ReplayMetadata);
		return Packet;
	}

	FString BuildReplayRoundTripPath()
	{
		return FPaths::Combine(
			FPaths::ProjectSavedDir(),
			TEXT("Automation"),
			FString::Printf(
				TEXT("classic-cube-replay-roundtrip-%s.json"),
				*FGuid::NewGuid().ToString(EGuidFormats::Digits)
			)
		);
	}

	FString BuildLeaderboardRoundTripPath()
	{
		return FPaths::Combine(
			FPaths::ProjectSavedDir(),
			TEXT("Automation"),
			FString::Printf(
				TEXT("classic-cube-leaderboard-roundtrip-%s.json"),
				*FGuid::NewGuid().ToString(EGuidFormats::Digits)
			)
		);
	}

	FString BuildMediaExportFixturePath()
	{
		return FPaths::Combine(
			FPaths::ProjectSavedDir(),
			TEXT("Automation"),
			TEXT("classic-cube-media-export-fixture.json")
		);
	}

	int32 CountReplayEventsOfType(
		const FHyperTwistReplayPacket& Packet,
		const EHyperTwistReplayEventType EventType
	)
	{
		int32 Count = 0;
		for (const FHyperTwistReplayEvent& Event : Packet.Events)
		{
			if (Event.EventType == EventType)
			{
				++Count;
			}
		}

		return Count;
	}

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

	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 TryBuildRuntimeSnapshot(
		AHyperTwistClassicCubeActor* CubeActor,
		const FString& SessionId,
		FHyperTwistStateSnapshot& OutSnapshot
	)
	{
		OutSnapshot = FHyperTwistStateSnapshot();
		if (CubeActor == nullptr)
		{
			UE_LOG(LogTemp, Error, TEXT("runtime replay smoke: snapshot build failed because the cube actor was null"));
			return false;
		}

		if (!CubeActor->IsSettled())
		{
			UE_LOG(
				LogTemp,
				Error,
				TEXT("runtime replay smoke: snapshot build failed because the cube was not settled (queued=%d totalMoves=%d gameplayMoves=%d)"),
				CubeActor->GetQueuedRotationCount(),
				CubeActor->GetTotalCompletedMoveCount(),
				CubeActor->GetGameplayCompletedMoveCount()
			);
			return false;
		}

		const FString FaceletString = CubeActor->GetSolverFaceletString();
		if (FaceletString.IsEmpty())
		{
			UE_LOG(
				LogTemp,
				Error,
				TEXT("runtime replay smoke: snapshot build failed because the facelet string was empty (queued=%d totalMoves=%d gameplayMoves=%d settled=%s)"),
				CubeActor->GetQueuedRotationCount(),
				CubeActor->GetTotalCompletedMoveCount(),
				CubeActor->GetGameplayCompletedMoveCount(),
				CubeActor->IsSettled() ? TEXT("true") : TEXT("false")
			);
			return false;
		}

		FHyperTwistClassicFaceletSnapshotState FaceletState;
		if (!TryBuildClassicFaceletSnapshotState(FaceletString, FaceletState))
		{
			UE_LOG(
				LogTemp,
				Error,
				TEXT("runtime replay smoke: snapshot build failed because the facelet string length was %d instead of 54 (%s)"),
				FaceletString.Len(),
				*FaceletString
			);
			return false;
		}

		FString PayloadJson;
		if (!SerializeStruct(FaceletState, PayloadJson))
		{
			UE_LOG(
				LogTemp,
				Error,
				TEXT("runtime replay smoke: snapshot build failed because the facelet snapshot state did not serialize")
			);
			return false;
		}

		FHyperTwistPuzzleDefinitionRef Definition;
		Definition.PuzzleId = TEXT("cube/3x3x3");
		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 = CubeActor->IsSolved();
		RuntimeState.Source = EHyperTwistStateSource::Runtime;
		RuntimeState.CapturedAtUtc = FDateTime::UtcNow().ToIso8601();
		RuntimeState.SourceConfidence = 1.0f;
		RuntimeState.SourceSessionId = !SessionId.IsEmpty() ? SessionId : TEXT("classic-runtime");
		RuntimeState.Notes = TEXT("Classic-cube runtime replay snapshot.");

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

	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 ForceMockRecognitionAndSpeechClients(UHyperTwistTrainingSubsystem* TrainingSubsystem)
	{
		if (TrainingSubsystem == nullptr)
		{
			return;
		}

		TrainingSubsystem->SetRecognitionClientKindForAutomation(TEXT("mock"));
		TrainingSubsystem->SetCompanionSpeechClientKindForAutomation(TEXT("mock"));
	}
}

IMPLEMENT_SIMPLE_AUTOMATION_TEST(
	FHyperTwistClassicCubeReplayPersistenceRoundTripTest,
	"HyperTwist.Integration.ClassicCube.ReplayPersistenceRoundTrip",
	EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)

bool FHyperTwistClassicCubeReplayPersistenceRoundTripTest::RunTest(const FString& Parameters)
{
	const FHyperTwistReplayPacket Packet =
		HyperTwistClassicCubeIntegrationTestInternal::BuildReplayPacket();
	const FString ReplayPath =
		HyperTwistClassicCubeIntegrationTestInternal::BuildReplayRoundTripPath();

	FString SavedPath;
	TestTrue(
		TEXT("The replay packet must save to disk for round-trip validation."),
		UHyperTwistReplayPersistenceLibrary::SaveReplayPacketToFile(
			Packet,
			ReplayPath,
			SavedPath
		)
	);

	FHyperTwistReplayPacket LoadedPacket;
	FString LoadedPath;
	TestTrue(
		TEXT("The saved replay packet must load back from disk."),
		UHyperTwistReplayPersistenceLibrary::LoadReplayPacketFromFile(
			SavedPath,
			LoadedPacket,
			LoadedPath
		)
	);
	TestEqual(TEXT("The replay id must survive the persistence round trip."), LoadedPacket.ReplayId, Packet.ReplayId);
	TestEqual(TEXT("The event count must survive the persistence round trip."), LoadedPacket.Events.Num(), Packet.Events.Num());
	TestTrue(TEXT("The loaded packet must remain structurally valid."), LoadedPacket.IsStructurallyValid());

	FHyperTwistClassicCubeReplayMetadata LoadedMetadata;
	TestTrue(
		TEXT("The replay annotations must remain deserializable after the persistence round trip."),
		HyperTwistClassicCubeIntegrationTestInternal::DeserializeStruct(
			LoadedPacket.AnnotationsJson,
			LoadedMetadata
		)
	);
	TestEqual(
		TEXT("The replay metadata must retain the original puzzle id."),
		LoadedMetadata.PuzzleId,
		TEXT("cube/3x3x3")
	);
	TestEqual(
		TEXT("The replay metadata must retain the original session mode."),
		LoadedMetadata.SessionMode,
		EHyperTwistClassicCubeSessionMode::FreePlay
	);
	TestEqual(
		TEXT("The replay metadata must retain the original voice profile id."),
		LoadedMetadata.VoiceProfileId,
		TEXT("en_US-lessac-medium")
	);

	IFileManager::Get().Delete(*SavedPath);
	return true;
}

IMPLEMENT_SIMPLE_AUTOMATION_TEST(
	FHyperTwistClassicCubeReplayViewerLoadTest,
	"HyperTwist.Integration.ClassicCube.ReplayViewerLoad",
	EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)

bool FHyperTwistClassicCubeReplayViewerLoadTest::RunTest(const FString& Parameters)
{
	UHyperTwistPuzzleViewerComponent* ViewerComponent =
		NewObject<UHyperTwistPuzzleViewerComponent>(GetTransientPackage());
	TestNotNull(TEXT("The replay viewer component must exist for replay loading."), ViewerComponent);
	if (ViewerComponent == nullptr)
	{
		return false;
	}

	const FHyperTwistReplayPacket Packet =
		HyperTwistClassicCubeIntegrationTestInternal::BuildReplayPacket();
	TestTrue(
		TEXT("The viewer must load a structurally valid replay packet."),
		ViewerComponent->LoadReplayPacket(Packet)
	);

	const FHyperTwistViewerPlaybackState PlaybackState = ViewerComponent->GetPlaybackState();
	TestTrue(TEXT("The viewer must mark the replay as loaded."), PlaybackState.bReplayLoaded);
	TestTrue(TEXT("The viewer must materialize scene context from the replay snapshot."), PlaybackState.bSceneContextLoaded);
	const FHyperTwistSimulationSceneContext ReplaySceneContext = ViewerComponent->GetSceneContext();
	TestTrue(TEXT("The classic-cube replay scene context must stay structurally valid."), ReplaySceneContext.IsStructurallyValid());
	TestEqual(
		TEXT("The classic-cube replay scene context must use the generic classic projection kind."),
		ReplaySceneContext.ProjectionSettings.ProjectionKind,
		EHyperTwistProjectionKind::ClassicPerspective
	);
	TestEqual(
		TEXT("The classic-cube replay scene context must use the generic classic projection profile."),
		ReplaySceneContext.ProjectionSettings.ProjectionProfile,
		FString(TEXT("classic-default"))
	);
	TestTrue(
		TEXT("The classic-cube replay scene context must preserve a positive generic projection depth."),
		FMath::IsNearlyEqual(ReplaySceneContext.ProjectionSettings.ProjectionDepth, 1.0f)
	);
	TestEqual(
		TEXT("The classic-cube replay scene context must keep the bounded classic replay render-state profile."),
		ReplaySceneContext.RenderStateProfile,
		FString(TEXT("classic-replay"))
	);
	TestEqual(TEXT("The viewer must reconstruct the expected move count."), PlaybackState.TotalMoveCount, 10);
	TestEqual(TEXT("The viewer must retain the replay id after load."), PlaybackState.ReplayId, Packet.ReplayId);
	return true;
}

IMPLEMENT_SIMPLE_AUTOMATION_TEST(
	FHyperTwistClassicCubeReplayPersistenceNormalizationTest,
	"HyperTwist.Integration.ClassicCube.ReplayPersistenceNormalization",
	EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)

bool FHyperTwistClassicCubeReplayPersistenceNormalizationTest::RunTest(const FString& Parameters)
{
	FHyperTwistReplayPacket LegacyPacket =
		HyperTwistClassicCubeIntegrationTestInternal::BuildReplayPacket();
	LegacyPacket.ReplayId.Reset();
	LegacyPacket.SessionId.Reset();
	LegacyPacket.PacketVersion.Reset();
	LegacyPacket.DerivedSummary = FHyperTwistDerivedReplaySummary();

	for (int32 EventIndex = 0; EventIndex < LegacyPacket.Events.Num(); ++EventIndex)
	{
		FHyperTwistReplayEvent& ReplayEvent = LegacyPacket.Events[EventIndex];
		ReplayEvent.EventId.Reset();
		ReplayEvent.Sequence = 0;
		ReplayEvent.TimeMs = FMath::Max(LegacyPacket.Events.Num() - EventIndex - 1, 0);
	}

	FString LegacyJson;
	TestTrue(
		TEXT("The legacy replay packet must serialize for normalization checks."),
		HyperTwistClassicCubeIntegrationTestInternal::SerializeStruct(
			LegacyPacket,
			LegacyJson
		)
	);

	const FString ReplayPath =
		HyperTwistClassicCubeIntegrationTestInternal::BuildReplayRoundTripPath();
	IFileManager::Get().MakeDirectory(*FPaths::GetPath(ReplayPath), true);
	TestTrue(
		TEXT("The legacy replay packet JSON must write to disk."),
		FFileHelper::SaveStringToFile(
			LegacyJson,
			*ReplayPath,
			FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM
		)
	);

	FHyperTwistReplayPacket LoadedPacket;
	FString LoadedPath;
	TestTrue(
		TEXT("The replay persistence library must normalize and load schema-light replay packets."),
		UHyperTwistReplayPersistenceLibrary::LoadReplayPacketFromFile(
			ReplayPath,
			LoadedPacket,
			LoadedPath
		)
	);
	TestEqual(
		TEXT("Replay normalization must restore the canonical packet version."),
		LoadedPacket.PacketVersion,
		TEXT("ht-replay/v1")
	);
	TestFalse(
		TEXT("Replay normalization must restore a replay id when legacy packets omitted both ids."),
		LoadedPacket.ReplayId.IsEmpty()
	);
	TestEqual(
		TEXT("Replay normalization must keep replay and session identity aligned when both were repaired."),
		LoadedPacket.SessionId,
		LoadedPacket.ReplayId
	);
	TestTrue(
		TEXT("Replay normalization must restore a structurally valid packet."),
		LoadedPacket.IsStructurallyValid()
	);
	TestEqual(
		TEXT("Replay normalization must rebuild the derived move count."),
		LoadedPacket.DerivedSummary.MoveCount,
		10
	);
	TestEqual(
		TEXT("Replay normalization must rebuild the completed result."),
		LoadedPacket.DerivedSummary.Result,
		TEXT("completed")
	);

	int32 PreviousSequence = 0;
	int32 PreviousTimeMs = -1;
	for (const FHyperTwistReplayEvent& ReplayEvent : LoadedPacket.Events)
	{
		TestFalse(
			TEXT("Replay normalization must populate every event id."),
			ReplayEvent.EventId.IsEmpty()
		);
		TestTrue(
			TEXT("Replay normalization must restore strictly increasing event sequence numbers."),
			ReplayEvent.Sequence > PreviousSequence
		);
		TestTrue(
			TEXT("Replay normalization must restore monotonic event times."),
			ReplayEvent.TimeMs >= PreviousTimeMs
		);
		PreviousSequence = ReplayEvent.Sequence;
		PreviousTimeMs = ReplayEvent.TimeMs;
	}

	UHyperTwistPuzzleViewerComponent* ViewerComponent =
		NewObject<UHyperTwistPuzzleViewerComponent>(GetTransientPackage());
	TestNotNull(
		TEXT("The replay viewer component must exist for normalized replay loading."),
		ViewerComponent
	);
	TestTrue(
		TEXT("The viewer must accept the normalized replay packet directly."),
		ViewerComponent != nullptr && ViewerComponent->LoadReplayPacket(LegacyPacket)
	);

	IFileManager::Get().Delete(*ReplayPath);
	return true;
}

IMPLEMENT_SIMPLE_AUTOMATION_TEST(
	FHyperTwistClassicCubeReplayPersistenceSequenceRepairTest,
	"HyperTwist.Integration.ClassicCube.ReplayPersistenceSequenceRepair",
	EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)

bool FHyperTwistClassicCubeReplayPersistenceSequenceRepairTest::RunTest(const FString& Parameters)
{
	FHyperTwistReplayPacket LegacyPacket =
		HyperTwistClassicCubeIntegrationTestInternal::BuildReplayPacket();
	LegacyPacket.DerivedSummary = FHyperTwistDerivedReplaySummary();

	TArray<FHyperTwistReplayEvent> ReorderedEvents;
	ReorderedEvents.Reserve(LegacyPacket.Events.Num());
	for (int32 EventIndex = LegacyPacket.Events.Num() - 1; EventIndex >= 0; --EventIndex)
	{
		ReorderedEvents.Add(LegacyPacket.Events[EventIndex]);
	}
	LegacyPacket.Events = MoveTemp(ReorderedEvents);
	if (LegacyPacket.Events.Num() >= 2)
	{
		LegacyPacket.Events[0].EventId = LegacyPacket.Events[1].EventId;
	}

	const FString ReplayPath =
		HyperTwistClassicCubeIntegrationTestInternal::BuildReplayRoundTripPath();
	FString LegacyJson;
	TestTrue(
		TEXT("The out-of-order replay packet must serialize for sequence-repair validation."),
		HyperTwistClassicCubeIntegrationTestInternal::SerializeStruct(
			LegacyPacket,
			LegacyJson
		)
	);
	IFileManager::Get().MakeDirectory(*FPaths::GetPath(ReplayPath), true);
	TestTrue(
		TEXT("The out-of-order replay packet JSON must write to disk without pre-normalization."),
		FFileHelper::SaveStringToFile(
			LegacyJson,
			*ReplayPath,
			FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM
		)
	);

	FHyperTwistReplayPacket LoadedPacket;
	FString LoadedPath;
	TestTrue(
		TEXT("Replay persistence must repair positive-sequence packets whose array order drifted."),
		UHyperTwistReplayPersistenceLibrary::LoadReplayPacketFromFile(
			ReplayPath,
			LoadedPacket,
			LoadedPath
		)
	);
	TestTrue(
		TEXT("The repaired replay packet must remain structurally valid."),
		LoadedPacket.IsStructurallyValid()
	);
	TestEqual(
		TEXT("Sequence repair must restore the original timer-start first event."),
		LoadedPacket.Events[0].EventType,
		EHyperTwistReplayEventType::TimerStart
	);
	TestEqual(
		TEXT("Sequence repair must restore the original solve-end last event."),
		LoadedPacket.Events.Last().EventType,
		EHyperTwistReplayEventType::SolveEnd
	);
	TestEqual(
		TEXT("Sequence repair must preserve the replay move count."),
		LoadedPacket.DerivedSummary.MoveCount,
		10
	);

	TSet<FString> SeenEventIds;
	for (const FHyperTwistReplayEvent& ReplayEvent : LoadedPacket.Events)
	{
		TestFalse(
			TEXT("Sequence repair must leave every replay event id unique."),
			SeenEventIds.Contains(ReplayEvent.EventId)
		);
		SeenEventIds.Add(ReplayEvent.EventId);
	}

	UHyperTwistPuzzleViewerComponent* ViewerComponent =
		NewObject<UHyperTwistPuzzleViewerComponent>(GetTransientPackage());
	TestNotNull(
		TEXT("The replay viewer component must exist for sequence-repair validation."),
		ViewerComponent
	);
	TestTrue(
		TEXT("The replay viewer must accept the repaired replay packet directly."),
		ViewerComponent != nullptr && ViewerComponent->LoadReplayPacket(LoadedPacket)
	);

	IFileManager::Get().Delete(*ReplayPath);
	return true;
}

IMPLEMENT_SIMPLE_AUTOMATION_TEST(
	FHyperTwistClassicCubeMediaExportFixtureTest,
	"HyperTwist.Integration.ClassicCube.MediaExportFixture",
	EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)

bool FHyperTwistClassicCubeMediaExportFixtureTest::RunTest(const FString& Parameters)
{
	const FHyperTwistReplayPacket Packet =
		HyperTwistClassicCubeIntegrationTestInternal::BuildReplayPacket();
	const FString FixturePath =
		HyperTwistClassicCubeIntegrationTestInternal::BuildMediaExportFixturePath();
	FString ExpectedFixturePath = FPaths::ConvertRelativePathToFull(FixturePath);
	FPaths::NormalizeFilename(ExpectedFixturePath);

	IFileManager::Get().MakeDirectory(*FPaths::GetPath(FixturePath), true);
	IFileManager::Get().Delete(*FixturePath);

	FString SavedPath;
	TestTrue(
		TEXT("The media-export fixture replay packet must save to disk."),
		UHyperTwistReplayPersistenceLibrary::SaveReplayPacketToFile(
			Packet,
			FixturePath,
			SavedPath
		)
	);
	FPaths::NormalizeFilename(SavedPath);
	TestEqual(
		TEXT("The media-export fixture replay path must resolve to the expected location."),
		SavedPath,
		ExpectedFixturePath
	);

	FHyperTwistReplayPacket LoadedPacket;
	FString LoadedPath;
	TestTrue(
		TEXT("The media-export fixture replay packet must load back from disk."),
		UHyperTwistReplayPersistenceLibrary::LoadReplayPacketFromFile(
			FixturePath,
			LoadedPacket,
			LoadedPath
		)
	);
	FPaths::NormalizeFilename(LoadedPath);
	TestEqual(
		TEXT("The loaded media-export fixture replay path must resolve to the expected location."),
		LoadedPath,
		ExpectedFixturePath
	);
	TestTrue(
		TEXT("The media-export fixture replay packet must remain structurally valid."),
		LoadedPacket.IsStructurallyValid()
	);
	TestEqual(
		TEXT("The media-export fixture replay packet must preserve its replay identity."),
		LoadedPacket.ReplayId,
		Packet.ReplayId
	);
	return true;
}

IMPLEMENT_SIMPLE_AUTOMATION_TEST(
	FHyperTwistClassicCubeMediaExportPlanTest,
	"HyperTwist.Integration.ClassicCube.MediaExportPlan",
	EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)

bool FHyperTwistClassicCubeMediaExportPlanTest::RunTest(const FString& Parameters)
{
	const FHyperTwistReplayPacket Packet =
		HyperTwistClassicCubeIntegrationTestInternal::BuildReplayPacket();
	const FString FixturePath =
		HyperTwistClassicCubeIntegrationTestInternal::BuildMediaExportFixturePath();
	const FString OutputDirectory = FPaths::Combine(
		FPaths::ProjectSavedDir(),
		TEXT("Automation"),
		TEXT("ClassicCubeMediaExport")
	);

	FHyperTwistTrainingClassicCubeMediaExportPlan Plan;
	TestTrue(
		TEXT("The media-export library must derive a classic-cube export plan from a replay packet."),
		UHyperTwistTrainingMediaExportLibrary::BuildClassicCubeMediaExportPlan(
			Packet,
			FixturePath,
			OutputDirectory,
			Plan
		)
	);
	TestTrue(
		TEXT("The derived classic-cube export plan must be structurally valid."),
		Plan.IsStructurallyValid()
	);
	TestEqual(
		TEXT("The media-export plan must preserve the replay identity."),
		Plan.ReplayId,
		Packet.ReplayId
	);
	TestEqual(
		TEXT("The media-export plan must preserve the replay file location."),
		Plan.ReplayPath,
		FixturePath
	);
	TestEqual(
		TEXT("The media-export plan must preserve the classic-cube training map route."),
		Plan.MapPath,
		TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining")
	);
	TestEqual(
		TEXT("The media-export plan must use Movie Render Queue as the primary video route."),
		Plan.VideoRoute,
		TEXT("ue-movie-render-queue")
	);
	TestEqual(
		TEXT("The media-export plan must preserve classic-cube scramble length metadata."),
		Plan.ScrambleLength,
		9
	);
	TestEqual(
		TEXT("The media-export plan must preserve classic-cube move count metadata."),
		Plan.MoveCount,
		10
	);
	TestEqual(
		TEXT("The media-export plan must derive the canonical final time label."),
		Plan.FinalTimeLabel,
		TEXT("00:01.320")
	);
	TestTrue(
		TEXT("The media-export plan must derive an MP4 output location."),
		Plan.ExpectedVideoPath.EndsWith(TEXT(".mp4"))
			&& Plan.ExpectedVideoPath.Contains(TEXT("/video/"))
	);
	TestEqual(
		TEXT("The media-export plan must derive the deterministic video directory."),
		Plan.VideoOutputDirectory,
		FPaths::Combine(OutputDirectory, TEXT("video"))
	);
	TestTrue(
		TEXT("The media-export plan must derive a still-frame PNG output location."),
		Plan.ExpectedStillFramePath.EndsWith(TEXT(".png"))
			&& Plan.ExpectedStillFramePath.Contains(TEXT("/still/"))
	);
	TestEqual(
		TEXT("The media-export plan must derive the deterministic still directory."),
		Plan.StillOutputDirectory,
		FPaths::Combine(OutputDirectory, TEXT("still"))
	);
	TestTrue(
		TEXT("The media-export plan must derive a share-card PNG output location."),
		Plan.ExpectedShareCardPath.EndsWith(TEXT(".png"))
	);
	TestTrue(
		TEXT("The media-export plan must derive a JSON report output location."),
		Plan.ExpectedReportPath.EndsWith(TEXT(".json"))
			&& Plan.ExpectedReportPath.Contains(TEXT("/validation/"))
	);
	TestTrue(
		TEXT("The media-export plan must derive the deterministic video-report location."),
		Plan.ExpectedVideoReportPath.EndsWith(TEXT("-video-report.json"))
			&& Plan.ExpectedVideoReportPath.Contains(TEXT("/validation/"))
	);
	TestTrue(
		TEXT("The media-export plan must derive the deterministic still-report location."),
		Plan.ExpectedStillReportPath.EndsWith(TEXT("-still-report.json"))
			&& Plan.ExpectedStillReportPath.Contains(TEXT("/validation/"))
	);
	TestEqual(
		TEXT("The media-export plan must derive the deterministic validation directory."),
		Plan.ValidationDirectory,
		FPaths::Combine(OutputDirectory, TEXT("validation"))
	);
	TestTrue(
		TEXT("The media-export plan must derive a positive render frame range."),
		Plan.RenderEndFrame > Plan.RenderStartFrame
	);
	TestTrue(
		TEXT("The still frame must fall inside the render frame range."),
		Plan.StillFrame >= Plan.RenderStartFrame && Plan.StillFrame < Plan.RenderEndFrame
	);

	const FString PlanJson =
		UHyperTwistTrainingMediaExportLibrary::BuildClassicCubeMediaExportPlanJson(Plan);
	TestTrue(
		TEXT("The media-export plan JSON must include the share-card route."),
		PlanJson.Contains(TEXT("powershell-share-card"))
	);
	TestTrue(
		TEXT("The media-export plan JSON must include the replay fixture path."),
		PlanJson.Contains(TEXT("classic-cube-media-export-fixture.json"))
	);
	return true;
}

IMPLEMENT_SIMPLE_AUTOMATION_TEST(
	FHyperTwistClassicCubeLeaderboardPersistenceTest,
	"HyperTwist.Integration.ClassicCube.LeaderboardPersistence",
	EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)

bool FHyperTwistClassicCubeLeaderboardPersistenceTest::RunTest(const FString& Parameters)
{
	FHyperTwistClassicCubeLeaderboardState LeaderboardState;

	FHyperTwistClassicCubeLeaderboardEntry FirstEntry;
	FirstEntry.PuzzleId = TEXT("cube/3x3x3");
	FirstEntry.ScrambleLength = 20;
	FirstEntry.FinalTimeMs = 15321;
	FirstEntry.ReplayId = TEXT("replay-a");
	FirstEntry.SessionId = TEXT("session-a");
	FirstEntry.ScrambleNotation = TEXT("R U F");
	FirstEntry.RecordedAtUtc = TEXT("2026-06-12T10:00:00Z");

	FHyperTwistClassicCubeLeaderboardEntry FasterEntry = FirstEntry;
	FasterEntry.FinalTimeMs = 14321;
	FasterEntry.ReplayId = TEXT("replay-b");
	FasterEntry.SessionId = TEXT("session-b");
	FasterEntry.RecordedAtUtc = TEXT("2026-06-12T10:05:00Z");

	FHyperTwistClassicCubeLeaderboardEntry OtherScrambleEntry = FirstEntry;
	OtherScrambleEntry.ScrambleLength = 25;
	OtherScrambleEntry.FinalTimeMs = 18234;
	OtherScrambleEntry.ReplayId = TEXT("replay-c");
	OtherScrambleEntry.SessionId = TEXT("session-c");
	OtherScrambleEntry.RecordedAtUtc = TEXT("2026-06-12T10:10:00Z");

	LeaderboardState = UHyperTwistClassicCubeLeaderboardLibrary::RecordBestTime(
		LeaderboardState,
		FirstEntry
	);
	LeaderboardState = UHyperTwistClassicCubeLeaderboardLibrary::RecordBestTime(
		LeaderboardState,
		FasterEntry
	);
	LeaderboardState = UHyperTwistClassicCubeLeaderboardLibrary::RecordBestTime(
		LeaderboardState,
		OtherScrambleEntry
	);

	FHyperTwistClassicCubeLeaderboardEntry BestEntry;
	TestTrue(
		TEXT("The leaderboard must retain a best entry for the 20-move scramble bucket."),
		UHyperTwistClassicCubeLeaderboardLibrary::FindBestEntry(
			LeaderboardState,
			TEXT("cube/3x3x3"),
			20,
			BestEntry
		)
	);
	TestEqual(TEXT("The faster entry must replace the slower entry for the same bucket."), BestEntry.FinalTimeMs, 14321);
	TestEqual(
		TEXT("The faster entry must also carry forward its replay identity."),
		BestEntry.ReplayId,
		TEXT("replay-b")
	);
	TestEqual(
		TEXT("The faster entry must also carry forward its session identity."),
		BestEntry.SessionId,
		TEXT("session-b")
	);
	TestEqual(TEXT("The leaderboard must retain one best entry per scramble bucket."), LeaderboardState.Entries.Num(), 2);

	const FString LeaderboardPath =
		HyperTwistClassicCubeIntegrationTestInternal::BuildLeaderboardRoundTripPath();
	FString SavedPath;
	TestTrue(
		TEXT("The leaderboard state must save to disk."),
		UHyperTwistClassicCubeLeaderboardLibrary::SaveLeaderboardStateToFile(
			LeaderboardState,
			LeaderboardPath,
			SavedPath
		)
	);

	FHyperTwistClassicCubeLeaderboardState LoadedState;
	FString LoadedPath;
	TestTrue(
		TEXT("The saved leaderboard state must load back from disk."),
		UHyperTwistClassicCubeLeaderboardLibrary::LoadLeaderboardStateFromFile(
			SavedPath,
			LoadedState,
			LoadedPath
		)
	);
	TestEqual(TEXT("The loaded leaderboard must retain the same number of entries."), LoadedState.Entries.Num(), 2);

	IFileManager::Get().Delete(*SavedPath);
	return true;
}

IMPLEMENT_SIMPLE_AUTOMATION_TEST(
	FHyperTwistClassicCubeLeaderboardNormalizationTest,
	"HyperTwist.Integration.ClassicCube.LeaderboardNormalization",
	EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)

bool FHyperTwistClassicCubeLeaderboardNormalizationTest::RunTest(const FString& Parameters)
{
	FHyperTwistClassicCubeLeaderboardState LegacyState;
	LegacyState.SchemaVersion.Reset();

	FHyperTwistClassicCubeLeaderboardEntry SlowerEntry;
	SlowerEntry.PuzzleId = TEXT("cube/3x3x3");
	SlowerEntry.ScrambleLength = 20;
	SlowerEntry.FinalTimeMs = 15321;
	SlowerEntry.ReplayId = TEXT("replay-slower");
	SlowerEntry.SessionId = TEXT("session-slower");
	SlowerEntry.ScrambleNotation = TEXT("R U F");
	SlowerEntry.RecordedAtUtc = TEXT("2026-06-12T10:10:00Z");

	FHyperTwistClassicCubeLeaderboardEntry FasterLaterEntry = SlowerEntry;
	FasterLaterEntry.FinalTimeMs = 14321;
	FasterLaterEntry.ReplayId = TEXT("replay-faster-later");
	FasterLaterEntry.SessionId = TEXT("session-faster-later");
	FasterLaterEntry.RecordedAtUtc = TEXT("2026-06-12T10:15:00Z");

	FHyperTwistClassicCubeLeaderboardEntry FasterEarlierEntry = FasterLaterEntry;
	FasterEarlierEntry.ReplayId = TEXT("replay-faster-earlier");
	FasterEarlierEntry.SessionId = TEXT("session-faster-earlier");
	FasterEarlierEntry.RecordedAtUtc = TEXT("2026-06-12T10:05:00Z");

	FHyperTwistClassicCubeLeaderboardEntry OtherBucketEntry = SlowerEntry;
	OtherBucketEntry.ScrambleLength = 25;
	OtherBucketEntry.FinalTimeMs = 18234;
	OtherBucketEntry.ReplayId = TEXT("replay-other-bucket");
	OtherBucketEntry.SessionId = TEXT("session-other-bucket");
	OtherBucketEntry.RecordedAtUtc = TEXT("2026-06-12T10:20:00Z");

	LegacyState.Entries = {
		SlowerEntry,
		FasterLaterEntry,
		OtherBucketEntry,
		FasterEarlierEntry
	};

	const FHyperTwistClassicCubeLeaderboardState NormalizedState =
		UHyperTwistClassicCubeLeaderboardLibrary::NormalizeLeaderboardState(LegacyState);
	TestEqual(
		TEXT("Normalization must restore the canonical schema version for schema-light legacy state."),
		NormalizedState.SchemaVersion,
		TEXT("ht-classic-cube-leaderboard/v1")
	);
	TestEqual(
		TEXT("Normalization must collapse duplicate scramble buckets down to one retained best entry."),
		NormalizedState.Entries.Num(),
		2
	);

	FHyperTwistClassicCubeLeaderboardEntry BestEntry;
	TestTrue(
		TEXT("Normalization must still allow the best entry to resolve from the repaired leaderboard state."),
		UHyperTwistClassicCubeLeaderboardLibrary::FindBestEntry(
			LegacyState,
			TEXT("cube/3x3x3"),
			20,
			BestEntry
		)
	);
	TestEqual(
		TEXT("The normalized best entry must preserve the fastest solve time for the duplicate bucket."),
		BestEntry.FinalTimeMs,
		14321
	);
	TestEqual(
		TEXT("Tied fastest solves must prefer the earliest recorded timestamp for deterministic repair."),
		BestEntry.ReplayId,
		TEXT("replay-faster-earlier")
	);
	TestEqual(
		TEXT("The repaired best entry must preserve its session identity."),
		BestEntry.SessionId,
		TEXT("session-faster-earlier")
	);
	return true;
}

IMPLEMENT_SIMPLE_AUTOMATION_TEST(
	FHyperTwistClassicCubeLeaderboardSchemaLightRepairTest,
	"HyperTwist.Integration.ClassicCube.LeaderboardSchemaLightRepair",
	EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)

bool FHyperTwistClassicCubeLeaderboardSchemaLightRepairTest::RunTest(const FString& Parameters)
{
	FHyperTwistClassicCubeLeaderboardState LegacyState;
	LegacyState.SchemaVersion.Reset();

	FHyperTwistClassicCubeLeaderboardEntry LegacyEntry;
	LegacyEntry.PuzzleId.Reset();
	LegacyEntry.ScrambleLength = 20;
	LegacyEntry.FinalTimeMs = 13321;
	LegacyEntry.ReplayId = TEXT("replay-schema-light");
	LegacyEntry.SessionId = TEXT("session-schema-light");
	LegacyEntry.ScrambleNotation = TEXT("R U F");
	LegacyEntry.RecordedAtUtc.Reset();
	LegacyState.Entries = {LegacyEntry};

	const FHyperTwistClassicCubeLeaderboardState NormalizedState =
		UHyperTwistClassicCubeLeaderboardLibrary::NormalizeLeaderboardState(LegacyState);
	TestEqual(
		TEXT("Schema-light leaderboard repair must restore the canonical schema version."),
		NormalizedState.SchemaVersion,
		TEXT("ht-classic-cube-leaderboard/v1")
	);
	TestEqual(
		TEXT("Schema-light leaderboard repair must preserve repairable best-time entries."),
		NormalizedState.Entries.Num(),
		1
	);

	FHyperTwistClassicCubeLeaderboardEntry BestEntry;
	TestTrue(
		TEXT("Schema-light leaderboard repair must still surface the repaired classic-cube best entry."),
		UHyperTwistClassicCubeLeaderboardLibrary::FindBestEntry(
			LegacyState,
			TEXT("cube/3x3x3"),
			20,
			BestEntry
		)
	);
	TestEqual(
		TEXT("Schema-light leaderboard repair must re-anchor entries onto the canonical classic-cube puzzle id."),
		BestEntry.PuzzleId,
		TEXT("cube/3x3x3")
	);
	TestFalse(
		TEXT("Schema-light leaderboard repair must backfill a deterministic recorded-at timestamp."),
		BestEntry.RecordedAtUtc.IsEmpty()
	);
	TestEqual(
		TEXT("Schema-light leaderboard repair must preserve the best recorded time."),
		BestEntry.FinalTimeMs,
		13321
	);
	TestEqual(
		TEXT("Schema-light leaderboard repair must still drive the shared HUD wording path."),
		UHyperTwistClassicCubeLeaderboardLibrary::BuildLeaderboardStatusLine(
			LegacyState,
			TEXT("cube/3x3x3"),
			20
		),
		TEXT("leaderboard: best 00:13.321 for 20-move scramble")
	);
	return true;
}

IMPLEMENT_SIMPLE_AUTOMATION_TEST(
	FHyperTwistClassicCubeLeaderboardStatusLineTest,
	"HyperTwist.Integration.ClassicCube.LeaderboardStatusLine",
	EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)

bool FHyperTwistClassicCubeLeaderboardStatusLineTest::RunTest(const FString& Parameters)
{
	FHyperTwistClassicCubeLeaderboardState LegacyState;
	LegacyState.SchemaVersion.Reset();

	FHyperTwistClassicCubeLeaderboardEntry SlowerEntry;
	SlowerEntry.PuzzleId = TEXT("cube/3x3x3");
	SlowerEntry.ScrambleLength = 20;
	SlowerEntry.FinalTimeMs = 15321;
	SlowerEntry.ReplayId = TEXT("replay-slower");
	SlowerEntry.SessionId = TEXT("session-slower");
	SlowerEntry.RecordedAtUtc = TEXT("2026-06-12T10:10:00Z");

	FHyperTwistClassicCubeLeaderboardEntry FasterEntry = SlowerEntry;
	FasterEntry.FinalTimeMs = 14321;
	FasterEntry.ReplayId = TEXT("replay-faster");
	FasterEntry.SessionId = TEXT("session-faster");
	FasterEntry.RecordedAtUtc = TEXT("2026-06-12T10:05:00Z");

	LegacyState.Entries = {SlowerEntry, FasterEntry};

	TestEqual(
		TEXT("The leaderboard status line must normalize duplicate buckets before choosing the best entry."),
		UHyperTwistClassicCubeLeaderboardLibrary::BuildLeaderboardStatusLine(
			LegacyState,
			TEXT("cube/3x3x3"),
			20
		),
		TEXT("leaderboard: best 00:14.321 for 20-move scramble")
	);
	TestEqual(
		TEXT("The leaderboard status line must surface empty buckets explicitly."),
		UHyperTwistClassicCubeLeaderboardLibrary::BuildLeaderboardStatusLine(
			LegacyState,
			TEXT("cube/3x3x3"),
			25
		),
		TEXT("leaderboard: no local best yet for 25-move scramble")
	);
	return true;
}

IMPLEMENT_SIMPLE_AUTOMATION_TEST(
	FHyperTwistClassicCubeRuntimeReplaySmokeTest,
	"HyperTwist.Integration.ClassicCube.RuntimeReplaySmoke",
	EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)

bool FHyperTwistClassicCubeRuntimeReplaySmokeTest::RunTest(const FString& Parameters)
{
	UGameInstance* GameInstance = NewObject<UGameInstance>();
	TestNotNull(TEXT("The runtime replay smoke must allocate a game instance."), GameInstance);
	if (GameInstance == nullptr)
	{
		return false;
	}

	UHyperTwistTrainingSubsystem* TrainingSubsystem =
		NewObject<UHyperTwistTrainingSubsystem>(GameInstance);
	TestNotNull(
		TEXT("The runtime replay smoke must allocate the training subsystem."),
		TrainingSubsystem
	);
	if (TrainingSubsystem == nullptr)
	{
		return false;
	}

	HyperTwistClassicCubeIntegrationTestInternal::ForceMockRecognitionAndSpeechClients(
		TrainingSubsystem
	);
	UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: about to start active run"));

	const FHyperTwistTrainingRunState StartedRunState =
		TrainingSubsystem->StartSampleClassicTrainingRun(
			TEXT("classic-cube-integration-user"),
			TEXT("classic-cube-runtime-smoke-session"),
			EHyperTwistTrainingDeliveryMode::Timer
		);
	TestTrue(
		TEXT("The runtime replay smoke must start a structurally valid training run."),
		StartedRunState.IsStructurallyValid()
	);
	UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: active run started"));
	AddInfo(TEXT("runtime replay smoke: active run started"));

	AHyperTwistClassicCubeActor* CubeActor =
		NewObject<AHyperTwistClassicCubeActor>(GetTransientPackage());
	TestNotNull(TEXT("The runtime replay smoke must allocate a classic-cube actor."), CubeActor);
	if (CubeActor == nullptr)
	{
		return false;
	}

	CubeActor->ResetCube();

	const TArray<FString> ScrambleSequence = {
		TEXT("R"),
		TEXT("U"),
		TEXT("F2"),
		TEXT("L"),
		TEXT("D"),
		TEXT("B'")
	};
	CubeActor->ApplyScrambleImmediately(ScrambleSequence);

	FHyperTwistClassicCubeReplayMetadata ReplayMetadata;
	ReplayMetadata.PuzzleId = TEXT("cube/3x3x3");
	ReplayMetadata.ScrambleNotation = FString::Join(ScrambleSequence, TEXT(" "));
	ReplayMetadata.ScrambleLength = ScrambleSequence.Num();
	ReplayMetadata.SessionMode = EHyperTwistClassicCubeSessionMode::FreePlay;
	ReplayMetadata.VoiceProfileId = TEXT("en_US-lessac-medium");

	FString MetadataJson;
	TestTrue(
		TEXT("The runtime replay smoke must serialize replay metadata."),
		HyperTwistClassicCubeIntegrationTestInternal::SerializeStruct(
			ReplayMetadata,
			MetadataJson
		)
	);
	TestTrue(
		TEXT("The runtime replay smoke must store replay metadata on the active packet."),
		TrainingSubsystem->SetActiveReplayAnnotationsJson(MetadataJson)
	);

	FHyperTwistStateSnapshot InitialSnapshot;
	TestTrue(
		TEXT("The runtime replay smoke must build an initial runtime snapshot."),
		HyperTwistClassicCubeIntegrationTestInternal::TryBuildRuntimeSnapshot(
			CubeActor,
			StartedRunState.Session.TrainingSessionId,
			InitialSnapshot
		)
	);
	TestTrue(
		TEXT("The runtime replay smoke must append the initial runtime snapshot."),
		TrainingSubsystem->AppendActiveReplayStateSnapshotEvent(InitialSnapshot, 0)
	);
	UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: initial snapshot appended"));
	AddInfo(TEXT("runtime replay smoke: initial snapshot appended"));

	const TArray<FString> MoveSequence = {
		TEXT("R"),
		TEXT("U"),
		TEXT("R'"),
		TEXT("U'"),
		TEXT("F"),
		TEXT("L"),
		TEXT("D"),
		TEXT("B'"),
		TEXT("R2"),
		TEXT("U2")
	};
	for (const FString& MoveNotation : MoveSequence)
	{
		FHyperTwistClassicCubeMoveDescriptor MoveDescriptor;
		if (!TestTrue(
				*FString::Printf(
					TEXT("The runtime replay smoke move '%s' must parse cleanly."),
					*MoveNotation
				),
				UHyperTwistClassicCubeCommandLibrary::TryParseMoveNotation(
					MoveNotation,
					MoveDescriptor
				)))
		{
			return false;
		}

		MoveDescriptor.bGameplayMove = true;
		if (!TestTrue(
				*FString::Printf(
					TEXT("The runtime replay smoke move '%s' must apply immediately."),
					*MoveNotation
				),
				CubeActor->ExecuteMoveDescriptorImmediately(MoveDescriptor)))
		{
			return false;
		}

		if (!TestTrue(
				*FString::Printf(
					TEXT("The runtime replay smoke move '%s' must append to the active replay packet."),
					*MoveNotation
				),
				TrainingSubsystem->AppendActiveReplayMoveEvent(MoveDescriptor.Notation)))
		{
			return false;
		}
	}
	UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: move sequence applied and appended"));
	AddInfo(TEXT("runtime replay smoke: move sequence applied and appended"));

	FHyperTwistStateSnapshot FinalSnapshot;
	TestTrue(
		TEXT("The runtime replay smoke must build a final runtime snapshot."),
		HyperTwistClassicCubeIntegrationTestInternal::TryBuildRuntimeSnapshot(
			CubeActor,
			StartedRunState.Session.TrainingSessionId,
			FinalSnapshot
		)
	);
	TestTrue(
		TEXT("The runtime replay smoke must append the final runtime snapshot."),
		TrainingSubsystem->AppendActiveReplayStateSnapshotEvent(FinalSnapshot)
	);
	UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: final snapshot appended"));
	AddInfo(TEXT("runtime replay smoke: final snapshot appended"));

	const FHyperTwistTrainingRunState RunState = TrainingSubsystem->GetActiveRunState();
	TestTrue(
		TEXT("The runtime replay smoke must preserve a structurally valid replay packet."),
		RunState.ReplayPacket.IsStructurallyValid()
	);
	TestEqual(
		TEXT("The runtime replay smoke must record ten replay move events."),
		HyperTwistClassicCubeIntegrationTestInternal::CountReplayEventsOfType(
			RunState.ReplayPacket,
			EHyperTwistReplayEventType::Move
		),
		10
	);
	TestTrue(
		TEXT("The runtime replay smoke must capture at least two state snapshot events."),
		HyperTwistClassicCubeIntegrationTestInternal::CountReplayEventsOfType(
			RunState.ReplayPacket,
			EHyperTwistReplayEventType::StateSnapshot
		) >= 2
	);

	FString ExportPath;
	TestTrue(
		TEXT("The runtime replay smoke must export the active replay packet."),
		TrainingSubsystem->SaveActiveReplayPacketToDefaultPath(ExportPath)
	);
	UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: replay packet exported"));
	AddInfo(TEXT("runtime replay smoke: replay packet exported"));

	FHyperTwistReplayPacket LoadedPacket;
	FString LoadedPath;
	TestTrue(
		TEXT("The runtime replay smoke must load the exported replay packet."),
		UHyperTwistReplayPersistenceLibrary::LoadReplayPacketFromFile(
			ExportPath,
			LoadedPacket,
			LoadedPath
		)
	);
	TestEqual(
		TEXT("The runtime replay smoke load path must match the exported replay path."),
		LoadedPath,
		ExportPath
	);

	UHyperTwistPuzzleViewerComponent* ViewerComponent =
		NewObject<UHyperTwistPuzzleViewerComponent>(GetTransientPackage());
	TestNotNull(
		TEXT("The runtime replay smoke must allocate a viewer component."),
		ViewerComponent
	);
	TestTrue(
		TEXT("The runtime replay smoke must load the replay packet into the viewer."),
		ViewerComponent != nullptr && ViewerComponent->LoadReplayPacket(LoadedPacket)
	);
	UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: replay packet loaded into viewer"));
	AddInfo(TEXT("runtime replay smoke: replay packet loaded into viewer"));

	FHyperTwistClassicCubeReplayMetadata LoadedMetadata;
	TestTrue(
		TEXT("The runtime replay smoke must deserialize classic-cube replay metadata."),
		HyperTwistClassicCubeIntegrationTestInternal::DeserializeStruct(
			LoadedPacket.AnnotationsJson,
			LoadedMetadata
		)
	);

	AHyperTwistClassicCubeActor* ReplayActor =
		NewObject<AHyperTwistClassicCubeActor>(GetTransientPackage());
	TestNotNull(
		TEXT("The runtime replay smoke must allocate a replay reconstruction actor."),
		ReplayActor
	);
	if (ReplayActor == nullptr)
	{
		return false;
	}

	ReplayActor->ResetCube();
	ReplayActor->ApplyScrambleImmediately(
		HyperTwistClassicCubeIntegrationTestInternal::ParseNotationSequence(
			LoadedMetadata.ScrambleNotation
		)
	);
	for (const FHyperTwistReplayEvent& Event : LoadedPacket.Events)
	{
		if (Event.EventType != EHyperTwistReplayEventType::Move)
		{
			continue;
		}

		FHyperTwistClassicCubeMoveDescriptor MoveDescriptor;
		if (!TestTrue(
				TEXT("Every replay move event must deserialize into a move descriptor."),
				HyperTwistClassicCubeIntegrationTestInternal::TryBuildReplayMoveDescriptor(
					Event,
					MoveDescriptor
				)))
		{
			return false;
		}

		if (!TestTrue(
				TEXT("Every replay move event must reconstruct cleanly on the replay actor."),
				ReplayActor->ExecuteMoveDescriptorImmediately(MoveDescriptor)))
		{
			return false;
		}
	}
	UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: replay move reconstruction completed"));
	AddInfo(TEXT("runtime replay smoke: replay move reconstruction completed"));

	TestEqual(
		TEXT("The reconstructed replay actor must land on the same final facelet state."),
		ReplayActor->GetSolverFaceletString(),
		CubeActor->GetSolverFaceletString()
	);

	IFileManager::Get().Delete(*ExportPath);
	FString CloseError;
	TestTrue(
		TEXT("The runtime replay smoke must close the recognition session cleanly."),
		TrainingSubsystem->CloseActiveRecognitionSession(CloseError)
	);
	TestTrue(
		TEXT("Closing the recognition session must not report an error."),
		CloseError.IsEmpty()
	);
	CloseError.Reset();
	TestTrue(
		TEXT("The runtime replay smoke must close the companion speech session cleanly."),
		TrainingSubsystem->CloseActiveCompanionSpeechSession(CloseError)
	);
	TestTrue(
		TEXT("Closing the companion speech session must not report an error."),
		CloseError.IsEmpty()
	);
	UE_LOG(LogTemp, Display, TEXT("runtime replay smoke: teardown completed"));
	AddInfo(TEXT("runtime replay smoke: teardown completed"));
	TrainingSubsystem->ClearActiveRun();
	return true;
}

#endif
