#include "HyperTwistTraining/HyperTwistTrainingLibrary.h"

#include "HyperTwistTraining/HyperTwistTrainingCatalogLibrary.h"
#include "HyperTwistReplay/HyperTwistReplayLibrary.h"
#include "JsonObjectConverter.h"

namespace HyperTwistTrainingLibraryInternal
{
	int32 GetPenaltyOffsetMs(EHyperTwistTrainingPenalty Penalty)
	{
		switch (Penalty)
		{
		case EHyperTwistTrainingPenalty::Plus2:
			return 2000;
		default:
			return 0;
		}
	}

	EHyperTwistTrainingPenalty CombinePenalties(
		EHyperTwistTrainingPenalty LeftPenalty,
		EHyperTwistTrainingPenalty RightPenalty
	)
	{
		if (LeftPenalty == EHyperTwistTrainingPenalty::DNF || RightPenalty == EHyperTwistTrainingPenalty::DNF)
		{
			return EHyperTwistTrainingPenalty::DNF;
		}

		if (LeftPenalty == EHyperTwistTrainingPenalty::Plus2 || RightPenalty == EHyperTwistTrainingPenalty::Plus2)
		{
			return EHyperTwistTrainingPenalty::Plus2;
		}

		return EHyperTwistTrainingPenalty::None;
	}

	EHyperTwistTrainingPenalty ComputeInspectionPenalty(
		int32 InspectionElapsedMs,
		const FHyperTwistTrainingTimingPolicy& TimingPolicy
	)
	{
		if (!TimingPolicy.bInspectionEnabled || !TimingPolicy.bInspectionPenaltiesEnabled)
		{
			return EHyperTwistTrainingPenalty::None;
		}

		const int32 LimitMs = FMath::Max(0, TimingPolicy.InspectionDurationMs);
		if (LimitMs <= 0 || InspectionElapsedMs <= LimitMs)
		{
			return EHyperTwistTrainingPenalty::None;
		}

		if (InspectionElapsedMs <= LimitMs + 2000)
		{
			return EHyperTwistTrainingPenalty::Plus2;
		}

		return EHyperTwistTrainingPenalty::DNF;
	}

	FHyperTwistTrainingTimingPolicy NormalizeTimingPolicyInternal(
		const FHyperTwistTrainingTimingPolicy& TimingPolicy
	)
	{
		FHyperTwistTrainingTimingPolicy Normalized = TimingPolicy;
		Normalized.InspectionDurationMs = FMath::Max(0, TimingPolicy.InspectionDurationMs);
		Normalized.SplitPhases.Reset();

		TArray<FHyperTwistTrainingSplitPhaseDefinition> SortedPhases = TimingPolicy.SplitPhases;
		SortedPhases.Sort([](const FHyperTwistTrainingSplitPhaseDefinition& Left, const FHyperTwistTrainingSplitPhaseDefinition& Right)
		{
			if (Left.Order != Right.Order)
			{
				return Left.Order < Right.Order;
			}

			return Left.PhaseId < Right.PhaseId;
		});

		TSet<FString> SeenPhaseIds;
		int32 NextOrder = 0;
		for (FHyperTwistTrainingSplitPhaseDefinition PhaseDefinition : SortedPhases)
		{
			if (!PhaseDefinition.IsStructurallyValid() || SeenPhaseIds.Contains(PhaseDefinition.PhaseId))
			{
				continue;
			}

			SeenPhaseIds.Add(PhaseDefinition.PhaseId);
			PhaseDefinition.Order = NextOrder++;
			if (PhaseDefinition.Label.IsEmpty())
			{
				PhaseDefinition.Label = PhaseDefinition.PhaseId;
			}

			Normalized.SplitPhases.Add(PhaseDefinition);
		}

		return Normalized;
	}

	int32 ResolveSplitOrder(
		const FHyperTwistTrainingTimingPolicy& TimingPolicy,
		const FHyperTwistTrainingSplitCapture& SplitCapture
	)
	{
		for (const FHyperTwistTrainingSplitPhaseDefinition& PhaseDefinition : TimingPolicy.SplitPhases)
		{
			if (PhaseDefinition.PhaseId == SplitCapture.PhaseId)
			{
				return PhaseDefinition.Order;
			}
		}

		return INDEX_NONE;
	}

	FString ResolveSplitLabel(
		const FHyperTwistTrainingTimingPolicy& TimingPolicy,
		const FHyperTwistTrainingSplitCapture& SplitCapture
	)
	{
		if (!SplitCapture.Label.IsEmpty())
		{
			return SplitCapture.Label;
		}

		for (const FHyperTwistTrainingSplitPhaseDefinition& PhaseDefinition : TimingPolicy.SplitPhases)
		{
			if (PhaseDefinition.PhaseId == SplitCapture.PhaseId)
			{
				return PhaseDefinition.Label;
			}
		}

		return SplitCapture.PhaseId;
	}

	TArray<FHyperTwistTrainingSplitCapture> NormalizeSplitCaptures(
		const FHyperTwistTrainingTimingPolicy& TimingPolicy,
		const TArray<FHyperTwistTrainingSplitCapture>& SplitCaptures
	)
	{
		TArray<FHyperTwistTrainingSplitCapture> NormalizedCaptures;
		TSet<FString> SeenPhaseIds;

		for (FHyperTwistTrainingSplitCapture SplitCapture : SplitCaptures)
		{
			if (!SplitCapture.IsStructurallyValid())
			{
				continue;
			}

			if (SeenPhaseIds.Contains(SplitCapture.PhaseId))
			{
				continue;
			}

			SplitCapture.TimestampMs = FMath::Max(0, SplitCapture.TimestampMs);
			SplitCapture.Label = ResolveSplitLabel(TimingPolicy, SplitCapture);
			SeenPhaseIds.Add(SplitCapture.PhaseId);
			NormalizedCaptures.Add(SplitCapture);
		}

		NormalizedCaptures.Sort([&TimingPolicy](const FHyperTwistTrainingSplitCapture& Left, const FHyperTwistTrainingSplitCapture& Right)
		{
			const int32 LeftOrder = ResolveSplitOrder(TimingPolicy, Left);
			const int32 RightOrder = ResolveSplitOrder(TimingPolicy, Right);
			if (LeftOrder != INDEX_NONE || RightOrder != INDEX_NONE)
			{
				if (LeftOrder == INDEX_NONE)
				{
					return false;
				}
				if (RightOrder == INDEX_NONE)
				{
					return true;
				}
				if (LeftOrder != RightOrder)
				{
					return LeftOrder < RightOrder;
				}
			}

			return Left.TimestampMs < Right.TimestampMs;
		});

		return NormalizedCaptures;
	}

	TArray<FHyperTwistPhaseTiming> BuildDerivedPhaseTimings(
		const FHyperTwistTrainingTimingPolicy& TimingPolicy,
		const FHyperTwistTrainingTimingBreakdown& TimingBreakdown
	)
	{
		TArray<FHyperTwistPhaseTiming> PhaseTimings;
		const TArray<FHyperTwistTrainingSplitCapture> NormalizedCaptures = NormalizeSplitCaptures(
			TimingPolicy,
			TimingBreakdown.SplitCaptures
		);

		int32 PhaseStartMs = 0;
		int32 ConsumedPhaseCount = 0;
		for (const FHyperTwistTrainingSplitCapture& SplitCapture : NormalizedCaptures)
		{
			const int32 PhaseEndMs = FMath::Max(PhaseStartMs, SplitCapture.TimestampMs);

			FHyperTwistPhaseTiming PhaseTiming;
			PhaseTiming.PhaseKind = !SplitCapture.Label.IsEmpty() ? SplitCapture.Label : SplitCapture.PhaseId;
			PhaseTiming.StartedAtMs = PhaseStartMs;
			PhaseTiming.EndedAtMs = PhaseEndMs;
			PhaseTiming.DurationMs = FMath::Max(0, PhaseEndMs - PhaseStartMs);
			PhaseTimings.Add(PhaseTiming);

			PhaseStartMs = PhaseEndMs;
			ConsumedPhaseCount += 1;
		}

		if (TimingBreakdown.RawSolveTimeMs > PhaseStartMs && TimingPolicy.SplitPhases.IsValidIndex(ConsumedPhaseCount))
		{
			const FHyperTwistTrainingSplitPhaseDefinition& FinalPhaseDefinition = TimingPolicy.SplitPhases[ConsumedPhaseCount];

			FHyperTwistPhaseTiming PhaseTiming;
			PhaseTiming.PhaseKind = FinalPhaseDefinition.Label;
			PhaseTiming.StartedAtMs = PhaseStartMs;
			PhaseTiming.EndedAtMs = TimingBreakdown.RawSolveTimeMs;
			PhaseTiming.DurationMs = FMath::Max(0, PhaseTiming.EndedAtMs - PhaseTiming.StartedAtMs);
			PhaseTimings.Add(PhaseTiming);
		}

		return PhaseTimings;
	}

	EHyperTwistCaptureMode ToCaptureMode(EHyperTwistTrainingDeliveryMode Mode)
	{
		switch (Mode)
		{
		case EHyperTwistTrainingDeliveryMode::RecognitionAssisted:
			return EHyperTwistCaptureMode::Recognition;
		case EHyperTwistTrainingDeliveryMode::SmartDevice:
			return EHyperTwistCaptureMode::SmartDevice;
		default:
			return EHyperTwistCaptureMode::Runtime;
		}
	}

	bool IsModeAllowed(
		const FHyperTwistTrainingCase& TrainingCase,
		EHyperTwistTrainingDeliveryMode Mode
	)
	{
		return TrainingCase.AllowedDeliveryModes.Num() == 0 || TrainingCase.AllowedDeliveryModes.Contains(Mode);
	}

	const FHyperTwistProgressionMemory* FindMemoryForCase(
		const TArray<FHyperTwistProgressionMemory>& Memories,
		const FString& CaseId
	)
	{
		for (const FHyperTwistProgressionMemory& Memory : Memories)
		{
			if (Memory.CaseId == CaseId)
			{
				return &Memory;
			}
		}

		return nullptr;
	}

	const FHyperTwistTrainingCase* FindCaseById(
		const FHyperTwistTrainingDeck& Deck,
		const FString& CaseId
	)
	{
		for (const FHyperTwistTrainingCase& TrainingCase : Deck.Cases)
		{
			if (TrainingCase.CaseId == CaseId)
			{
				return &TrainingCase;
			}
		}

		return nullptr;
	}

	bool ContainsCaseId(const TArray<FString>& CaseIds, const FString& CaseId)
	{
		return CaseIds.ContainsByPredicate([&CaseId](const FString& Value)
		{
			return Value == CaseId;
		});
	}

	const FHyperTwistTrainingCase* FindFirstCompatibleCase(
		const FHyperTwistTrainingDeck& Deck,
		EHyperTwistTrainingDeliveryMode Mode
	)
	{
		for (const FHyperTwistTrainingCase& TrainingCase : Deck.Cases)
		{
			if (TrainingCase.IsStructurallyValid() && IsModeAllowed(TrainingCase, Mode))
			{
				return &TrainingCase;
			}
		}

		return nullptr;
	}

	FHyperTwistTrainingDeck FilterDeckToCaseIds(
		const FHyperTwistTrainingDeck& Deck,
		const TArray<FString>& AllowedCaseIds
	)
	{
		FHyperTwistTrainingDeck FilteredDeck = Deck;
		FilteredDeck.Cases.Reset();

		for (const FHyperTwistTrainingCase& TrainingCase : Deck.Cases)
		{
			if (ContainsCaseId(AllowedCaseIds, TrainingCase.CaseId))
			{
				FilteredDeck.Cases.Add(TrainingCase);
			}
		}

		return FilteredDeck;
	}

	float GetBaseDifficultyWeight(const FHyperTwistTrainingCase& TrainingCase)
	{
		return TrainingCase.Difficulty > 0.0f ? TrainingCase.Difficulty : 1.0f;
	}

	float GetBaseResurfacePriority(const FHyperTwistTrainingCase& TrainingCase)
	{
		return FMath::Max(1.0f, GetBaseDifficultyWeight(TrainingCase) * 10.0f);
	}

	FHyperTwistPuzzleDefinitionRef MakeDefinitionForCase(const FHyperTwistTrainingCase& TrainingCase)
	{
		FHyperTwistPuzzleDefinitionRef RetainedHyperDefinition;
		if (UHyperTwistTrainingCatalogLibrary::TryResolveRetainedHyperPuzzleDefinition(
			TrainingCase.PuzzleId,
			RetainedHyperDefinition
		))
		{
			return RetainedHyperDefinition;
		}

		FHyperTwistPuzzleDefinitionRef Definition;
		Definition.PuzzleId = TrainingCase.PuzzleId;
		Definition.DefinitionVersion = TEXT("2026.04");

		if (TrainingCase.PuzzleId.StartsWith(TEXT("hypercube/")))
		{
			Definition.PuzzleFamily = EHyperTwistPuzzleFamily::Hypercube;
			Definition.NotationProfile = TEXT("hyper-4d");
			Definition.SizeVector = {2, 2, 2, 2};
			Definition.Dimension = 4;
			return Definition;
		}

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

		const FString Prefix = TEXT("cube/");
		if (TrainingCase.PuzzleId.StartsWith(Prefix))
		{
			const FString SizePart = TrainingCase.PuzzleId.RightChop(Prefix.Len());
			TArray<FString> Tokens;
			SizePart.ParseIntoArray(Tokens, TEXT("x"), true);
			TArray<int32> ParsedSizes;
			for (const FString& Token : Tokens)
			{
				const int32 ParsedValue = FCString::Atoi(*Token);
				if (ParsedValue > 0)
				{
					ParsedSizes.Add(ParsedValue);
				}
			}

			if (ParsedSizes.Num() > 0)
			{
				Definition.SizeVector = ParsedSizes;
				Definition.Dimension = ParsedSizes.Num();
			}
		}

		return Definition;
	}

	FHyperTwistReplayPacket MakeReplayPacketForRun(
		const FHyperTwistTrainingSession& Session,
		const FHyperTwistTrainingCase& TrainingCase,
		const FHyperTwistTrainingTimingPolicy& TimingPolicy
	)
	{
		FHyperTwistReplayPacket Packet;
		Packet.ReplayId = FString::Printf(TEXT("replay_%s"), *Session.TrainingSessionId);
		Packet.PuzzleDefinition = MakeDefinitionForCase(TrainingCase);
		Packet.CaptureMode = ToCaptureMode(Session.Mode);
		Packet.SessionId = Session.TrainingSessionId;
		Packet.StartedAtUtc = Session.StartedAtUtc;
		Packet.EndedAtUtc = Session.EndedAtUtc;

		const FHyperTwistTrainingTimingPolicy NormalizedTimingPolicy = NormalizeTimingPolicyInternal(TimingPolicy);
		FHyperTwistReplayEvent StartEvent;
		StartEvent.Sequence = 1;
		StartEvent.TimeMs = 0;
		StartEvent.EventType = NormalizedTimingPolicy.bInspectionEnabled
			? EHyperTwistReplayEventType::InspectionStart
			: EHyperTwistReplayEventType::TimerStart;
		StartEvent.EventId = NormalizedTimingPolicy.bInspectionEnabled
			? TEXT("evt_inspection_start")
			: TEXT("evt_timer_start");

		Packet = UHyperTwistReplayLibrary::AppendReplayEvent(Packet, StartEvent);
		return Packet;
	}

	int32 GetObservedAttemptTimeMs(const FHyperTwistTrainingAttempt& Attempt)
	{
		return FMath::Max(
			0,
			Attempt.TimingBreakdown.InspectionElapsedMs
				+ Attempt.TimingBreakdown.RawSolveTimeMs
				+ FMath::Max(0, Attempt.RecognitionTimeMs)
		);
	}

	float ComputeSelectionScore(
		const FHyperTwistTrainingCase& TrainingCase,
		const FHyperTwistProgressionMemory* Memory,
		const FString& CurrentCaseId
	)
	{
		float Score = Memory ? Memory->ResurfacePriority : GetBaseResurfacePriority(TrainingCase);

		if (Memory)
		{
			const int32 FailPressure = FMath::Max(0, Memory->AttemptCount - Memory->SuccessCount);
			Score += static_cast<float>(FailPressure) * 0.35f;
			Score -= static_cast<float>(Memory->SuccessCount) * 0.10f;
			Score -= FMath::Max(0.0f, Memory->StrengthScore) * 0.20f;

			if (Memory->LastOutcome != EHyperTwistTrainingAttemptResult::Success)
			{
				Score += 0.5f;
			}
		}

		if (!CurrentCaseId.IsEmpty() && TrainingCase.CaseId == CurrentCaseId)
		{
			Score -= 1000.0f;
		}

		return Score;
	}

	bool MatchesPuzzleFamily(const FString& PuzzleId, EHyperTwistPuzzleFamily PuzzleFamily)
	{
		FHyperTwistPuzzleDefinitionRef RetainedHyperDefinition;
		if (UHyperTwistTrainingCatalogLibrary::TryResolveRetainedHyperPuzzleDefinition(
			PuzzleId,
			RetainedHyperDefinition
		))
		{
			return RetainedHyperDefinition.PuzzleFamily == PuzzleFamily;
		}

		switch (PuzzleFamily)
		{
		case EHyperTwistPuzzleFamily::ClassicCube:
			return PuzzleId.StartsWith(TEXT("cube/"));
		case EHyperTwistPuzzleFamily::Hypercube:
			return PuzzleId.StartsWith(TEXT("hypercube/"));
		default:
			return !PuzzleId.IsEmpty();
		}
	}

	template <typename TStruct>
	FString SerializePayload(const TStruct& Payload)
	{
		FString PayloadJson;
		FJsonObjectConverter::UStructToJsonObjectString(Payload, PayloadJson);
		return PayloadJson;
	}

	FString ResolveDrillTimestamp(const FString& Value)
	{
		return Value.IsEmpty() ? FDateTime::UtcNow().ToIso8601() : Value;
	}

	FString DrillModeToToken(EHyperTwistTrainingDrillModeKind DrillMode)
	{
		switch (DrillMode)
		{
		case EHyperTwistTrainingDrillModeKind::Recognition:
			return TEXT("recognition");
		case EHyperTwistTrainingDrillModeKind::Recall:
			return TEXT("recall");
		case EHyperTwistTrainingDrillModeKind::Execution:
			return TEXT("execution");
		case EHyperTwistTrainingDrillModeKind::Reconstruction:
			return TEXT("reconstruction");
		default:
			return TEXT("drill");
		}
	}

	FString DrillVisibilityToToken(EHyperTwistTrainingDrillVisibilityPolicy VisibilityPolicy)
	{
		switch (VisibilityPolicy)
		{
		case EHyperTwistTrainingDrillVisibilityPolicy::FullInformation:
			return TEXT("full");
		case EHyperTwistTrainingDrillVisibilityPolicy::PromptOnly:
			return TEXT("prompt-only");
		case EHyperTwistTrainingDrillVisibilityPolicy::MaskedState:
			return TEXT("masked");
		case EHyperTwistTrainingDrillVisibilityPolicy::PartialState:
			return TEXT("partial");
		default:
			return TEXT("visibility");
		}
	}

	TArray<FString> BuildDrillCaseIds(const FHyperTwistTrainingMethodDrillDefinition& DrillDefinition)
	{
		TArray<FString> CaseIds;
		if (!DrillDefinition.SourceDeck.IsStructurallyValid())
		{
			return CaseIds;
		}

		if (DrillDefinition.FocusCaseIds.Num() > 0)
		{
			for (const FString& CaseId : DrillDefinition.FocusCaseIds)
			{
				const FHyperTwistTrainingCase* MatchingCase = FindCaseById(DrillDefinition.SourceDeck, CaseId);
				if (MatchingCase != nullptr && MatchingCase->IsStructurallyValid())
				{
					CaseIds.AddUnique(CaseId);
				}
			}
		}

		if (CaseIds.Num() == 0)
		{
			for (const FHyperTwistTrainingCase& TrainingCase : DrillDefinition.SourceDeck.Cases)
			{
				if (TrainingCase.IsStructurallyValid())
				{
					CaseIds.AddUnique(TrainingCase.CaseId);
				}
			}
		}

		return CaseIds;
	}

	const FHyperTwistTrainingCase* FindDrillCaseById(
		const FHyperTwistTrainingMethodDrillDefinition& DrillDefinition,
		const FString& CaseId
	)
	{
		return FindCaseById(DrillDefinition.SourceDeck, CaseId);
	}

	void NormalizeDrillQueueAgainstDefinition(
		const FHyperTwistTrainingMethodDrillDefinition& DrillDefinition,
		TArray<FString>& PendingCaseIds,
		TArray<FString>& CompletedCaseIds,
		FString& CurrentCaseId
	)
	{
		const TArray<FString> AllowedCaseIds = BuildDrillCaseIds(DrillDefinition);
		TSet<FString> AllowedCaseSet(AllowedCaseIds);

		TArray<FString> NormalizedPending;
		for (const FString& CaseId : PendingCaseIds)
		{
			if (AllowedCaseSet.Contains(CaseId))
			{
				NormalizedPending.AddUnique(CaseId);
			}
		}

		TArray<FString> NormalizedCompleted;
		for (const FString& CaseId : CompletedCaseIds)
		{
			if (AllowedCaseSet.Contains(CaseId))
			{
				NormalizedCompleted.AddUnique(CaseId);
			}
		}

		PendingCaseIds = MoveTemp(NormalizedPending);
		CompletedCaseIds = MoveTemp(NormalizedCompleted);

		if (!CurrentCaseId.IsEmpty())
		{
			if (!AllowedCaseSet.Contains(CurrentCaseId))
			{
				CurrentCaseId.Reset();
			}
			else
			{
				CompletedCaseIds.Remove(CurrentCaseId);
			}
		}
	}

	FHyperTwistTrainingMethodDrillSummary BuildDrillSummaryInternal(
		const FHyperTwistTrainingMethodDrillRunState& RunState
	)
	{
		FHyperTwistTrainingMethodDrillSummary Summary;
		if (!RunState.IsStructurallyValid())
		{
			return Summary;
		}

		TSet<FString> KnownCaseIds;
		for (const FString& CaseId : RunState.PendingCaseIds)
		{
			KnownCaseIds.Add(CaseId);
		}
		for (const FString& CaseId : RunState.CompletedCaseIds)
		{
			KnownCaseIds.Add(CaseId);
		}
		if (!RunState.CurrentCase.CaseId.IsEmpty())
		{
			KnownCaseIds.Add(RunState.CurrentCase.CaseId);
		}

		Summary.RunId = RunState.RunId;
		Summary.DrillId = RunState.DrillDefinition.DrillId;
		Summary.UserId = RunState.UserId;
		Summary.LifecycleState = RunState.LifecycleState;
		Summary.TotalCaseCount = KnownCaseIds.Num();
		Summary.PendingCaseCount = RunState.PendingCaseIds.Num();
		Summary.CompletedCaseCount = RunState.CompletedCaseIds.Num();
		Summary.ResponseCount = RunState.Responses.Num();
		Summary.RevealCount = RunState.RevealCount;
		Summary.RepeatCount = RunState.RepeatCount;
		Summary.bAnswerHiddenUntilReveal = RunState.DrillDefinition.bAnswerHiddenUntilReveal;
		Summary.CurrentCaseId = RunState.CurrentCase.CaseId;
		Summary.LastUpdatedAtUtc = RunState.LastUpdatedAtUtc;
		return Summary;
	}

	struct FMethodDrillCaseMetrics
	{
		int32 AttemptCount = 0;
		int32 CorrectResponseCount = 0;
		int32 IncorrectResponseCount = 0;
		int32 RevealCount = 0;
		int32 RepeatCount = 0;
		int64 ElapsedAccumulator = 0;
		int32 ElapsedSampleCount = 0;
		int32 BestElapsedMs = 0;
		int32 WorstElapsedMs = 0;
	};

	TArray<FString> BuildKnownDrillCaseIds(const FHyperTwistTrainingMethodDrillRunState& RunState)
	{
		TArray<FString> KnownCaseIds = BuildDrillCaseIds(RunState.DrillDefinition);
		for (const FString& CaseId : RunState.PendingCaseIds)
		{
			if (!CaseId.IsEmpty())
			{
				KnownCaseIds.AddUnique(CaseId);
			}
		}
		for (const FString& CaseId : RunState.CompletedCaseIds)
		{
			if (!CaseId.IsEmpty())
			{
				KnownCaseIds.AddUnique(CaseId);
			}
		}
		if (!RunState.CurrentCase.CaseId.IsEmpty())
		{
			KnownCaseIds.AddUnique(RunState.CurrentCase.CaseId);
		}
		for (const FHyperTwistTrainingMethodDrillResponseRecord& Response : RunState.Responses)
		{
			if (!Response.CaseId.IsEmpty())
			{
				KnownCaseIds.AddUnique(Response.CaseId);
			}
		}

		return KnownCaseIds;
	}

	FMethodDrillCaseMetrics CollectMethodDrillCaseMetrics(
		const FHyperTwistTrainingMethodDrillRunState& RunState,
		const FString& CaseId
	)
	{
		FMethodDrillCaseMetrics Metrics;
		for (const FHyperTwistTrainingMethodDrillResponseRecord& Response : RunState.Responses)
		{
			if (Response.CaseId != CaseId)
			{
				continue;
			}

			Metrics.AttemptCount += 1;
			Metrics.CorrectResponseCount += Response.bMarkedCorrect ? 1 : 0;
			Metrics.IncorrectResponseCount += Response.bMarkedCorrect ? 0 : 1;
			Metrics.RevealCount += Response.bAnswerWasRevealed ? 1 : 0;
			Metrics.RepeatCount += Response.bWasRepeat ? 1 : 0;

			if (Response.ElapsedMs > 0)
			{
				Metrics.ElapsedAccumulator += Response.ElapsedMs;
				Metrics.ElapsedSampleCount += 1;
				Metrics.BestElapsedMs = Metrics.BestElapsedMs == 0
					? Response.ElapsedMs
					: FMath::Min(Metrics.BestElapsedMs, Response.ElapsedMs);
				Metrics.WorstElapsedMs = FMath::Max(Metrics.WorstElapsedMs, Response.ElapsedMs);
			}
		}

		return Metrics;
	}

	EHyperTwistTrainingMethodDrillDiagnosticState ResolveMethodDrillCaseDiagnosticState(
		const FMethodDrillCaseMetrics& Metrics,
		bool bCompleted,
		bool bStillPending
	)
	{
		if (bStillPending)
		{
			return EHyperTwistTrainingMethodDrillDiagnosticState::Deferred;
		}
		if (Metrics.RepeatCount > 0 || Metrics.IncorrectResponseCount > 0)
		{
			return EHyperTwistTrainingMethodDrillDiagnosticState::RepeatBurden;
		}
		if (Metrics.RevealCount > 0)
		{
			return EHyperTwistTrainingMethodDrillDiagnosticState::AssistedCompletion;
		}
		if (bCompleted)
		{
			return EHyperTwistTrainingMethodDrillDiagnosticState::CleanCompletion;
		}

		return EHyperTwistTrainingMethodDrillDiagnosticState::Unknown;
	}

	EHyperTwistTrainingReviewGrade ResolveMethodDrillCaseReviewGrade(
		const FMethodDrillCaseMetrics& Metrics,
		bool bCompleted,
		bool bStillPending,
		bool bWasCarryoverCase
	)
	{
		if (bStillPending)
		{
			return EHyperTwistTrainingReviewGrade::Again;
		}
		if (Metrics.RepeatCount > 1 || Metrics.IncorrectResponseCount > 0)
		{
			return EHyperTwistTrainingReviewGrade::Again;
		}
		if (Metrics.RepeatCount == 1 || Metrics.RevealCount > 0)
		{
			return EHyperTwistTrainingReviewGrade::Hard;
		}
		if (bWasCarryoverCase)
		{
			return EHyperTwistTrainingReviewGrade::Good;
		}
		if (bCompleted && Metrics.AttemptCount > 0 && Metrics.CorrectResponseCount == Metrics.AttemptCount)
		{
			return EHyperTwistTrainingReviewGrade::Easy;
		}
		if (bCompleted)
		{
			return EHyperTwistTrainingReviewGrade::Good;
		}

		return EHyperTwistTrainingReviewGrade::Hard;
	}

	FString BuildMethodDrillCaseBoundaryReason(
		const FMethodDrillCaseMetrics& Metrics,
		bool bCompleted,
		bool bStillPending,
		bool bWasCarryoverCase
	)
	{
		if (bStillPending)
		{
			return TEXT("case remains pending or active and should stay in follow-up focus");
		}
		if (Metrics.RepeatCount > 1 || Metrics.IncorrectResponseCount > 0)
		{
			return TEXT("case required repeated or incorrect responses before stabilization");
		}
		if (Metrics.RepeatCount == 1)
		{
			return TEXT("case required an immediate repeat before closure");
		}
		if (Metrics.RevealCount > 0)
		{
			return TEXT("case depended on answer reveal assistance");
		}
		if (bWasCarryoverCase)
		{
			return TEXT("case closed cleanly after resuming carried-over drill work");
		}
		if (bCompleted)
		{
			return TEXT("case closed cleanly without reveal or repeat pressure");
		}

		return TEXT("case has no actionable diagnostic boundary");
	}

	float BuildMethodDrillCasePriorityScore(
		const FMethodDrillCaseMetrics& Metrics,
		bool bStillPending,
		bool bWasCarryoverCase
	)
	{
		float Score = 0.0f;
		if (bStillPending)
		{
			Score += 100.0f;
		}

		Score += static_cast<float>(Metrics.RepeatCount) * 45.0f;
		Score += static_cast<float>(Metrics.IncorrectResponseCount) * 30.0f;
		if (Metrics.RevealCount > 0)
		{
			Score += 20.0f;
		}
		if (bWasCarryoverCase)
		{
			Score += 10.0f;
		}

		return Score;
	}

	FString BuildMethodDrillRunBoundaryReason(const FHyperTwistTrainingMethodDrillDiagnosticPacket& Packet)
	{
		if (Packet.bBoundaryTriggeredByPendingCases)
		{
			return TEXT("deferred drill work remains open and should be revisited first");
		}
		if (Packet.bBoundaryTriggeredByRepeatBurden)
		{
			return TEXT("repeat burden or incorrect responses still dominate the follow-up set");
		}
		if (Packet.bBoundaryTriggeredByAssistance)
		{
			return TEXT("answer reveal assistance was needed on completed drill cases");
		}
		if (Packet.bBoundaryTriggeredByResumeCarryover)
		{
			return TEXT("resumed carryover work closed cleanly but should remain reviewable");
		}
		if (Packet.CompletedCaseCount > 0 && Packet.CompletedCaseCount == Packet.TotalCaseCount)
		{
			return TEXT("drill completed cleanly without reveal or repeat pressure");
		}

		return TEXT("drill diagnostic packet is neutral");
	}

	EHyperTwistTrainingReviewGrade ResolveMethodDrillRunReviewGrade(
		const FHyperTwistTrainingMethodDrillDiagnosticPacket& Packet
	)
	{
		if (Packet.bBoundaryTriggeredByPendingCases)
		{
			return EHyperTwistTrainingReviewGrade::Again;
		}

		const bool bHasAgainCase = Packet.CasePackets.ContainsByPredicate(
			[](const FHyperTwistTrainingMethodDrillCaseComparisonPacket& CasePacket)
			{
				return CasePacket.RecommendedReviewGrade == EHyperTwistTrainingReviewGrade::Again;
			}
		);
		if (bHasAgainCase)
		{
			return EHyperTwistTrainingReviewGrade::Again;
		}
		if (Packet.bBoundaryTriggeredByRepeatBurden || Packet.bBoundaryTriggeredByAssistance)
		{
			return EHyperTwistTrainingReviewGrade::Hard;
		}
		if (Packet.bBoundaryTriggeredByResumeCarryover)
		{
			return EHyperTwistTrainingReviewGrade::Good;
		}
		if (Packet.CompletedCaseCount > 0
			&& Packet.CompletedCaseCount == Packet.TotalCaseCount
			&& Packet.ResponseCount >= Packet.CompletedCaseCount)
		{
			return EHyperTwistTrainingReviewGrade::Easy;
		}
		if (Packet.CompletedCaseCount == Packet.TotalCaseCount)
		{
			return EHyperTwistTrainingReviewGrade::Good;
		}

		return EHyperTwistTrainingReviewGrade::Hard;
	}

	EHyperTwistTrainingMethodDrillDiagnosticState ResolveMethodDrillRunDiagnosticState(
		const FHyperTwistTrainingMethodDrillDiagnosticPacket& Packet
	)
	{
		if (Packet.bBoundaryTriggeredByPendingCases)
		{
			return EHyperTwistTrainingMethodDrillDiagnosticState::Deferred;
		}
		if (Packet.bBoundaryTriggeredByRepeatBurden)
		{
			return EHyperTwistTrainingMethodDrillDiagnosticState::RepeatBurden;
		}
		if (Packet.bBoundaryTriggeredByAssistance)
		{
			return EHyperTwistTrainingMethodDrillDiagnosticState::AssistedCompletion;
		}
		if (Packet.CompletedCaseCount > 0 && Packet.CompletedCaseCount == Packet.TotalCaseCount)
		{
			return EHyperTwistTrainingMethodDrillDiagnosticState::CleanCompletion;
		}

		return EHyperTwistTrainingMethodDrillDiagnosticState::Unknown;
	}
}

FHyperTwistContentPack UHyperTwistTrainingLibrary::MakeSampleClassicContentPack()
{
	FHyperTwistTrainingSourceAttribution FirstPartyAttribution;
	FirstPartyAttribution.SourceMode = EHyperTwistTrainingSourceMode::FirstParty;
	FirstPartyAttribution.SourceNotes = TEXT("First-party HyperTwist starter content pack.");

	FHyperTwistTrainingCase CaseA;
	CaseA.CaseId = TEXT("cross-case-01");
	CaseA.PuzzleId = TEXT("cube/3x3x3");
	CaseA.PromptKind = EHyperTwistTrainingPromptKind::Algorithm;
	CaseA.PromptLabel = TEXT("Cross 01");
	CaseA.CanonicalNotation = TEXT("R F");
	CaseA.ScrambleNotation = TEXT("F' U R U' R'");
	CaseA.SubsetId = TEXT("cross");
	CaseA.Difficulty = 0.2f;
	CaseA.Tags = {TEXT("cross"), TEXT("seed")};
	CaseA.AllowedDeliveryModes = {EHyperTwistTrainingDeliveryMode::Timer, EHyperTwistTrainingDeliveryMode::VirtualCube};
	CaseA.TimeTargetMs = 3000;
	CaseA.SourceAttribution = FirstPartyAttribution;

	FHyperTwistTrainingCase CaseB;
	CaseB.CaseId = TEXT("cross-case-02");
	CaseB.PuzzleId = TEXT("cube/3x3x3");
	CaseB.PromptKind = EHyperTwistTrainingPromptKind::Algorithm;
	CaseB.PromptLabel = TEXT("Cross 02");
	CaseB.CanonicalNotation = TEXT("F U R");
	CaseB.ScrambleNotation = TEXT("R U R' U F' U2");
	CaseB.SubsetId = TEXT("cross");
	CaseB.Difficulty = 0.35f;
	CaseB.Tags = {TEXT("cross"), TEXT("seed")};
	CaseB.AllowedDeliveryModes = {EHyperTwistTrainingDeliveryMode::Timer, EHyperTwistTrainingDeliveryMode::VirtualCube};
	CaseB.TimeTargetMs = 3500;
	CaseB.SourceAttribution = FirstPartyAttribution;

	FHyperTwistTrainingDeck Deck;
	Deck.DeckId = TEXT("cross-1-to-8");
	Deck.Title = TEXT("Cross 1 to 8");
	Deck.DeliveryModes = {EHyperTwistTrainingDeliveryMode::Timer, EHyperTwistTrainingDeliveryMode::VirtualCube};
	Deck.SelectionPolicy = EHyperTwistTrainingSelectionPolicy::Weighted;
	Deck.bUsesRealScrambles = false;
	Deck.bSupportsAlgorithmReveal = true;
	Deck.bSupportsVirtualCube = true;
	Deck.bSupportsSmartCube = false;

	FHyperTwistTrainingSplitPhaseDefinition CrossPhaseDefinition;
	CrossPhaseDefinition.PhaseId = TEXT("cross");
	CrossPhaseDefinition.Label = TEXT("Cross");
	CrossPhaseDefinition.Order = 0;

	Deck.TimingPolicy.bInspectionEnabled = true;
	Deck.TimingPolicy.InspectionDurationMs = 15000;
	Deck.TimingPolicy.bInspectionPenaltiesEnabled = true;
	Deck.TimingPolicy.bAllowManualPenalty = true;
	Deck.TimingPolicy.SplitPhases = {CrossPhaseDefinition};
	Deck.Cases = {CaseA, CaseB};
	Deck.DifficultyBand = TEXT("starter");
	Deck.Tags = {TEXT("classic"), TEXT("cross")};
	Deck.SubsetId = TEXT("cross");
	Deck.SourceAttribution = FirstPartyAttribution;

	FHyperTwistTrainingTrack Track;
	Track.TrackId = TEXT("cross");
	Track.Title = TEXT("Cross");
	Track.Goal = TEXT("build fast and consistent cross execution");
	Track.Decks = {Deck};
	Track.SourceAttribution = FirstPartyAttribution;

	FHyperTwistContentPack ContentPack;
	ContentPack.ContentPackId = TEXT("classic-3x3-core");
	ContentPack.Title = TEXT("Classic 3x3 Core");
	ContentPack.Version = TEXT("2026.04");
	ContentPack.PuzzleFamily = EHyperTwistPuzzleFamily::ClassicCube;
	ContentPack.NotationProfile = TEXT("classic-wca");
	ContentPack.Tracks = {Track};
	ContentPack.SourceAttribution = FirstPartyAttribution;
	return ContentPack;
}

bool UHyperTwistTrainingLibrary::IsTrainingCaseCompatibleWithPack(
	const FHyperTwistTrainingCase& TrainingCase,
	const FHyperTwistContentPack& ContentPack
)
{
	return TrainingCase.IsStructurallyValid()
		&& ContentPack.IsStructurallyValid()
		&& !ContentPack.NotationProfile.IsEmpty()
		&& HyperTwistTrainingLibraryInternal::MatchesPuzzleFamily(TrainingCase.PuzzleId, ContentPack.PuzzleFamily);
}

bool UHyperTwistTrainingLibrary::CanStartTrainingSession(
	const FHyperTwistTrainingDeck& Deck,
	const FHyperTwistContentPack& ContentPack
)
{
	if (!Deck.IsStructurallyValid() || !ContentPack.IsStructurallyValid())
	{
		return false;
	}

	for (const FHyperTwistTrainingCase& TrainingCase : Deck.Cases)
	{
		if (!IsTrainingCaseCompatibleWithPack(TrainingCase, ContentPack))
		{
			return false;
		}
	}

	return true;
}

FHyperTwistTrainingSession UHyperTwistTrainingLibrary::StartTrainingSession(
	const FHyperTwistTrainingDeck& Deck,
	const FString& UserId,
	const FString& SessionId,
	EHyperTwistTrainingDeliveryMode Mode
)
{
	FHyperTwistTrainingSession UpdatedSession;
	UpdatedSession.TrainingSessionId = SessionId.IsEmpty() ? TEXT("training_session_01") : SessionId;
	UpdatedSession.DeckId = Deck.DeckId;
	UpdatedSession.UserId = UserId.IsEmpty() ? TEXT("local-user") : UserId;
	UpdatedSession.StartedAtUtc = TEXT("2026-04-25T12:00:00Z");
	UpdatedSession.Mode = Mode;
	UpdatedSession.SelectionPolicy = Deck.SelectionPolicy;
	UpdatedSession.SessionState = Deck.IsStructurallyValid()
		? EHyperTwistTrainingSessionState::Ready
		: EHyperTwistTrainingSessionState::Created;
	if (Deck.Cases.Num() > 0)
	{
		UpdatedSession.CurrentCaseId = Deck.Cases[0].CaseId;
	}
	return UpdatedSession;
}

FHyperTwistTrainingRunState UHyperTwistTrainingLibrary::StartTrainingRun(
	const FHyperTwistTrainingDeck& Deck,
	const FString& UserId,
	const FString& SessionId,
	EHyperTwistTrainingDeliveryMode Mode
)
{
	FHyperTwistTrainingRunState RunState;
	if (!Deck.IsStructurallyValid())
	{
		return RunState;
	}

	RunState.ActiveDeck = Deck;
	RunState.Session = StartTrainingSession(Deck, UserId, SessionId, Mode);
	if (!RunState.Session.IsStructurallyValid())
	{
		return RunState;
	}
	if (Deck.ImportedRuntimeSurface.IsStructurallyValid())
	{
		RunState.ImportedRuntimeSelectionState.SurfaceId = Deck.ImportedRuntimeSurface.SurfaceId;
		RunState.ImportedRuntimeSelectionState.SurfaceKind = Deck.ImportedRuntimeSurface.SurfaceKind;
	}

	for (const FHyperTwistTrainingCase& TrainingCase : Deck.Cases)
	{
		if (!TrainingCase.IsStructurallyValid())
		{
			continue;
		}

		RunState.RemainingCaseIds.Add(TrainingCase.CaseId);
		RunState.Memories.Add(MakeInitialProgressionMemory(RunState.Session, TrainingCase));
	}

	const FHyperTwistTrainingCase* InitialCase = HyperTwistTrainingLibraryInternal::FindFirstCompatibleCase(Deck, Mode);
	if (InitialCase != nullptr)
	{
		FHyperTwistTrainingDeck InitialDeck = Deck;
		InitialDeck.Cases.Reset();
		InitialDeck.Cases.Add(*InitialCase);
		RunState.CurrentSelection = SelectNextTrainingCase(InitialDeck, RunState.Memories, Mode, FString());
	}

	if (RunState.CurrentSelection.IsStructurallyValid())
	{
		RunState.Session = AdvanceTrainingSession(
			RunState.Session,
			EHyperTwistTrainingSessionState::Active,
			RunState.CurrentSelection.TrainingCase.CaseId
		);
		RunState.ReplayPacket = HyperTwistTrainingLibraryInternal::MakeReplayPacketForRun(
			RunState.Session,
			RunState.CurrentSelection.TrainingCase,
			Deck.TimingPolicy
		);
		if (RunState.ReplayPacket.Events.Num() > 0)
		{
			RunState.LastReplayEvent = RunState.ReplayPacket.Events.Last();
		}
	}

	return RunState;
}

bool UHyperTwistTrainingLibrary::CanTransitionTrainingSession(
	EHyperTwistTrainingSessionState FromState,
	EHyperTwistTrainingSessionState ToState
)
{
	switch (FromState)
	{
	case EHyperTwistTrainingSessionState::Created:
		return ToState == EHyperTwistTrainingSessionState::Ready;
	case EHyperTwistTrainingSessionState::Ready:
		return ToState == EHyperTwistTrainingSessionState::Active;
	case EHyperTwistTrainingSessionState::Active:
		return ToState == EHyperTwistTrainingSessionState::Paused
			|| ToState == EHyperTwistTrainingSessionState::Reviewing
			|| ToState == EHyperTwistTrainingSessionState::Completed
			|| ToState == EHyperTwistTrainingSessionState::Aborted;
	case EHyperTwistTrainingSessionState::Paused:
		return ToState == EHyperTwistTrainingSessionState::Active
			|| ToState == EHyperTwistTrainingSessionState::Aborted;
	case EHyperTwistTrainingSessionState::Reviewing:
		return ToState == EHyperTwistTrainingSessionState::Active
			|| ToState == EHyperTwistTrainingSessionState::Completed;
	default:
		return false;
	}
}

FHyperTwistTrainingSession UHyperTwistTrainingLibrary::AdvanceTrainingSession(
	const FHyperTwistTrainingSession& Session,
	EHyperTwistTrainingSessionState TargetState,
	const FString& NextCaseId
)
{
	FHyperTwistTrainingSession UpdatedSession = Session;
	if (!CanTransitionTrainingSession(Session.SessionState, TargetState))
	{
		return UpdatedSession;
	}

	UpdatedSession.SessionState = TargetState;

	if (TargetState == EHyperTwistTrainingSessionState::Active || TargetState == EHyperTwistTrainingSessionState::Reviewing)
	{
		if (!NextCaseId.IsEmpty())
		{
			UpdatedSession.CurrentCaseId = NextCaseId;
		}
	}
	else if (TargetState == EHyperTwistTrainingSessionState::Completed || TargetState == EHyperTwistTrainingSessionState::Aborted)
	{
		UpdatedSession.EndedAtUtc = TEXT("2026-04-25T12:05:00Z");
		UpdatedSession.CurrentCaseId.Reset();
	}

	return UpdatedSession;
}

FHyperTwistTrainingAdvanceResult UHyperTwistTrainingLibrary::AdvanceTrainingSessionWithSelection(
	const FHyperTwistTrainingSession& Session,
	const FHyperTwistTrainingDeck& Deck,
	const TArray<FHyperTwistProgressionMemory>& Memories,
	EHyperTwistTrainingSessionState TargetState
)
{
	FHyperTwistTrainingAdvanceResult AdvanceResult;
	AdvanceResult.UpdatedSession = Session;

	FString NextCaseId;
	if ((TargetState == EHyperTwistTrainingSessionState::Active || TargetState == EHyperTwistTrainingSessionState::Reviewing)
		&& Deck.IsStructurallyValid())
	{
		AdvanceResult.NextCaseSelection = SelectNextTrainingCase(Deck, Memories, Session.Mode, Session.CurrentCaseId);
		if (AdvanceResult.NextCaseSelection.IsStructurallyValid())
		{
			NextCaseId = AdvanceResult.NextCaseSelection.TrainingCase.CaseId;
		}
	}

	AdvanceResult.UpdatedSession = AdvanceTrainingSession(Session, TargetState, NextCaseId);
	return AdvanceResult;
}

FHyperTwistTrainingSession UHyperTwistTrainingLibrary::RecordTrainingAttempt(
	const FHyperTwistTrainingSession& Session,
	const FHyperTwistTrainingAttempt& Attempt
)
{
	FHyperTwistTrainingSession UpdatedSession = Session;
	if (!Attempt.IsStructurallyValid())
	{
		return UpdatedSession;
	}

	if (Session.SessionState != EHyperTwistTrainingSessionState::Active
		&& Session.SessionState != EHyperTwistTrainingSessionState::Reviewing)
	{
		return UpdatedSession;
	}

	UpdatedSession.AttemptCount = Session.AttemptCount + 1;
	UpdatedSession.CurrentCaseId = Attempt.CaseId;
	UpdatedSession.SessionState = EHyperTwistTrainingSessionState::Reviewing;
	return UpdatedSession;
}

FHyperTwistTrainingTimingPolicy UHyperTwistTrainingLibrary::NormalizeTimingPolicy(
	const FHyperTwistTrainingTimingPolicy& TimingPolicy
)
{
	return HyperTwistTrainingLibraryInternal::NormalizeTimingPolicyInternal(TimingPolicy);
}

FHyperTwistTrainingAttempt UHyperTwistTrainingLibrary::NormalizeTrainingAttemptTiming(
	const FHyperTwistTrainingAttempt& Attempt,
	const FHyperTwistTrainingTimingPolicy& TimingPolicy
)
{
	FHyperTwistTrainingAttempt NormalizedAttempt = Attempt;
	FHyperTwistTrainingTimingBreakdown TimingBreakdown = Attempt.TimingBreakdown;
	const FHyperTwistTrainingTimingPolicy NormalizedTimingPolicy = NormalizeTimingPolicy(TimingPolicy);

	TimingBreakdown.InspectionElapsedMs = NormalizedTimingPolicy.bInspectionEnabled
		? FMath::Max(0, TimingBreakdown.InspectionElapsedMs)
		: 0;
	TimingBreakdown.ManualPenalty = NormalizedTimingPolicy.bAllowManualPenalty
		? TimingBreakdown.ManualPenalty
		: EHyperTwistTrainingPenalty::None;
	TimingBreakdown.InspectionPenalty = HyperTwistTrainingLibraryInternal::ComputeInspectionPenalty(
		TimingBreakdown.InspectionElapsedMs,
		NormalizedTimingPolicy
	);
	TimingBreakdown.AppliedPenalty = HyperTwistTrainingLibraryInternal::CombinePenalties(
		TimingBreakdown.InspectionPenalty,
		TimingBreakdown.ManualPenalty
	);

	const int32 RecognitionTimeMs = FMath::Max(0, Attempt.RecognitionTimeMs);
	int32 RawSolveTimeMs = FMath::Max(0, TimingBreakdown.RawSolveTimeMs);
	if (RawSolveTimeMs == 0 && Attempt.ExecutionTimeMs > 0)
	{
		RawSolveTimeMs = Attempt.ExecutionTimeMs;
	}
	if (RawSolveTimeMs == 0 && Attempt.TotalTimeMs > 0)
	{
		RawSolveTimeMs = FMath::Max(
			0,
			Attempt.TotalTimeMs
				- RecognitionTimeMs
				- HyperTwistTrainingLibraryInternal::GetPenaltyOffsetMs(TimingBreakdown.AppliedPenalty)
		);
	}

	TimingBreakdown.RawSolveTimeMs = RawSolveTimeMs;
	TimingBreakdown.SplitCaptures = HyperTwistTrainingLibraryInternal::NormalizeSplitCaptures(
		NormalizedTimingPolicy,
		TimingBreakdown.SplitCaptures
	);
	TimingBreakdown.DerivedPhaseTimings = HyperTwistTrainingLibraryInternal::BuildDerivedPhaseTimings(
		NormalizedTimingPolicy,
		TimingBreakdown
	);
	TimingBreakdown.PenaltyOffsetMs = HyperTwistTrainingLibraryInternal::GetPenaltyOffsetMs(
		TimingBreakdown.AppliedPenalty
	);

	if (NormalizedAttempt.Result == EHyperTwistTrainingAttemptResult::Aborted)
	{
		TimingBreakdown.bDidNotFinish = true;
	}

	if (TimingBreakdown.AppliedPenalty == EHyperTwistTrainingPenalty::DNF)
	{
		TimingBreakdown.bDidNotFinish = true;
		if (NormalizedAttempt.Result != EHyperTwistTrainingAttemptResult::Aborted)
		{
			NormalizedAttempt.Result = EHyperTwistTrainingAttemptResult::DNF;
		}
	}

	NormalizedAttempt.ExecutionTimeMs = RawSolveTimeMs;
	NormalizedAttempt.TimingBreakdown = TimingBreakdown;

	if (TimingBreakdown.bDidNotFinish || NormalizedAttempt.Result == EHyperTwistTrainingAttemptResult::DNF)
	{
		NormalizedAttempt.TotalTimeMs = 0;
		NormalizedAttempt.TimingBreakdown.FinalTimeMs = 0;
	}
	else
	{
		const int32 FinalTimeMs = FMath::Max(
			0,
			RawSolveTimeMs
				+ RecognitionTimeMs
				+ TimingBreakdown.PenaltyOffsetMs
		);
		NormalizedAttempt.TotalTimeMs = FinalTimeMs;
		NormalizedAttempt.TimingBreakdown.FinalTimeMs = FinalTimeMs;
	}

	return NormalizedAttempt;
}

FHyperTwistTrainingSession UHyperTwistTrainingLibrary::CompleteTrainingSession(
	const FHyperTwistTrainingSession& Session,
	bool bAbortSession
)
{
	FHyperTwistTrainingSession UpdatedSession = Session;
	const EHyperTwistTrainingSessionState TargetState = bAbortSession
		? EHyperTwistTrainingSessionState::Aborted
		: EHyperTwistTrainingSessionState::Completed;

	if (!CanTransitionTrainingSession(Session.SessionState, TargetState)
		&& Session.SessionState != EHyperTwistTrainingSessionState::Reviewing)
	{
		return UpdatedSession;
	}

	UpdatedSession.SessionState = TargetState;
	UpdatedSession.EndedAtUtc = TEXT("2026-04-25T12:05:00Z");
	UpdatedSession.CurrentCaseId.Reset();
	return UpdatedSession;
}

FHyperTwistProgressionMemory UHyperTwistTrainingLibrary::MakeInitialProgressionMemory(
	const FHyperTwistTrainingSession& Session,
	const FHyperTwistTrainingCase& TrainingCase
)
{
	FHyperTwistProgressionMemory Memory;
	Memory.UserId = Session.UserId;
	Memory.DeckId = Session.DeckId;
	Memory.CaseId = TrainingCase.CaseId;
	Memory.StrengthScore = 0.0f;
	Memory.LastSeenAtUtc = Session.StartedAtUtc;
	Memory.LastOutcome = EHyperTwistTrainingAttemptResult::Failure;
	Memory.AttemptCount = 0;
	Memory.SuccessCount = 0;
	Memory.DifficultyWeight = HyperTwistTrainingLibraryInternal::GetBaseDifficultyWeight(TrainingCase);
	Memory.ResurfacePriority = HyperTwistTrainingLibraryInternal::GetBaseResurfacePriority(TrainingCase);
	return Memory;
}

FHyperTwistProgressionMemory UHyperTwistTrainingLibrary::UpdateProgressionMemory(
	const FHyperTwistTrainingSession& Session,
	const FHyperTwistTrainingCase& TrainingCase,
	const FHyperTwistProgressionMemory& CurrentMemory,
	const FHyperTwistTrainingAttempt& Attempt
)
{
	FHyperTwistProgressionMemory UpdatedMemory = CurrentMemory;
	if (!TrainingCase.IsStructurallyValid() || !Attempt.IsStructurallyValid())
	{
		return UpdatedMemory;
	}

	if (!UpdatedMemory.UserId.Len())
	{
		UpdatedMemory.UserId = Session.UserId;
	}

	if (!UpdatedMemory.DeckId.Len())
	{
		UpdatedMemory.DeckId = Session.DeckId;
	}

	UpdatedMemory.CaseId = TrainingCase.CaseId;
	UpdatedMemory.DifficultyWeight = UpdatedMemory.DifficultyWeight > 0.0f
		? UpdatedMemory.DifficultyWeight
		: HyperTwistTrainingLibraryInternal::GetBaseDifficultyWeight(TrainingCase);

	if (UpdatedMemory.ResurfacePriority <= 0.0f)
	{
		UpdatedMemory.ResurfacePriority = HyperTwistTrainingLibraryInternal::GetBaseResurfacePriority(TrainingCase);
	}

	++UpdatedMemory.AttemptCount;
	UpdatedMemory.LastSeenAtUtc = !Attempt.CompletedAtUtc.IsEmpty() ? Attempt.CompletedAtUtc : Session.StartedAtUtc;
	UpdatedMemory.LastOutcome = Attempt.Result;

	switch (Attempt.Result)
	{
	case EHyperTwistTrainingAttemptResult::Success:
		++UpdatedMemory.SuccessCount;
		UpdatedMemory.StrengthScore += 1.0f;
		UpdatedMemory.ResurfacePriority -= 0.75f;
		break;
	case EHyperTwistTrainingAttemptResult::Failure:
		UpdatedMemory.StrengthScore -= 0.25f;
		UpdatedMemory.ResurfacePriority += 1.25f;
		break;
	case EHyperTwistTrainingAttemptResult::Timeout:
	case EHyperTwistTrainingAttemptResult::DNF:
		UpdatedMemory.StrengthScore -= 0.50f;
		UpdatedMemory.ResurfacePriority += 1.50f;
		break;
	case EHyperTwistTrainingAttemptResult::Aborted:
		UpdatedMemory.StrengthScore -= 0.10f;
		UpdatedMemory.ResurfacePriority += 0.50f;
		break;
	default:
		break;
	}

	if (Attempt.Mistakes > 0)
	{
		UpdatedMemory.StrengthScore -= static_cast<float>(Attempt.Mistakes) * 0.05f;
		UpdatedMemory.ResurfacePriority += static_cast<float>(Attempt.Mistakes) * 0.10f;
	}

	if (TrainingCase.TimeTargetMs > 0 && Attempt.TotalTimeMs > 0)
	{
		if (Attempt.TotalTimeMs <= TrainingCase.TimeTargetMs)
		{
			UpdatedMemory.StrengthScore += 0.25f;
		}
		else
		{
			const float OvertimeRatio = static_cast<float>(Attempt.TotalTimeMs - TrainingCase.TimeTargetMs) / static_cast<float>(TrainingCase.TimeTargetMs);
			UpdatedMemory.ResurfacePriority += FMath::Clamp(OvertimeRatio, 0.0f, 1.5f);
		}
	}

	UpdatedMemory.StrengthScore = FMath::Clamp(UpdatedMemory.StrengthScore, 0.0f, 10.0f);
	UpdatedMemory.ResurfacePriority = FMath::Max(0.25f, UpdatedMemory.ResurfacePriority);
	return UpdatedMemory;
}

FHyperTwistTrainingCaseSelection UHyperTwistTrainingLibrary::SelectNextTrainingCase(
	const FHyperTwistTrainingDeck& Deck,
	const TArray<FHyperTwistProgressionMemory>& Memories,
	EHyperTwistTrainingDeliveryMode Mode,
	const FString& CurrentCaseId
)
{
	FHyperTwistTrainingCaseSelection BestSelection;
	BestSelection.SelectionScore = -1000.0f;

	if (!Deck.IsStructurallyValid())
	{
		return BestSelection;
	}

	for (const FHyperTwistTrainingCase& TrainingCase : Deck.Cases)
	{
		if (!TrainingCase.IsStructurallyValid())
		{
			continue;
		}

		const bool bModeCompatible = HyperTwistTrainingLibraryInternal::IsModeAllowed(TrainingCase, Mode);
		if (!bModeCompatible)
		{
			continue;
		}

		const FHyperTwistProgressionMemory* Memory = HyperTwistTrainingLibraryInternal::FindMemoryForCase(Memories, TrainingCase.CaseId);
		const float Score = HyperTwistTrainingLibraryInternal::ComputeSelectionScore(TrainingCase, Memory, CurrentCaseId);

		if (Score > BestSelection.SelectionScore)
		{
			BestSelection.TrainingCase = TrainingCase;
			BestSelection.SelectionScore = Score;
			BestSelection.DifficultyWeight = Memory ? Memory->DifficultyWeight : HyperTwistTrainingLibraryInternal::GetBaseDifficultyWeight(TrainingCase);
			BestSelection.ResurfacePriority = Memory ? Memory->ResurfacePriority : HyperTwistTrainingLibraryInternal::GetBaseResurfacePriority(TrainingCase);
			BestSelection.AttemptCount = Memory ? Memory->AttemptCount : 0;
			BestSelection.SuccessCount = Memory ? Memory->SuccessCount : 0;
			BestSelection.bModeCompatible = true;
			BestSelection.bUsedStoredMemory = Memory != nullptr;
		}
	}

	return BestSelection;
}

FHyperTwistTrainingSessionSummary UHyperTwistTrainingLibrary::DeriveTrainingSessionSummary(
	const FHyperTwistTrainingSession& Session,
  const TArray<FHyperTwistTrainingAttempt>& Attempts,
	const FHyperTwistTrainingImportedRuntimeSelectionState& ImportedRuntimeSelectionState
)
{
	FHyperTwistTrainingSessionSummary Summary;
	Summary.TrainingSessionId = Session.TrainingSessionId;
	Summary.DeckId = Session.DeckId;
	Summary.UserId = Session.UserId;
	Summary.SessionState = Session.SessionState;

	for (const FHyperTwistTrainingAttempt& Attempt : Attempts)
	{
		if (!Attempt.IsStructurallyValid() || Attempt.TrainingSessionId != Session.TrainingSessionId)
		{
			continue;
		}

		++Summary.CompletedAttemptCount;
		Summary.TotalTimeMs += FMath::Max(0, Attempt.TotalTimeMs);
		Summary.TotalExecutionTimeMs += FMath::Max(0, Attempt.ExecutionTimeMs);
		Summary.TotalRecognitionTimeMs += FMath::Max(0, Attempt.RecognitionTimeMs);
		Summary.TotalInspectionTimeMs += FMath::Max(0, Attempt.TimingBreakdown.InspectionElapsedMs);
		Summary.TotalMistakes += FMath::Max(0, Attempt.Mistakes);

		if (Attempt.TotalTimeMs > 0)
		{
			Summary.BestTotalTimeMs = Summary.BestTotalTimeMs == 0
				? Attempt.TotalTimeMs
				: FMath::Min(Summary.BestTotalTimeMs, Attempt.TotalTimeMs);
		}

		if (Attempt.TimingBreakdown.RawSolveTimeMs > 0)
		{
			Summary.BestRawSolveTimeMs = Summary.BestRawSolveTimeMs == 0
				? Attempt.TimingBreakdown.RawSolveTimeMs
				: FMath::Min(Summary.BestRawSolveTimeMs, Attempt.TimingBreakdown.RawSolveTimeMs);
		}

		if (Attempt.TimingBreakdown.AppliedPenalty == EHyperTwistTrainingPenalty::Plus2)
		{
			++Summary.Plus2PenaltyCount;
		}

		switch (Attempt.Result)
		{
		case EHyperTwistTrainingAttemptResult::Success:
			++Summary.SuccessCount;
			break;
		case EHyperTwistTrainingAttemptResult::Failure:
			++Summary.FailureCount;
			break;
		case EHyperTwistTrainingAttemptResult::Timeout:
			++Summary.TimeoutCount;
			break;
		case EHyperTwistTrainingAttemptResult::DNF:
			++Summary.DNFCount;
			break;
		case EHyperTwistTrainingAttemptResult::Aborted:
			++Summary.AbortedCount;
			break;
		default:
			break;
		}

		if (Summary.LastCompletedAtUtc.IsEmpty() || Attempt.CompletedAtUtc >= Summary.LastCompletedAtUtc)
		{
			Summary.LastCompletedAtUtc = Attempt.CompletedAtUtc;
			Summary.LastCaseId = Attempt.CaseId;
			Summary.LastReplayId = Attempt.ReplayId;
		}
	}

	Summary.AttemptCount = FMath::Max(Session.AttemptCount, Summary.CompletedAttemptCount);
	if (Summary.CompletedAttemptCount > 0)
	{
		Summary.AverageTotalTimeMs = Summary.TotalTimeMs / Summary.CompletedAttemptCount;
		Summary.AverageRawSolveTimeMs = Summary.TotalExecutionTimeMs / Summary.CompletedAttemptCount;
		Summary.AverageInspectionTimeMs = Summary.TotalInspectionTimeMs / Summary.CompletedAttemptCount;
		Summary.SuccessRate = static_cast<float>(Summary.SuccessCount) / static_cast<float>(Summary.CompletedAttemptCount);
	}

	if (ImportedRuntimeSelectionState.IsStructurallyValid())
	{
		Summary.ImportedRuntimeSurfaceId = ImportedRuntimeSelectionState.SurfaceId;
		Summary.ImportedRuntimeSurfaceKind = ImportedRuntimeSelectionState.SurfaceKind;
		Summary.ImportedRuntimeSelectorChoices = ImportedRuntimeSelectionState.SelectorChoices;
	}

	return Summary;
}

FHyperTwistReplayEvent UHyperTwistTrainingLibrary::MakeTrainingAttemptReplayEvent(
	const FHyperTwistTrainingSession& Session,
	const FHyperTwistTrainingAttempt& Attempt,
    const FHyperTwistTrainingImportedRuntimeSelectionState& ImportedRuntimeSelectionState,
	int32 TimeMs
)
{
	FHyperTwistReplayEvent ReplayEvent;
	if (!Session.IsStructurallyValid() || !Attempt.IsStructurallyValid())
	{
		return ReplayEvent;
	}

	FHyperTwistTrainingAttemptReplayPayload Payload;
	Payload.TrainingSessionId = Session.TrainingSessionId;
	Payload.AttemptId = Attempt.AttemptId;
	Payload.DeckId = Session.DeckId;
	Payload.UserId = Session.UserId;
	Payload.CaseId = Attempt.CaseId;
	Payload.DeliveryMode = Session.Mode;
	Payload.Result = Attempt.Result;
	Payload.TotalTimeMs = Attempt.TotalTimeMs;
	Payload.ExecutionTimeMs = Attempt.ExecutionTimeMs;
	Payload.RecognitionTimeMs = Attempt.RecognitionTimeMs;
	Payload.TimingBreakdown = Attempt.TimingBreakdown;
	Payload.Mistakes = Attempt.Mistakes;
	Payload.ReplayId = Attempt.ReplayId;
	Payload.CompletedAtUtc = Attempt.CompletedAtUtc;
	if (ImportedRuntimeSelectionState.IsStructurallyValid())
	{
		Payload.ImportedRuntimeSurfaceId = ImportedRuntimeSelectionState.SurfaceId;
		Payload.ImportedRuntimeSurfaceKind = ImportedRuntimeSelectionState.SurfaceKind;
		Payload.ImportedRuntimeSelectorChoices = ImportedRuntimeSelectionState.SelectorChoices;
	}

	ReplayEvent.EventId = Attempt.AttemptId.IsEmpty()
		? TEXT("training_attempt")
		: FString::Printf(TEXT("training_attempt_%s"), *Attempt.AttemptId);
	ReplayEvent.Sequence = 0;
	ReplayEvent.TimeMs = FMath::Max(
		0,
		TimeMs > 0 ? TimeMs : HyperTwistTrainingLibraryInternal::GetObservedAttemptTimeMs(Attempt)
	);
	ReplayEvent.EventType = EHyperTwistReplayEventType::Annotation;
	ReplayEvent.PayloadJson = HyperTwistTrainingLibraryInternal::SerializePayload(Payload);
	return ReplayEvent;
}

FHyperTwistReplayPacket UHyperTwistTrainingLibrary::AppendTrainingAttemptToReplay(
	const FHyperTwistReplayPacket& ReplayPacket,
	const FHyperTwistTrainingSession& Session,
	const FHyperTwistTrainingAttempt& Attempt,
    const FHyperTwistTrainingImportedRuntimeSelectionState& ImportedRuntimeSelectionState,
	int32 TimeMs
)
{
	FHyperTwistReplayPacket UpdatedReplayPacket = ReplayPacket;
	const int32 InspectionOffsetMs = FMath::Max(0, Attempt.TimingBreakdown.InspectionElapsedMs);

	if (InspectionOffsetMs > 0)
	{
		FHyperTwistReplayEvent InspectionEndEvent;
		InspectionEndEvent.EventType = EHyperTwistReplayEventType::InspectionEnd;
		InspectionEndEvent.TimeMs = InspectionOffsetMs;
		UpdatedReplayPacket = UHyperTwistReplayLibrary::AppendReplayEvent(UpdatedReplayPacket, InspectionEndEvent);

		FHyperTwistReplayEvent TimerStartEvent;
		TimerStartEvent.EventType = EHyperTwistReplayEventType::TimerStart;
		TimerStartEvent.TimeMs = InspectionOffsetMs;
		UpdatedReplayPacket = UHyperTwistReplayLibrary::AppendReplayEvent(UpdatedReplayPacket, TimerStartEvent);
	}

	for (int32 PhaseIndex = 0; PhaseIndex < Attempt.TimingBreakdown.DerivedPhaseTimings.Num(); ++PhaseIndex)
	{
		const FHyperTwistPhaseTiming& PhaseTiming = Attempt.TimingBreakdown.DerivedPhaseTimings[PhaseIndex];
		const FString PhaseId = FString::Printf(TEXT("phase_%02d"), PhaseIndex + 1);

		FHyperTwistReplayPhaseMarkPayload PhaseStartPayload;
		PhaseStartPayload.PhaseId = PhaseId;
		PhaseStartPayload.PhaseKind = PhaseTiming.PhaseKind;
		PhaseStartPayload.PhaseSource = TEXT("training-timing");
		PhaseStartPayload.PhaseAction = TEXT("start");

		FHyperTwistReplayEvent PhaseStartEvent;
		PhaseStartEvent.EventType = EHyperTwistReplayEventType::PhaseMark;
		PhaseStartEvent.TimeMs = InspectionOffsetMs + FMath::Max(0, PhaseTiming.StartedAtMs);
		PhaseStartEvent.PayloadJson = HyperTwistTrainingLibraryInternal::SerializePayload(PhaseStartPayload);
		UpdatedReplayPacket = UHyperTwistReplayLibrary::AppendReplayEvent(UpdatedReplayPacket, PhaseStartEvent);

		FHyperTwistReplayPhaseMarkPayload PhaseCompletePayload = PhaseStartPayload;
		PhaseCompletePayload.PhaseAction = TEXT("complete");

		FHyperTwistReplayEvent PhaseCompleteEvent;
		PhaseCompleteEvent.EventType = EHyperTwistReplayEventType::PhaseMark;
		PhaseCompleteEvent.TimeMs = InspectionOffsetMs + FMath::Max(0, PhaseTiming.EndedAtMs);
		PhaseCompleteEvent.PayloadJson = HyperTwistTrainingLibraryInternal::SerializePayload(PhaseCompletePayload);
		UpdatedReplayPacket = UHyperTwistReplayLibrary::AppendReplayEvent(UpdatedReplayPacket, PhaseCompleteEvent);
	}

    const FHyperTwistReplayEvent ReplayEvent = MakeTrainingAttemptReplayEvent(
		Session,
		Attempt,
		ImportedRuntimeSelectionState,
		TimeMs
	);
	if (!ReplayEvent.IsStructurallyValid())
	{
		return UpdatedReplayPacket;
	}

	return UHyperTwistReplayLibrary::AppendReplayEvent(UpdatedReplayPacket, ReplayEvent);
}

FHyperTwistTrainingAttemptResolution UHyperTwistTrainingLibrary::ResolveTrainingAttempt(
	const FHyperTwistTrainingSession& Session,
	const FHyperTwistTrainingDeck& Deck,
	const FHyperTwistTrainingCase& TrainingCase,
	const TArray<FHyperTwistProgressionMemory>& Memories,
	const FHyperTwistTrainingAttempt& Attempt,
    const FHyperTwistTrainingImportedRuntimeSelectionState& ImportedRuntimeSelectionState,
	int32 TimeMs
)
{
	FHyperTwistTrainingAttemptResolution Resolution;
	if (!Session.IsStructurallyValid() || !Deck.IsStructurallyValid() || !TrainingCase.IsStructurallyValid() || !Attempt.IsStructurallyValid())
	{
		return Resolution;
	}

	const FHyperTwistTrainingAttempt NormalizedAttempt = NormalizeTrainingAttemptTiming(
		Attempt,
		Deck.TimingPolicy
	);

	FHyperTwistProgressionMemory SeedMemory;
	bool bFoundExistingMemory = false;
	for (const FHyperTwistProgressionMemory& Memory : Memories)
	{
		if (Memory.CaseId == TrainingCase.CaseId)
		{
			SeedMemory = Memory;
			bFoundExistingMemory = true;
			break;
		}
	}

	if (!bFoundExistingMemory)
	{
		SeedMemory = MakeInitialProgressionMemory(Session, TrainingCase);
	}

	Resolution.UpdatedProgressionMemory = UpdateProgressionMemory(Session, TrainingCase, SeedMemory, NormalizedAttempt);
	Resolution.ReplayEvent = MakeTrainingAttemptReplayEvent(
		Session,
		NormalizedAttempt,
        ImportedRuntimeSelectionState,
		TimeMs > 0 ? TimeMs : HyperTwistTrainingLibraryInternal::GetObservedAttemptTimeMs(NormalizedAttempt)
	);

	TArray<FHyperTwistProgressionMemory> UpdatedMemories = Memories;
	bool bReplacedExistingMemory = false;
	for (FHyperTwistProgressionMemory& Memory : UpdatedMemories)
	{
		if (Memory.CaseId == Resolution.UpdatedProgressionMemory.CaseId)
		{
			Memory = Resolution.UpdatedProgressionMemory;
			bReplacedExistingMemory = true;
			break;
		}
	}

	if (!bReplacedExistingMemory)
	{
		UpdatedMemories.Add(Resolution.UpdatedProgressionMemory);
	}

	const FHyperTwistTrainingSession ReviewSession = RecordTrainingAttempt(Session, NormalizedAttempt);
	const FHyperTwistTrainingAdvanceResult AdvanceResult = AdvanceTrainingSessionWithSelection(
		ReviewSession,
		Deck,
		UpdatedMemories,
		EHyperTwistTrainingSessionState::Active
	);

	Resolution.UpdatedSession = AdvanceResult.UpdatedSession;
	Resolution.NextCaseSelection = AdvanceResult.NextCaseSelection;
	return Resolution;
}

FHyperTwistTrainingRunStepResult UHyperTwistTrainingLibrary::ResolveTrainingRunAttempt(
	const FHyperTwistTrainingRunState& RunState,
	const FHyperTwistTrainingAttempt& Attempt,
	int32 TimeMs
)
{
	FHyperTwistTrainingRunStepResult StepResult;
	StepResult.UpdatedRunState = RunState;

	if (!RunState.IsStructurallyValid() || !Attempt.IsStructurallyValid())
	{
		return StepResult;
	}

	const FHyperTwistTrainingCase* TrainingCase = HyperTwistTrainingLibraryInternal::FindCaseById(
		RunState.ActiveDeck,
		Attempt.CaseId
	);
	if (TrainingCase == nullptr || !TrainingCase->IsStructurallyValid())
	{
		return StepResult;
	}

	FHyperTwistTrainingAttempt NormalizedAttempt = Attempt;
	if (NormalizedAttempt.ReplayId.IsEmpty() && !RunState.ReplayPacket.ReplayId.IsEmpty())
	{
		NormalizedAttempt.ReplayId = RunState.ReplayPacket.ReplayId;
	}
	NormalizedAttempt = NormalizeTrainingAttemptTiming(NormalizedAttempt, RunState.ActiveDeck.TimingPolicy);
	const int32 ObservedAttemptTimeMs = TimeMs > 0
		? TimeMs
		: HyperTwistTrainingLibraryInternal::GetObservedAttemptTimeMs(NormalizedAttempt);

	StepResult.AttemptResolution = ResolveTrainingAttempt(
		RunState.Session,
		RunState.ActiveDeck,
		*TrainingCase,
		RunState.Memories,
		NormalizedAttempt,
       RunState.ImportedRuntimeSelectionState,
		ObservedAttemptTimeMs
	);
	if (!StepResult.AttemptResolution.IsStructurallyValid())
	{
		return StepResult;
	}

	StepResult.UpdatedRunState.Session = StepResult.AttemptResolution.UpdatedSession;
	StepResult.UpdatedRunState.CurrentSelection = StepResult.AttemptResolution.NextCaseSelection;
	StepResult.UpdatedRunState.Attempts.Add(NormalizedAttempt);
	StepResult.UpdatedRunState.ReplayPacket = AppendTrainingAttemptToReplay(
		RunState.ReplayPacket,
		RunState.Session,
		NormalizedAttempt,
       RunState.ImportedRuntimeSelectionState,
		ObservedAttemptTimeMs
	);
	if (StepResult.UpdatedRunState.ReplayPacket.Events.Num() > 0)
	{
		StepResult.UpdatedRunState.LastReplayEvent = StepResult.UpdatedRunState.ReplayPacket.Events.Last();
	}
	else
	{
		StepResult.UpdatedRunState.LastReplayEvent = StepResult.AttemptResolution.ReplayEvent;
	}

	bool bUpdatedExistingMemory = false;
	for (FHyperTwistProgressionMemory& Memory : StepResult.UpdatedRunState.Memories)
	{
		if (Memory.CaseId == StepResult.AttemptResolution.UpdatedProgressionMemory.CaseId)
		{
			Memory = StepResult.AttemptResolution.UpdatedProgressionMemory;
			bUpdatedExistingMemory = true;
			break;
		}
	}

	if (!bUpdatedExistingMemory)
	{
		StepResult.UpdatedRunState.Memories.Add(StepResult.AttemptResolution.UpdatedProgressionMemory);
	}

	StepResult.UpdatedRunState.RemainingCaseIds = RunState.RemainingCaseIds;
	if (NormalizedAttempt.Result == EHyperTwistTrainingAttemptResult::Success)
	{
		StepResult.UpdatedRunState.RemainingCaseIds.Remove(NormalizedAttempt.CaseId);
	}

	if (StepResult.UpdatedRunState.RemainingCaseIds.Num() == 0)
	{
		StepResult.UpdatedRunState.Session = CompleteTrainingSession(
			StepResult.UpdatedRunState.Session,
			false
		);
		StepResult.UpdatedRunState.ReplayPacket.EndedAtUtc = !NormalizedAttempt.CompletedAtUtc.IsEmpty()
			? NormalizedAttempt.CompletedAtUtc
			: StepResult.UpdatedRunState.Session.EndedAtUtc;

		FHyperTwistReplayEvent SolveEndEvent;
		SolveEndEvent.EventType = EHyperTwistReplayEventType::SolveEnd;
		SolveEndEvent.TimeMs = ObservedAttemptTimeMs;
		StepResult.UpdatedRunState.ReplayPacket = UHyperTwistReplayLibrary::AppendReplayEvent(
			StepResult.UpdatedRunState.ReplayPacket,
			SolveEndEvent
		);
		if (StepResult.UpdatedRunState.ReplayPacket.Events.Num() > 0)
		{
			StepResult.UpdatedRunState.LastReplayEvent = StepResult.UpdatedRunState.ReplayPacket.Events.Last();
		}
		StepResult.UpdatedRunState.CurrentSelection = FHyperTwistTrainingCaseSelection();
	}
	else
	{
		const FHyperTwistTrainingDeck RemainingDeck = HyperTwistTrainingLibraryInternal::FilterDeckToCaseIds(
			RunState.ActiveDeck,
			StepResult.UpdatedRunState.RemainingCaseIds
		);
		const FHyperTwistTrainingCaseSelection RemainingSelection = SelectNextTrainingCase(
			RemainingDeck,
			StepResult.UpdatedRunState.Memories,
			RunState.Session.Mode,
			NormalizedAttempt.CaseId
		);

		if (RemainingSelection.IsStructurallyValid())
		{
			StepResult.UpdatedRunState.CurrentSelection = RemainingSelection;
			StepResult.UpdatedRunState.Session.CurrentCaseId = RemainingSelection.TrainingCase.CaseId;
			StepResult.UpdatedRunState.Session.SessionState = EHyperTwistTrainingSessionState::Active;
		}
		else
		{
			StepResult.UpdatedRunState.Session = CompleteTrainingSession(
				StepResult.UpdatedRunState.Session,
				false
			);
			StepResult.UpdatedRunState.CurrentSelection = FHyperTwistTrainingCaseSelection();
			StepResult.UpdatedRunState.ReplayPacket.EndedAtUtc = !NormalizedAttempt.CompletedAtUtc.IsEmpty()
				? NormalizedAttempt.CompletedAtUtc
				: StepResult.UpdatedRunState.Session.EndedAtUtc;
		}
	}

	StepResult.SessionSummary = DeriveTrainingSessionSummary(
		StepResult.UpdatedRunState.Session,
     StepResult.UpdatedRunState.Attempts,
		StepResult.UpdatedRunState.ImportedRuntimeSelectionState
	);
	return StepResult;
}

FHyperTwistTrainingMethodDrillDefinition UHyperTwistTrainingLibrary::BuildMethodDrillDefinitionFromDeck(
	const FHyperTwistTrainingDeck& Deck,
	EHyperTwistTrainingDrillModeKind DrillMode,
	EHyperTwistTrainingDrillVisibilityPolicy VisibilityPolicy,
	bool bAnswerHiddenUntilReveal,
	EHyperTwistTrainingDrillEntrySource EntrySource
)
{
	FHyperTwistTrainingMethodDrillDefinition DrillDefinition;
	if (!Deck.IsStructurallyValid())
	{
		return DrillDefinition;
	}

	DrillDefinition.MethodDescriptor = UHyperTwistTrainingCatalogLibrary::DescribeTrainingDeckMethod(Deck);
	if (!DrillDefinition.MethodDescriptor.IsStructurallyValid())
	{
		return DrillDefinition;
	}

	DrillDefinition.SourceDeckId = Deck.DeckId;
	DrillDefinition.SourceDeck = Deck;
	DrillDefinition.SourceDeck.Cases = FilterDrillCasesByModeCompatibility(
		Deck.Cases,
		DrillMode,
		VisibilityPolicy
	);
	if (DrillDefinition.SourceDeck.Cases.Num() == 0)
	{
		return FHyperTwistTrainingMethodDrillDefinition();
	}
	DrillDefinition.FocusCaseIds = HyperTwistTrainingLibraryInternal::BuildDrillCaseIds(DrillDefinition);
	DrillDefinition.DrillMode = DrillMode;
	DrillDefinition.VisibilityPolicy = VisibilityPolicy;
	DrillDefinition.EntrySource = EntrySource;
	DrillDefinition.bAnswerHiddenUntilReveal = bAnswerHiddenUntilReveal;
	DrillDefinition.bAllowImmediateRepeat = true;
	DrillDefinition.SourceAttribution = Deck.SourceAttribution;
	DrillDefinition.StageId = !Deck.SubsetId.IsEmpty()
		? Deck.SubsetId.ToLower()
		: DrillDefinition.MethodDescriptor.SegmentId;
	DrillDefinition.StageLabel = !Deck.SubsetId.IsEmpty()
		? Deck.SubsetId
		: DrillDefinition.MethodDescriptor.MethodLabel;
	DrillDefinition.DrillId = FString::Printf(
		TEXT("%s/drill/%s/%s"),
		*Deck.DeckId,
		*HyperTwistTrainingLibraryInternal::DrillModeToToken(DrillMode),
		*HyperTwistTrainingLibraryInternal::DrillVisibilityToToken(VisibilityPolicy)
	);
	DrillDefinition.Title = FString::Printf(TEXT("%s Drill"), *Deck.Title);
	DrillDefinition.Tags = Deck.Tags;
	DrillDefinition.Tags.AddUnique(TEXT("method-drill"));
	DrillDefinition.Tags.AddUnique(FString::Printf(
		TEXT("drill-mode:%s"),
		*HyperTwistTrainingLibraryInternal::DrillModeToToken(DrillMode)
	));
	DrillDefinition.Tags.AddUnique(FString::Printf(
		TEXT("visibility:%s"),
		*HyperTwistTrainingLibraryInternal::DrillVisibilityToToken(VisibilityPolicy)
	));
	DrillDefinition.Tags.AddUnique(FString::Printf(
		TEXT("method-segment:%s"),
		*DrillDefinition.MethodDescriptor.SegmentId.ToLower()
	));
	return DrillDefinition;
}

FHyperTwistTrainingMethodDrillRunState UHyperTwistTrainingLibrary::StartMethodDrillRun(
	const FHyperTwistTrainingMethodDrillDefinition& DrillDefinition,
	const FString& UserId,
	const FString& RunId
)
{
	FHyperTwistTrainingMethodDrillRunState RunState;
	if (!DrillDefinition.IsStructurallyValid())
	{
		return RunState;
	}

	RunState.DrillDefinition = DrillDefinition;
	RunState.RunId = RunId.IsEmpty()
		? FString::Printf(TEXT("method_drill_%s"), *DrillDefinition.DrillId)
		: RunId;
	RunState.UserId = UserId.IsEmpty() ? TEXT("local-user") : UserId;
	RunState.StartedAtUtc = FDateTime::UtcNow().ToIso8601();
	RunState.LastUpdatedAtUtc = RunState.StartedAtUtc;
	RunState.LifecycleState = EHyperTwistTrainingDrillLifecycleState::Ready;
	RunState.PendingCaseIds = HyperTwistTrainingLibraryInternal::BuildDrillCaseIds(DrillDefinition);
	if (RunState.PendingCaseIds.Num() == 0)
	{
		return FHyperTwistTrainingMethodDrillRunState();
	}

	return RunState;
}

FHyperTwistTrainingMethodDrillRunState UHyperTwistTrainingLibrary::ResumeMethodDrillRunFromBatch(
	const FHyperTwistTrainingMethodDrillDefinition& DrillDefinition,
	const FHyperTwistTrainingMethodDrillBatch& DrillBatch,
	const FString& RunId
)
{
	FHyperTwistTrainingMethodDrillRunState RunState;
	if (!DrillDefinition.IsStructurallyValid()
		|| !DrillBatch.IsStructurallyValid()
		|| DrillBatch.DrillId != DrillDefinition.DrillId
		|| DrillBatch.SourceDeckId != DrillDefinition.SourceDeckId)
	{
		return RunState;
	}

	RunState.DrillDefinition = DrillDefinition;
	RunState.RunId = RunId.IsEmpty()
		? FString::Printf(TEXT("method_drill_resume_%s"), *DrillBatch.BatchId)
		: RunId;
	RunState.UserId = DrillBatch.UserId;
	RunState.SourceBatchId = DrillBatch.BatchId;
	RunState.LifecycleState = DrillBatch.SavedLifecycleState;
	RunState.StartedAtUtc = HyperTwistTrainingLibraryInternal::ResolveDrillTimestamp(DrillBatch.CreatedAtUtc);
	RunState.LastUpdatedAtUtc = HyperTwistTrainingLibraryInternal::ResolveDrillTimestamp(DrillBatch.LastUpdatedAtUtc);
	RunState.PendingCaseIds = DrillBatch.PendingCaseIds;
	RunState.CompletedCaseIds = DrillBatch.CompletedCaseIds;
	RunState.Responses = DrillBatch.Responses;
	RunState.RevealCount = DrillBatch.RevealCount;
	RunState.RepeatCount = DrillBatch.RepeatCount;
	RunState.bAnswerVisible = RunState.LifecycleState == EHyperTwistTrainingDrillLifecycleState::Revealed;

	FString CurrentCaseId = DrillBatch.CurrentCaseId;
	HyperTwistTrainingLibraryInternal::NormalizeDrillQueueAgainstDefinition(
		RunState.DrillDefinition,
		RunState.PendingCaseIds,
		RunState.CompletedCaseIds,
		CurrentCaseId
	);

	if (!CurrentCaseId.IsEmpty())
	{
		const FHyperTwistTrainingCase* CurrentCase = HyperTwistTrainingLibraryInternal::FindDrillCaseById(
			RunState.DrillDefinition,
			CurrentCaseId
		);
		if (CurrentCase != nullptr && CurrentCase->IsStructurallyValid())
		{
			RunState.CurrentCase = *CurrentCase;
		}
	}

	if ((RunState.LifecycleState == EHyperTwistTrainingDrillLifecycleState::Active
			|| RunState.LifecycleState == EHyperTwistTrainingDrillLifecycleState::Revealed)
		&& !RunState.CurrentCase.IsStructurallyValid())
	{
		RunState.LifecycleState = EHyperTwistTrainingDrillLifecycleState::Ready;
		RunState.bAnswerVisible = false;
	}

	if (RunState.LifecycleState == EHyperTwistTrainingDrillLifecycleState::Completed
		|| RunState.LifecycleState == EHyperTwistTrainingDrillLifecycleState::Aborted)
	{
		RunState.EndedAtUtc = RunState.LastUpdatedAtUtc;
		RunState.CurrentCase = FHyperTwistTrainingCase();
		RunState.bAnswerVisible = false;
	}

	return RunState;
}

FHyperTwistTrainingMethodDrillRunState UHyperTwistTrainingLibrary::StartNextMethodDrillCase(
	const FHyperTwistTrainingMethodDrillRunState& RunState
)
{
	FHyperTwistTrainingMethodDrillRunState UpdatedRunState = RunState;
	if (!UpdatedRunState.IsStructurallyValid()
		|| UpdatedRunState.LifecycleState != EHyperTwistTrainingDrillLifecycleState::Ready)
	{
		return UpdatedRunState;
	}

	FString CurrentCaseId = UpdatedRunState.CurrentCase.CaseId;
	HyperTwistTrainingLibraryInternal::NormalizeDrillQueueAgainstDefinition(
		UpdatedRunState.DrillDefinition,
		UpdatedRunState.PendingCaseIds,
		UpdatedRunState.CompletedCaseIds,
		CurrentCaseId
	);

	if (UpdatedRunState.PendingCaseIds.Num() == 0)
	{
		return FinishMethodDrillRun(UpdatedRunState, false);
	}

	const FHyperTwistTrainingCase* NextCase = HyperTwistTrainingLibraryInternal::FindDrillCaseById(
		UpdatedRunState.DrillDefinition,
		UpdatedRunState.PendingCaseIds[0]
	);
	if (NextCase == nullptr || !NextCase->IsStructurallyValid())
	{
		UpdatedRunState.PendingCaseIds.RemoveAt(0);
		return StartNextMethodDrillCase(UpdatedRunState);
	}

	UpdatedRunState.CurrentCase = *NextCase;
	UpdatedRunState.LifecycleState = EHyperTwistTrainingDrillLifecycleState::Active;
	UpdatedRunState.bAnswerVisible = !UpdatedRunState.DrillDefinition.bAnswerHiddenUntilReveal;
	UpdatedRunState.LastUpdatedAtUtc = FDateTime::UtcNow().ToIso8601();
	return UpdatedRunState;
}

FHyperTwistTrainingMethodDrillRunState UHyperTwistTrainingLibrary::RevealMethodDrillCase(
	const FHyperTwistTrainingMethodDrillRunState& RunState
)
{
	FHyperTwistTrainingMethodDrillRunState UpdatedRunState = RunState;
	if (!UpdatedRunState.IsStructurallyValid()
		|| UpdatedRunState.LifecycleState != EHyperTwistTrainingDrillLifecycleState::Active
		|| !UpdatedRunState.CurrentCase.IsStructurallyValid())
	{
		return UpdatedRunState;
	}

	if (!UpdatedRunState.bAnswerVisible)
	{
		UpdatedRunState.RevealCount += 1;
	}

	UpdatedRunState.bAnswerVisible = true;
	UpdatedRunState.LifecycleState = EHyperTwistTrainingDrillLifecycleState::Revealed;
	UpdatedRunState.LastUpdatedAtUtc = FDateTime::UtcNow().ToIso8601();
	return UpdatedRunState;
}

FHyperTwistTrainingMethodDrillRunState UHyperTwistTrainingLibrary::RepeatMethodDrillCase(
	const FHyperTwistTrainingMethodDrillRunState& RunState
)
{
	FHyperTwistTrainingMethodDrillRunState UpdatedRunState = RunState;
	if (!UpdatedRunState.IsStructurallyValid()
		|| (UpdatedRunState.LifecycleState != EHyperTwistTrainingDrillLifecycleState::Active
			&& UpdatedRunState.LifecycleState != EHyperTwistTrainingDrillLifecycleState::Revealed)
		|| !UpdatedRunState.CurrentCase.IsStructurallyValid()
		|| !UpdatedRunState.DrillDefinition.bAllowImmediateRepeat)
	{
		return UpdatedRunState;
	}

	UpdatedRunState.RepeatCount += 1;
	UpdatedRunState.bAnswerVisible = !UpdatedRunState.DrillDefinition.bAnswerHiddenUntilReveal;
	UpdatedRunState.LifecycleState = EHyperTwistTrainingDrillLifecycleState::Active;
	UpdatedRunState.LastUpdatedAtUtc = FDateTime::UtcNow().ToIso8601();
	return UpdatedRunState;
}

FHyperTwistTrainingMethodDrillRunState UHyperTwistTrainingLibrary::AdvanceMethodDrillCase(
	const FHyperTwistTrainingMethodDrillRunState& RunState
)
{
	FHyperTwistTrainingMethodDrillRunState UpdatedRunState = RunState;
	if (!UpdatedRunState.IsStructurallyValid()
		|| (UpdatedRunState.LifecycleState != EHyperTwistTrainingDrillLifecycleState::Active
			&& UpdatedRunState.LifecycleState != EHyperTwistTrainingDrillLifecycleState::Revealed)
		|| !UpdatedRunState.CurrentCase.IsStructurallyValid())
	{
		return UpdatedRunState;
	}

	const FString CompletedCaseId = UpdatedRunState.CurrentCase.CaseId;
	UpdatedRunState.PendingCaseIds.Remove(CompletedCaseId);
	UpdatedRunState.CompletedCaseIds.AddUnique(CompletedCaseId);
	UpdatedRunState.CurrentCase = FHyperTwistTrainingCase();
	UpdatedRunState.bAnswerVisible = false;
	UpdatedRunState.LastUpdatedAtUtc = FDateTime::UtcNow().ToIso8601();

	FString CurrentCaseId;
	HyperTwistTrainingLibraryInternal::NormalizeDrillQueueAgainstDefinition(
		UpdatedRunState.DrillDefinition,
		UpdatedRunState.PendingCaseIds,
		UpdatedRunState.CompletedCaseIds,
		CurrentCaseId
	);

	UpdatedRunState.LifecycleState = UpdatedRunState.PendingCaseIds.Num() > 0
		? EHyperTwistTrainingDrillLifecycleState::Ready
		: EHyperTwistTrainingDrillLifecycleState::Completed;

	if (UpdatedRunState.LifecycleState == EHyperTwistTrainingDrillLifecycleState::Completed)
	{
		UpdatedRunState.EndedAtUtc = UpdatedRunState.LastUpdatedAtUtc;
	}

	return UpdatedRunState;
}

FHyperTwistTrainingMethodDrillRunState UHyperTwistTrainingLibrary::FinishMethodDrillRun(
	const FHyperTwistTrainingMethodDrillRunState& RunState,
	bool bAbortRun
)
{
	FHyperTwistTrainingMethodDrillRunState UpdatedRunState = RunState;
	if (!UpdatedRunState.IsStructurallyValid())
	{
		return UpdatedRunState;
	}

	UpdatedRunState.LifecycleState = bAbortRun
		? EHyperTwistTrainingDrillLifecycleState::Aborted
		: EHyperTwistTrainingDrillLifecycleState::Completed;
	UpdatedRunState.EndedAtUtc = FDateTime::UtcNow().ToIso8601();
	UpdatedRunState.LastUpdatedAtUtc = UpdatedRunState.EndedAtUtc;
	UpdatedRunState.CurrentCase = FHyperTwistTrainingCase();
	UpdatedRunState.bAnswerVisible = false;
	return UpdatedRunState;
}

FHyperTwistTrainingMethodDrillSummary UHyperTwistTrainingLibrary::DeriveMethodDrillSummary(
	const FHyperTwistTrainingMethodDrillRunState& RunState
)
{
	return HyperTwistTrainingLibraryInternal::BuildDrillSummaryInternal(RunState);
}

FHyperTwistTrainingMethodDrillDiagnosticPacket UHyperTwistTrainingLibrary::DeriveMethodDrillDiagnosticPacket(
	const FHyperTwistTrainingMethodDrillRunState& RunState
)
{
	FHyperTwistTrainingMethodDrillDiagnosticPacket Packet;
	if (!RunState.IsStructurallyValid())
	{
		return Packet;
	}

	Packet.RunId = RunState.RunId;
	Packet.UserId = RunState.UserId;
	Packet.DrillId = RunState.DrillDefinition.DrillId;
	Packet.SourceDeckId = RunState.DrillDefinition.SourceDeckId;
	Packet.MethodSegmentId = RunState.DrillDefinition.MethodDescriptor.SegmentId;
	Packet.StageId = RunState.DrillDefinition.StageId;
	Packet.StageLabel = RunState.DrillDefinition.StageLabel;
	Packet.SourceBatchId = RunState.SourceBatchId;
	Packet.ReferenceUtc = HyperTwistTrainingLibraryInternal::ResolveDrillTimestamp(RunState.LastUpdatedAtUtc);
	Packet.DrillMode = RunState.DrillDefinition.DrillMode;
	Packet.VisibilityPolicy = RunState.DrillDefinition.VisibilityPolicy;
	Packet.LifecycleState = RunState.LifecycleState;
	Packet.ResponseCount = RunState.Responses.Num();
	Packet.bWasResumedRun = !RunState.SourceBatchId.IsEmpty();

	const TArray<FString> KnownCaseIds = HyperTwistTrainingLibraryInternal::BuildKnownDrillCaseIds(RunState);
	TSet<FString> CompletedCaseSet(RunState.CompletedCaseIds);
	TSet<FString> UnresolvedCaseSet;
	for (const FString& CaseId : RunState.PendingCaseIds)
	{
		if (!CaseId.IsEmpty())
		{
			UnresolvedCaseSet.Add(CaseId);
		}
	}
	if (!RunState.CurrentCase.CaseId.IsEmpty())
	{
		UnresolvedCaseSet.Add(RunState.CurrentCase.CaseId);
	}
	for (const FString& CompletedCaseId : RunState.CompletedCaseIds)
	{
		UnresolvedCaseSet.Remove(CompletedCaseId);
	}

	Packet.TotalCaseCount = KnownCaseIds.Num();
	Packet.CompletedCaseCount = CompletedCaseSet.Num();
	Packet.PendingCaseCount = UnresolvedCaseSet.Num();

	float MaxCasePriority = 0.0f;
	for (const FString& CaseId : KnownCaseIds)
	{
		const HyperTwistTrainingLibraryInternal::FMethodDrillCaseMetrics Metrics =
			HyperTwistTrainingLibraryInternal::CollectMethodDrillCaseMetrics(RunState, CaseId);
		const bool bCompleted = CompletedCaseSet.Contains(CaseId);
		const bool bStillPending = UnresolvedCaseSet.Contains(CaseId);
		const bool bWasCarryoverCase = Packet.bWasResumedRun;

		FHyperTwistTrainingMethodDrillCaseComparisonPacket CasePacket;
		CasePacket.CaseId = CaseId;
		CasePacket.AttemptCount = Metrics.AttemptCount;
		CasePacket.CorrectResponseCount = Metrics.CorrectResponseCount;
		CasePacket.IncorrectResponseCount = Metrics.IncorrectResponseCount;
		CasePacket.RevealCount = Metrics.RevealCount;
		CasePacket.RepeatCount = Metrics.RepeatCount;
		CasePacket.AverageElapsedMs = Metrics.ElapsedSampleCount > 0
			? static_cast<int32>(Metrics.ElapsedAccumulator / Metrics.ElapsedSampleCount)
			: 0;
		CasePacket.BestElapsedMs = Metrics.BestElapsedMs;
		CasePacket.WorstElapsedMs = Metrics.WorstElapsedMs;
		CasePacket.bCompleted = bCompleted;
		CasePacket.bStillPending = bStillPending;
		CasePacket.bWasCarryoverCase = bWasCarryoverCase;
		CasePacket.DiagnosticState = HyperTwistTrainingLibraryInternal::ResolveMethodDrillCaseDiagnosticState(
			Metrics,
			bCompleted,
			bStillPending
		);
		CasePacket.RecommendedReviewGrade = HyperTwistTrainingLibraryInternal::ResolveMethodDrillCaseReviewGrade(
			Metrics,
			bCompleted,
			bStillPending,
			bWasCarryoverCase
		);
		CasePacket.bBoundaryTriggeredByReveal = Metrics.RevealCount > 0;
		CasePacket.bBoundaryTriggeredByRepeat = Metrics.RepeatCount > 0 || Metrics.IncorrectResponseCount > 0;
		CasePacket.bBoundaryTriggeredByDeferredState = bStillPending;
		CasePacket.bBoundaryTriggeredByCarryover = bWasCarryoverCase;
		CasePacket.BoundaryReason = HyperTwistTrainingLibraryInternal::BuildMethodDrillCaseBoundaryReason(
			Metrics,
			bCompleted,
			bStillPending,
			bWasCarryoverCase
		);
		CasePacket.PriorityScore = HyperTwistTrainingLibraryInternal::BuildMethodDrillCasePriorityScore(
			Metrics,
			bStillPending,
			bWasCarryoverCase
		);

		Packet.CorrectResponseCount += Metrics.CorrectResponseCount;
		Packet.IncorrectResponseCount += Metrics.IncorrectResponseCount;
		Packet.RevealedCaseCount += Metrics.RevealCount > 0 ? 1 : 0;
		Packet.AssistedCompletionCount += CasePacket.bBoundaryTriggeredByReveal && bCompleted ? 1 : 0;
		Packet.RepeatBurdenCaseCount += CasePacket.bBoundaryTriggeredByRepeat ? 1 : 0;
		Packet.DeferredCaseCount +=
			CasePacket.DiagnosticState == EHyperTwistTrainingMethodDrillDiagnosticState::Deferred ? 1 : 0;
		Packet.ResolvedCarryoverCaseCount += bWasCarryoverCase && bCompleted ? 1 : 0;
		MaxCasePriority = FMath::Max(MaxCasePriority, CasePacket.PriorityScore);
		Packet.CasePackets.Add(CasePacket);
	}

	Packet.CasePackets.Sort([](
		const FHyperTwistTrainingMethodDrillCaseComparisonPacket& Left,
		const FHyperTwistTrainingMethodDrillCaseComparisonPacket& Right
	)
	{
		if (!FMath::IsNearlyEqual(Left.PriorityScore, Right.PriorityScore))
		{
			return Left.PriorityScore > Right.PriorityScore;
		}
		return Left.CaseId < Right.CaseId;
	});

	for (const FHyperTwistTrainingMethodDrillCaseComparisonPacket& CasePacket : Packet.CasePackets)
	{
		if (CasePacket.PriorityScore > 0.0f)
		{
			Packet.FocusCaseIds.Add(CasePacket.CaseId);
		}
	}

	Packet.CompletionFraction = Packet.TotalCaseCount > 0
		? static_cast<float>(Packet.CompletedCaseCount) / static_cast<float>(Packet.TotalCaseCount)
		: 0.0f;
	const int32 AssistanceCaseCount = Packet.AssistedCompletionCount + Packet.RepeatBurdenCaseCount;
	Packet.AssistanceFraction = Packet.TotalCaseCount > 0
		? static_cast<float>(AssistanceCaseCount) / static_cast<float>(Packet.TotalCaseCount)
		: 0.0f;
	Packet.bBoundaryTriggeredByPendingCases = Packet.DeferredCaseCount > 0;
	Packet.bBoundaryTriggeredByRepeatBurden = Packet.RepeatBurdenCaseCount > 0;
	Packet.bBoundaryTriggeredByAssistance = Packet.AssistedCompletionCount > 0;
	Packet.bBoundaryTriggeredByResumeCarryover = Packet.bWasResumedRun;
	Packet.bNeedsFollowUpTemplate = Packet.FocusCaseIds.Num() > 0;
	Packet.bNeedsReviewPlanBridge = Packet.FocusCaseIds.Num() > 0 && !Packet.bBoundaryTriggeredByPendingCases;
	Packet.PriorityScore = MaxCasePriority
		+ static_cast<float>(Packet.DeferredCaseCount * 10)
		+ static_cast<float>(Packet.RepeatBurdenCaseCount * 5)
		+ static_cast<float>(Packet.AssistedCompletionCount * 2);
	Packet.DiagnosticState = HyperTwistTrainingLibraryInternal::ResolveMethodDrillRunDiagnosticState(Packet);
	Packet.RecommendedReviewGrade = HyperTwistTrainingLibraryInternal::ResolveMethodDrillRunReviewGrade(Packet);
	Packet.BoundaryReason = HyperTwistTrainingLibraryInternal::BuildMethodDrillRunBoundaryReason(Packet);
	return Packet;
}

FHyperTwistTrainingMethodDrillBatch UHyperTwistTrainingLibrary::BuildMethodDrillBatch(
	const FHyperTwistTrainingMethodDrillRunState& RunState,
	const FString& BatchId,
	const FString& Title,
	bool bPinnedFavoritesOnly
)
{
	FHyperTwistTrainingMethodDrillBatch DrillBatch;
	if (!RunState.IsStructurallyValid())
	{
		return DrillBatch;
	}

	DrillBatch.UserId = RunState.UserId;
	DrillBatch.BatchId = BatchId.IsEmpty()
		? FString::Printf(TEXT("method_drill_batch_%s"), *RunState.RunId)
		: BatchId;
	DrillBatch.Title = Title.IsEmpty() ? RunState.DrillDefinition.Title : Title;
	DrillBatch.DrillId = RunState.DrillDefinition.DrillId;
	DrillBatch.SourceDeckId = RunState.DrillDefinition.SourceDeckId;
	DrillBatch.MethodDescriptor = RunState.DrillDefinition.MethodDescriptor;
	DrillBatch.DrillMode = RunState.DrillDefinition.DrillMode;
	DrillBatch.VisibilityPolicy = RunState.DrillDefinition.VisibilityPolicy;
	DrillBatch.bAnswerHiddenUntilReveal = RunState.DrillDefinition.bAnswerHiddenUntilReveal;
	DrillBatch.SavedLifecycleState = RunState.LifecycleState;
	DrillBatch.PendingCaseIds = RunState.PendingCaseIds;
	DrillBatch.CompletedCaseIds = RunState.CompletedCaseIds;
	DrillBatch.CurrentCaseId = RunState.CurrentCase.CaseId;
	DrillBatch.Responses = RunState.Responses;
	DrillBatch.RevealCount = RunState.RevealCount;
	DrillBatch.RepeatCount = RunState.RepeatCount;
	DrillBatch.CreatedAtUtc = HyperTwistTrainingLibraryInternal::ResolveDrillTimestamp(RunState.StartedAtUtc);
	DrillBatch.LastUpdatedAtUtc = HyperTwistTrainingLibraryInternal::ResolveDrillTimestamp(RunState.LastUpdatedAtUtc);
	DrillBatch.bPinnedFavoritesOnly = bPinnedFavoritesOnly;
	return DrillBatch;
}

FHyperTwistTrainingDrillCaseModeCompatibility UHyperTwistTrainingLibrary::DeriveDrillCaseModeCompatibility(
	const FHyperTwistTrainingCase& TrainingCase,
	EHyperTwistTrainingDrillModeKind DrillMode,
	EHyperTwistTrainingDrillVisibilityPolicy VisibilityPolicy
)
{
	FHyperTwistTrainingDrillCaseModeCompatibility Result;
	Result.CaseId = TrainingCase.CaseId;
	Result.DrillMode = DrillMode;
	Result.VisibilityPolicy = VisibilityPolicy;

	if (!TrainingCase.IsStructurallyValid())
	{
		return Result;
	}

	const bool bHasNotation = !TrainingCase.CanonicalNotation.IsEmpty();
	const bool bHasStateRef = !TrainingCase.CanonicalStateRef.IsEmpty();
	const bool bHasScramble = !TrainingCase.ScrambleNotation.IsEmpty();
	const bool bHasPromptLabel = !TrainingCase.PromptLabel.IsEmpty();

	const EHyperTwistTrainingPromptKind PromptKind = TrainingCase.PromptKind;

	switch (DrillMode)
	{
	case EHyperTwistTrainingDrillModeKind::Recognition:
		Result.bCompatibleWithMode =
			bHasStateRef
			|| bHasScramble
			|| PromptKind == EHyperTwistTrainingPromptKind::Recognition
			|| PromptKind == EHyperTwistTrainingPromptKind::State;
		break;

	case EHyperTwistTrainingDrillModeKind::Recall:
		Result.bCompatibleWithMode =
			bHasNotation
			|| PromptKind == EHyperTwistTrainingPromptKind::Algorithm
			|| PromptKind == EHyperTwistTrainingPromptKind::Sequence
			|| PromptKind == EHyperTwistTrainingPromptKind::Memo;
		break;

	case EHyperTwistTrainingDrillModeKind::Execution:
		Result.bCompatibleWithMode =
			bHasNotation
			|| PromptKind == EHyperTwistTrainingPromptKind::Algorithm
			|| PromptKind == EHyperTwistTrainingPromptKind::Sequence;
		break;

	case EHyperTwistTrainingDrillModeKind::Reconstruction:
		Result.bCompatibleWithMode =
			bHasStateRef
			|| PromptKind == EHyperTwistTrainingPromptKind::State
			|| PromptKind == EHyperTwistTrainingPromptKind::Recognition;
		break;

	default:
		Result.bCompatibleWithMode = false;
		break;
	}

	switch (VisibilityPolicy)
	{
	case EHyperTwistTrainingDrillVisibilityPolicy::FullInformation:
		Result.bCompatibleWithVisibility = true;
		break;

	case EHyperTwistTrainingDrillVisibilityPolicy::PromptOnly:
		Result.bCompatibleWithVisibility = bHasPromptLabel || bHasNotation;
		break;

	case EHyperTwistTrainingDrillVisibilityPolicy::MaskedState:
	case EHyperTwistTrainingDrillVisibilityPolicy::PartialState:
		Result.bCompatibleWithVisibility = bHasStateRef;
		break;

	default:
		Result.bCompatibleWithVisibility = false;
		break;
	}

	return Result;
}

TArray<FHyperTwistTrainingCase> UHyperTwistTrainingLibrary::FilterDrillCasesByModeCompatibility(
	const TArray<FHyperTwistTrainingCase>& Cases,
	EHyperTwistTrainingDrillModeKind DrillMode,
	EHyperTwistTrainingDrillVisibilityPolicy VisibilityPolicy
)
{
	TArray<FHyperTwistTrainingCase> Filtered;
	for (const FHyperTwistTrainingCase& TrainingCase : Cases)
	{
		const FHyperTwistTrainingDrillCaseModeCompatibility Compat =
			DeriveDrillCaseModeCompatibility(TrainingCase, DrillMode, VisibilityPolicy);
		if (Compat.bCompatibleWithMode && Compat.bCompatibleWithVisibility)
		{
			Filtered.Add(TrainingCase);
		}
	}
	return Filtered;
}
