// Clean-room implementation for HyperTwist puzzle viewer subsystem — Bound 1
// Lane: cubing/twisty.js (GPL-3.0-or-later — restrictive clean-room lane)
// Implemented from governance docs and abstract idea handoff only.
// No source code inspection. Clean-room workflow only.
// Date: 2026-05-17

#include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h"

#include "HyperTwistReplay/HyperTwistReplayLibrary.h"
#include "HyperTwistSimulation/HyperTwistViewerLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingClassicCubingLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingSpatialLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingViewerLibrary.h"
#include "JsonObjectConverter.h"

namespace HyperTwistPuzzleViewerComponentInternal
{
	template <typename TStruct>
	bool DeserializePayload(const FString& Json, TStruct& OutValue)
	{
		if (Json.IsEmpty())
		{
			return false;
		}

		return FJsonObjectConverter::JsonObjectStringToUStruct(Json, &OutValue, 0, 0);
	}

	FHyperTwistTransformation BuildReplayMoveTransform(
		const FHyperTwistReplayPacket& ReplayPacket,
		const FHyperTwistReplayEvent& Event,
		const FHyperTwistReplayMovePayload* MovePayload,
		const bool bPayloadParsed
	)
	{
		FHyperTwistTransformation Transform;
		Transform.Definition = ReplayPacket.PuzzleDefinition;
		Transform.TransformKind = EHyperTwistTransformKind::SingleMove;
		Transform.TransformEncoding.EncodingProfile = TEXT("ht-replay-move-ref/v1");
		Transform.TransformEncoding.PayloadJson = Event.PayloadJson;

		FString ResolvedNotation = Event.EventId;
		if (MovePayload != nullptr)
		{
			if (!MovePayload->Notation.IsEmpty())
			{
				ResolvedNotation = MovePayload->Notation;
			}
			else if (!MovePayload->TransformationRef.IsEmpty())
			{
				ResolvedNotation = MovePayload->TransformationRef;
			}
		}

		if (ResolvedNotation.IsEmpty())
		{
			ResolvedNotation = FString::Printf(TEXT("move-%d"), Event.Sequence);
		}

		Transform.Notation = ResolvedNotation;
		Transform.OriginalNotation = ResolvedNotation;

		if (!bPayloadParsed)
		{
			Transform.NormalizationWarnings.Add(TEXT("replay-move-payload-unparsed"));
		}

		return Transform;
	}

	FHyperTwistViewerMoveEntry BuildReplayMoveEntry(
		const FHyperTwistReplayPacket& ReplayPacket,
		const FHyperTwistReplayEvent& Event
	)
	{
		FHyperTwistReplayMovePayload MovePayload;
		const bool bPayloadParsed = DeserializePayload(Event.PayloadJson, MovePayload);

		FHyperTwistViewerMoveEntry Entry;
		Entry.Transform = BuildReplayMoveTransform(
			ReplayPacket,
			Event,
			bPayloadParsed ? &MovePayload : nullptr,
			bPayloadParsed
		);
		Entry.DisplayLabel = Entry.Transform.Notation;
		Entry.HintPositionSeconds = static_cast<float>(FMath::Max(0, Event.TimeMs)) / 1000.0f;
		return Entry;
	}

	TArray<FHyperTwistViewerMoveEntry> BuildReplayMoveList(const FHyperTwistReplayPacket& ReplayPacket)
	{
		TArray<FHyperTwistViewerMoveEntry> Result;

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

			Result.Add(BuildReplayMoveEntry(ReplayPacket, Event));
		}

		return Result;
	}

	TArray<int32> BuildMoveBoundaryTimesMs(const TArray<FHyperTwistViewerMoveEntry>& MoveList)
	{
		TArray<int32> Result;
		Result.Reserve(MoveList.Num());

		int32 PreviousBoundaryTimeMs = 0;
		for (int32 MoveIndex = 0; MoveIndex < MoveList.Num(); ++MoveIndex)
		{
			const FHyperTwistViewerMoveEntry& Entry = MoveList[MoveIndex];
			int32 BoundaryTimeMs = 0;
			if (Entry.HintPositionSeconds > 0.0f)
			{
				BoundaryTimeMs = FMath::RoundToInt(Entry.HintPositionSeconds * 1000.0f);
			}
			else
			{
				BoundaryTimeMs = (MoveIndex + 1) * 1000;
			}

			BoundaryTimeMs = FMath::Max(BoundaryTimeMs, PreviousBoundaryTimeMs);
			Result.Add(BoundaryTimeMs);
			PreviousBoundaryTimeMs = BoundaryTimeMs;
		}

		return Result;
	}

	int32 ResolvePlaybackTerminalTimeMs(
		const TArray<int32>& MoveBoundaryTimesMs,
		const FHyperTwistDerivedReplaySummary& ReplaySummary,
		const bool bReplayLoaded
	)
	{
		const int32 LastMoveBoundaryTimeMs = MoveBoundaryTimesMs.Num() > 0 ? MoveBoundaryTimesMs.Last() : 0;
		if (bReplayLoaded)
		{
			return FMath::Max(ReplaySummary.TotalTimeMs, LastMoveBoundaryTimeMs);
		}

		return LastMoveBoundaryTimeMs;
	}

	bool TryBuildSceneContextFromReplay(
		const FHyperTwistReplayPacket& ReplayPacket,
		FHyperTwistSimulationSceneContext& OutContext
	)
	{
		for (const FHyperTwistReplayEvent& Event : ReplayPacket.Events)
		{
			if (Event.EventType != EHyperTwistReplayEventType::StateSnapshot)
			{
				continue;
			}

			FHyperTwistStateSnapshot Snapshot;
			if (!DeserializePayload(Event.PayloadJson, Snapshot) || !Snapshot.IsStructurallyValid())
			{
				continue;
			}

			OutContext.SceneContextId = !ReplayPacket.ReplayId.IsEmpty()
				? FString::Printf(TEXT("replay/%s"), *ReplayPacket.ReplayId)
				: Snapshot.SnapshotId;
			OutContext.PuzzleState = Snapshot.State;
			OutContext.RenderStateProfile = ReplayPacket.PuzzleDefinition.PuzzleFamily == EHyperTwistPuzzleFamily::ClassicCube
				? TEXT("classic-replay")
				: TEXT("replay-default");
			return OutContext.IsStructurallyValid();
		}

		return false;
	}

	int32 DeriveCurrentTimeMs(
		const TArray<FHyperTwistViewerMoveEntry>& MoveList,
		const FHyperTwistViewerPlaybackState& PlaybackState,
		const FHyperTwistDerivedReplaySummary& ReplaySummary
	)
	{
		if (MoveList.Num() == 0)
		{
			return 0;
		}

		if (PlaybackState.bAtStart)
		{
			return 0;
		}

		if (PlaybackState.bAtEnd && ReplaySummary.TotalTimeMs > 0)
		{
			return ReplaySummary.TotalTimeMs;
		}

		if (MoveList.IsValidIndex(PlaybackState.CurrentMoveIndex))
		{
			return FMath::RoundToInt(FMath::Max(0.0f, MoveList[PlaybackState.CurrentMoveIndex].HintPositionSeconds) * 1000.0f);
		}

		return FMath::Max(0, ReplaySummary.TotalTimeMs);
	}

	int32 FindLastCompletedMoveIndexForTime(const TArray<int32>& MoveBoundaryTimesMs, const int32 CurrentTimeMs)
	{
		int32 LastCompletedMoveIndex = INDEX_NONE;
		for (int32 MoveIndex = 0; MoveIndex < MoveBoundaryTimesMs.Num(); ++MoveIndex)
		{
			if (MoveBoundaryTimesMs[MoveIndex] <= CurrentTimeMs)
			{
				LastCompletedMoveIndex = MoveIndex;
			}
			else
			{
				break;
			}
		}

		return LastCompletedMoveIndex;
	}

	int32 FindDisplayMoveIndexForTime(
		const TArray<int32>& MoveBoundaryTimesMs,
		const int32 CurrentTimeMs,
		const int32 TerminalTimeMs
	)
	{
		if (MoveBoundaryTimesMs.Num() == 0)
		{
			return INDEX_NONE;
		}

		if (CurrentTimeMs <= 0)
		{
			return 0;
		}

		if (TerminalTimeMs > 0 && CurrentTimeMs >= TerminalTimeMs)
		{
			return MoveBoundaryTimesMs.Num() - 1;
		}

		for (int32 MoveIndex = 0; MoveIndex < MoveBoundaryTimesMs.Num(); ++MoveIndex)
		{
			if (CurrentTimeMs < MoveBoundaryTimesMs[MoveIndex])
			{
				return MoveIndex;
			}

			if (CurrentTimeMs == MoveBoundaryTimesMs[MoveIndex] && MoveIndex + 1 < MoveBoundaryTimesMs.Num())
			{
				return MoveIndex + 1;
			}
		}

		return MoveBoundaryTimesMs.Num() - 1;
	}

	int32 ResolveNextMoveBoundaryTimeMs(const TArray<int32>& MoveBoundaryTimesMs, const int32 CurrentTimeMs)
	{
		for (const int32 MoveBoundaryTimeMs : MoveBoundaryTimesMs)
		{
			if (MoveBoundaryTimeMs > CurrentTimeMs)
			{
				return MoveBoundaryTimeMs;
			}
		}

		return CurrentTimeMs;
	}

	int32 ResolvePreviousMoveBoundaryTimeMs(const TArray<int32>& MoveBoundaryTimesMs, const int32 CurrentTimeMs)
	{
		int32 PreviousBoundaryTimeMs = 0;
		for (const int32 MoveBoundaryTimeMs : MoveBoundaryTimesMs)
		{
			if (MoveBoundaryTimeMs >= CurrentTimeMs)
			{
				break;
			}

			PreviousBoundaryTimeMs = MoveBoundaryTimeMs;
		}

		return PreviousBoundaryTimeMs;
	}

	TArray<int32> ResolveProjectionBoundaryTimesMs(
		const TArray<FHyperTwistViewerMoveEntry>& MoveList,
		const TArray<int32>& CachedMoveBoundaryTimesMs
	)
	{
		return CachedMoveBoundaryTimesMs.Num() == MoveList.Num()
			? CachedMoveBoundaryTimesMs
			: BuildMoveBoundaryTimesMs(MoveList);
	}

	int32 ResolveProjectionMoveStartTimeMs(const TArray<int32>& MoveBoundaryTimesMs, const int32 MoveIndex)
	{
		return MoveIndex > 0 && MoveBoundaryTimesMs.IsValidIndex(MoveIndex - 1)
			? MoveBoundaryTimesMs[MoveIndex - 1]
			: 0;
	}

	EHyperTwistViewerVisualizationMode ResolvePreferredVisualizationMode(
		const EHyperTwistViewerVisualizationPreference Preference
	)
	{
		return Preference == EHyperTwistViewerVisualizationPreference::Prefer3D
			? EHyperTwistViewerVisualizationMode::ThreeD
			: EHyperTwistViewerVisualizationMode::TwoD;
	}

	EHyperTwistViewerPresentationMode ResolvePresentationMode(
		const EHyperTwistViewerPresentationMode RequestedMode,
		const bool bAllowExpandedView,
		const bool bAllowFullscreen
	)
	{
		switch (RequestedMode)
		{
		case EHyperTwistViewerPresentationMode::Fullscreen:
			if (bAllowFullscreen)
			{
				return EHyperTwistViewerPresentationMode::Fullscreen;
			}
			if (bAllowExpandedView)
			{
				return EHyperTwistViewerPresentationMode::Expanded;
			}
			return EHyperTwistViewerPresentationMode::Inline;

		case EHyperTwistViewerPresentationMode::Expanded:
			return bAllowExpandedView
				? EHyperTwistViewerPresentationMode::Expanded
				: EHyperTwistViewerPresentationMode::Inline;

		default:
			return EHyperTwistViewerPresentationMode::Inline;
		}
	}

	bool SupportsThreeDVisualization(
		const FHyperTwistViewerEmbeddedHostBootstrapState& BootstrapState,
		const FHyperTwistTrainingViewerEmbedContract& EmbedContract,
		const FHyperTwistTrainingSpatialUiContract& UiContract,
		const FHyperTwistTrainingSpatialEffectBoundary& EffectBoundary,
		const FHyperTwistTrainingSpatialStateMotionContract& StateMotionContract
	)
	{
		return BootstrapState.bReadyForEmbed
			&& BootstrapState.BindingMode == EHyperTwistViewerEmbeddedHostBindingMode::ScissoredView
			&& BootstrapState.RenderStateProfile == TEXT("classic-replay")
			&& BootstrapState.SpatialEventSurfaceTags.Contains(TEXT("scissored view embedding"))
			&& BootstrapState.SpatialEventSurfaceTags.Contains(TEXT("HTML overlay bridge"))
			&& EmbedContract.ShippableBehaviorTags.Contains(TEXT("orbit-controls"))
			&& UiContract.StateSurfaceTags.Contains(TEXT("HTML/spatial overlay bridge"))
			&& EffectBoundary.EffectFamilies.Contains(TEXT("tone mapping and highlight effects"))
			&& StateMotionContract.MotionKinds.Contains(TEXT("reduced-motion support"));
	}

	EHyperTwistViewerVisualizationFallbackReason ResolveThreeDFallbackReason(
		const FHyperTwistViewerEmbeddedHostBootstrapState& BootstrapState,
		const FHyperTwistTrainingViewerEmbedContract& EmbedContract,
		const FHyperTwistTrainingSpatialUiContract& UiContract,
		const FHyperTwistTrainingSpatialEffectBoundary& EffectBoundary,
		const FHyperTwistTrainingSpatialStateMotionContract& StateMotionContract
	)
	{
		if (BootstrapState.BindingMode != EHyperTwistViewerEmbeddedHostBindingMode::ScissoredView)
		{
			return EHyperTwistViewerVisualizationFallbackReason::HostBindingDoesNotSupport3D;
		}

		if (BootstrapState.RenderStateProfile != TEXT("classic-replay"))
		{
			return EHyperTwistViewerVisualizationFallbackReason::RenderProfilePrefers2D;
		}

		return SupportsThreeDVisualization(BootstrapState, EmbedContract, UiContract, EffectBoundary, StateMotionContract)
			? EHyperTwistViewerVisualizationFallbackReason::None
			: EHyperTwistViewerVisualizationFallbackReason::MissingSupportContract;
	}

	bool CanApplyPresentationMode(
		const FHyperTwistViewerVisualizationShellState& VisualizationState,
		const EHyperTwistViewerPresentationMode NewMode
	)
	{
		switch (NewMode)
		{
		case EHyperTwistViewerPresentationMode::Fullscreen:
			return VisualizationState.bSupportsFullscreen;

		case EHyperTwistViewerPresentationMode::Expanded:
			return VisualizationState.bSupportsExpandedView;

		default:
			return true;
		}
	}

	FHyperTwistViewerPositionSnapshot BuildPositionSnapshot(
		const TArray<FHyperTwistViewerMoveEntry>& MoveList,
		const TArray<int32>& MoveBoundaryTimesMs,
		const int32 CurrentTimeMs,
		const int32 TerminalTimeMs,
		const EHyperTwistViewerPlaybackDirection PlaybackDirection
	)
	{
		FHyperTwistViewerPositionSnapshot Snapshot;
		Snapshot.CurrentTimeMs = FMath::Max(0, CurrentTimeMs);
		Snapshot.bAtStart = Snapshot.CurrentTimeMs <= 0;
		Snapshot.bAtEnd = TerminalTimeMs > 0 ? Snapshot.CurrentTimeMs >= TerminalTimeMs : MoveList.Num() == 0;

		if (MoveList.Num() == 0)
		{
			Snapshot.bAtBoundary = true;
			return Snapshot;
		}

		Snapshot.BaseCompletedMoveIndex = FindLastCompletedMoveIndexForTime(MoveBoundaryTimesMs, Snapshot.CurrentTimeMs);
		Snapshot.BaseBoundaryTimeMs = Snapshot.BaseCompletedMoveIndex != INDEX_NONE
			? MoveBoundaryTimesMs[Snapshot.BaseCompletedMoveIndex]
			: 0;

		const int32 NextMoveIndex = Snapshot.BaseCompletedMoveIndex + 1;
		if (!Snapshot.bAtEnd
			&& NextMoveIndex >= 0
			&& MoveList.IsValidIndex(NextMoveIndex)
			&& MoveBoundaryTimesMs.IsValidIndex(NextMoveIndex))
		{
			const int32 SegmentStartTimeMs = Snapshot.BaseBoundaryTimeMs;
			const int32 SegmentEndTimeMs = MoveBoundaryTimesMs[NextMoveIndex];
			if (Snapshot.CurrentTimeMs > SegmentStartTimeMs && Snapshot.CurrentTimeMs < SegmentEndTimeMs)
			{
				FHyperTwistViewerInFlightMoveProgress Progress;
				Progress.MoveIndex = NextMoveIndex;
				Progress.MoveEntry = MoveList[NextMoveIndex];
				Progress.Direction = PlaybackDirection == EHyperTwistViewerPlaybackDirection::None
					? EHyperTwistViewerPlaybackDirection::Forward
					: PlaybackDirection;
				Progress.StartedAtMs = SegmentStartTimeMs;
				Progress.EndsAtMs = SegmentEndTimeMs;

				const int32 SegmentDurationMs = FMath::Max(1, SegmentEndTimeMs - SegmentStartTimeMs);
				Progress.Fraction = FMath::Clamp(
					static_cast<float>(Snapshot.CurrentTimeMs - SegmentStartTimeMs) / static_cast<float>(SegmentDurationMs),
					0.0f,
					1.0f
				);

				Snapshot.InFlightMoves.Add(Progress);
			}
		}

		Snapshot.bAtBoundary = Snapshot.InFlightMoves.Num() == 0;
		return Snapshot;
	}

	FHyperTwistViewerDirectionState BuildDirectionState(
		const EHyperTwistViewerPlaybackMode Mode,
		const EHyperTwistViewerPlaybackDirection Direction
	)
	{
		FHyperTwistViewerDirectionState State;
		State.Mode = Mode;
		State.Direction = Direction;
		State.bIsContinuousPlayback = Mode == EHyperTwistViewerPlaybackMode::Playing;
		State.bIsPaused = Mode == EHyperTwistViewerPlaybackMode::Paused;
		State.bIsStepping = Mode == EHyperTwistViewerPlaybackMode::Stepping;
		return State;
	}

	bool ArePlaybackStatesEquivalent(
		const FHyperTwistViewerPlaybackState& Left,
		const FHyperTwistViewerPlaybackState& Right
	)
	{
		return Left.Mode == Right.Mode
			&& Left.Direction == Right.Direction
			&& Left.CurrentMoveIndex == Right.CurrentMoveIndex
			&& Left.TotalMoveCount == Right.TotalMoveCount
			&& Left.CurrentTimeMs == Right.CurrentTimeMs
			&& Left.TotalTimeMs == Right.TotalTimeMs
			&& Left.InspectionTimeMs == Right.InspectionTimeMs
			&& Left.bAtStart == Right.bAtStart
			&& Left.bAtEnd == Right.bAtEnd
			&& Left.bSceneContextLoaded == Right.bSceneContextLoaded
			&& Left.bReplayLoaded == Right.bReplayLoaded
			&& Left.ReplayId == Right.ReplayId
			&& Left.PuzzleId == Right.PuzzleId
			&& Left.ReplayResult == Right.ReplayResult
			&& Left.StatusLabel == Right.StatusLabel;
	}

	bool ArePositionSnapshotsEquivalent(
		const FHyperTwistViewerPositionSnapshot& Left,
		const FHyperTwistViewerPositionSnapshot& Right
	)
	{
		if (Left.CurrentTimeMs != Right.CurrentTimeMs
			|| Left.BaseCompletedMoveIndex != Right.BaseCompletedMoveIndex
			|| Left.BaseBoundaryTimeMs != Right.BaseBoundaryTimeMs
			|| Left.bAtBoundary != Right.bAtBoundary
			|| Left.bAtStart != Right.bAtStart
			|| Left.bAtEnd != Right.bAtEnd
			|| Left.InFlightMoves.Num() != Right.InFlightMoves.Num())
		{
			return false;
		}

		for (int32 ProgressIndex = 0; ProgressIndex < Left.InFlightMoves.Num(); ++ProgressIndex)
		{
			const FHyperTwistViewerInFlightMoveProgress& LeftProgress = Left.InFlightMoves[ProgressIndex];
			const FHyperTwistViewerInFlightMoveProgress& RightProgress = Right.InFlightMoves[ProgressIndex];
			if (LeftProgress.MoveIndex != RightProgress.MoveIndex
				|| LeftProgress.Direction != RightProgress.Direction
				|| LeftProgress.StartedAtMs != RightProgress.StartedAtMs
				|| LeftProgress.EndsAtMs != RightProgress.EndsAtMs
				|| !FMath::IsNearlyEqual(LeftProgress.Fraction, RightProgress.Fraction)
				|| LeftProgress.MoveEntry.DisplayLabel != RightProgress.MoveEntry.DisplayLabel)
			{
				return false;
			}
		}

		return true;
	}

	bool AreDirectionStatesEquivalent(
		const FHyperTwistViewerDirectionState& Left,
		const FHyperTwistViewerDirectionState& Right
	)
	{
		return Left.Mode == Right.Mode
			&& Left.Direction == Right.Direction
			&& Left.bIsContinuousPlayback == Right.bIsContinuousPlayback
			&& Left.bIsPaused == Right.bIsPaused
			&& Left.bIsStepping == Right.bIsStepping;
	}
}

UHyperTwistPuzzleViewerComponent::UHyperTwistPuzzleViewerComponent()
{
	PrimaryComponentTick.bCanEverTick = false;

	// Initialise playback state to a clean stopped-empty state.
	PlaybackState.Mode             = EHyperTwistViewerPlaybackMode::Stopped;
	PlaybackState.Direction        = EHyperTwistViewerPlaybackDirection::None;
	PlaybackState.CurrentMoveIndex = INDEX_NONE;
	PlaybackState.TotalMoveCount   = 0;
	PlaybackState.CurrentTimeMs    = 0;
	PlaybackState.TotalTimeMs      = 0;
	PlaybackState.InspectionTimeMs = 0;
	PlaybackState.bAtStart         = true;
	PlaybackState.bAtEnd           = true;
	PlaybackState.bSceneContextLoaded = false;
	PlaybackState.bReplayLoaded   = false;
	PlaybackState.StatusLabel     = TEXT("No scene loaded");
}

void UHyperTwistPuzzleViewerComponent::BeginPlay()
{
	Super::BeginPlay();
	// Host-driven deterministic transport remains explicit in Bound 2.
}

// ---------------------------------------------------------------------------
// Load API
// ---------------------------------------------------------------------------

void UHyperTwistPuzzleViewerComponent::LoadSceneContext(const FHyperTwistSimulationSceneContext& InContext)
{
	const FHyperTwistViewerPlaybackState PreviousPlaybackState = PlaybackState;
	const FHyperTwistViewerPositionSnapshot PreviousPositionSnapshot = PositionSnapshot;
	const FHyperTwistViewerDirectionState PreviousDirectionState = DirectionState;

	SceneContext = InContext;
	PlaybackState.bSceneContextLoaded = InContext.IsStructurallyValid();
	HostBootstrapState = FHyperTwistViewerEmbeddedHostBootstrapState();
	VisualizationShellState = FHyperTwistViewerVisualizationShellState();

	// Reset playhead — loaded content has changed.
	PlaybackState.Mode             = EHyperTwistViewerPlaybackMode::Stopped;
	PlaybackState.Direction        = EHyperTwistViewerPlaybackDirection::None;
	PlaybackState.CurrentTimeMs    = 0;

	RebuildAndBroadcast(PreviousPlaybackState, PreviousPositionSnapshot, PreviousDirectionState);
}

void UHyperTwistPuzzleViewerComponent::LoadMoveList(const TArray<FHyperTwistViewerMoveEntry>& InMoveList)
{
	const FHyperTwistViewerPlaybackState PreviousPlaybackState = PlaybackState;
	const FHyperTwistViewerPositionSnapshot PreviousPositionSnapshot = PositionSnapshot;
	const FHyperTwistViewerDirectionState PreviousDirectionState = DirectionState;

	MoveList = InMoveList;
	ReplayPacket = FHyperTwistReplayPacket();
	ReplaySummary = FHyperTwistDerivedReplaySummary();
	bReplayPacketLoaded = false;
	HostBootstrapState = FHyperTwistViewerEmbeddedHostBootstrapState();
	VisualizationShellState = FHyperTwistViewerVisualizationShellState();

	PlaybackState.Mode             = EHyperTwistViewerPlaybackMode::Stopped;
	PlaybackState.Direction        = EHyperTwistViewerPlaybackDirection::None;
	PlaybackState.CurrentTimeMs    = 0;

	RebuildAndBroadcast(PreviousPlaybackState, PreviousPositionSnapshot, PreviousDirectionState);
}

bool UHyperTwistPuzzleViewerComponent::LoadReplayPacket(const FHyperTwistReplayPacket& InReplayPacket)
{
	if (!InReplayPacket.IsStructurallyValid())
	{
		return false;
	}

	const FHyperTwistViewerPlaybackState PreviousPlaybackState = PlaybackState;
	const FHyperTwistViewerPositionSnapshot PreviousPositionSnapshot = PositionSnapshot;
	const FHyperTwistViewerDirectionState PreviousDirectionState = DirectionState;

	ReplayPacket = InReplayPacket;
	ReplaySummary = UHyperTwistReplayLibrary::DeriveReplaySummary(InReplayPacket);
	bReplayPacketLoaded = true;
	MoveList = HyperTwistPuzzleViewerComponentInternal::BuildReplayMoveList(InReplayPacket);
	HostBootstrapState = FHyperTwistViewerEmbeddedHostBootstrapState();
	VisualizationShellState = FHyperTwistViewerVisualizationShellState();

	FHyperTwistSimulationSceneContext ReplaySceneContext;
	if (HyperTwistPuzzleViewerComponentInternal::TryBuildSceneContextFromReplay(InReplayPacket, ReplaySceneContext))
	{
		SceneContext = ReplaySceneContext;
	}
	else if (!SceneContext.IsStructurallyValid()
		|| SceneContext.PuzzleState.Definition.PuzzleId != InReplayPacket.PuzzleDefinition.PuzzleId)
	{
		SceneContext = FHyperTwistSimulationSceneContext();
	}

	PlaybackState.Mode             = EHyperTwistViewerPlaybackMode::Stopped;
	PlaybackState.Direction        = EHyperTwistViewerPlaybackDirection::None;
	PlaybackState.CurrentTimeMs    = 0;

	RebuildAndBroadcast(PreviousPlaybackState, PreviousPositionSnapshot, PreviousDirectionState);
	return true;
}

// ---------------------------------------------------------------------------
// Control request state machine
// ---------------------------------------------------------------------------

void UHyperTwistPuzzleViewerComponent::ApplyControlRequest(const FHyperTwistViewerControlRequest& Request)
{
	if (!Request.IsStructurallyValid())
	{
		return;
	}

	const FHyperTwistViewerPlaybackState PreviousPlaybackState = PlaybackState;
	const FHyperTwistViewerPositionSnapshot PreviousPositionSnapshot = PositionSnapshot;
	const FHyperTwistViewerDirectionState PreviousDirectionState = DirectionState;
	const int32 PreviousTimeMs = PlaybackState.CurrentTimeMs;
	const int32 PreviousMoveIndex = PlaybackState.CurrentMoveIndex;
	const bool bHasMoves = MoveList.Num() > 0;
	const auto ResolveJumpMoveIndex = [this](const int32 NewTimeMs)
	{
		return HyperTwistPuzzleViewerComponentInternal::FindDisplayMoveIndexForTime(
			MoveBoundaryTimesMs,
			NewTimeMs,
			PlaybackState.TotalTimeMs
		);
	};
	FHyperTwistViewerJumpEvent JumpEvent;
	FHyperTwistViewerJumpEvent* JumpEventPtr = nullptr;

	switch (Request.Action)
	{
	case EHyperTwistViewerControlAction::Play:
		if (bHasMoves && PlaybackState.Mode != EHyperTwistViewerPlaybackMode::Playing)
		{
			if (PlaybackState.TotalTimeMs > 0
				&& PlaybackState.CurrentTimeMs >= PlaybackState.TotalTimeMs
				&& PlaybackState.Mode != EHyperTwistViewerPlaybackMode::Playing)
			{
				PlaybackState.CurrentTimeMs = 0;
				JumpEvent.JumpKind = EHyperTwistViewerJumpKind::PlayWrap;
				JumpEvent.PreviousTimeMs = PreviousTimeMs;
				JumpEvent.NewTimeMs = 0;
				JumpEvent.PreviousMoveIndex = PreviousMoveIndex;
				JumpEvent.NewMoveIndex = ResolveJumpMoveIndex(0);
				JumpEventPtr = &JumpEvent;
			}
			PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Playing;
			PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::Forward;
		}
		break;

	case EHyperTwistViewerControlAction::Pause:
		if (PlaybackState.Mode == EHyperTwistViewerPlaybackMode::Playing)
		{
			PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Paused;
		}
		break;

	case EHyperTwistViewerControlAction::Stop:
		PlaybackState.Mode             = EHyperTwistViewerPlaybackMode::Stopped;
		PlaybackState.Direction        = EHyperTwistViewerPlaybackDirection::None;
		PlaybackState.CurrentTimeMs    = 0;
		if (PreviousTimeMs != 0)
		{
			JumpEvent.JumpKind = EHyperTwistViewerJumpKind::Stop;
			JumpEvent.PreviousTimeMs = PreviousTimeMs;
			JumpEvent.NewTimeMs = 0;
			JumpEvent.PreviousMoveIndex = PreviousMoveIndex;
			JumpEvent.NewMoveIndex = bHasMoves ? 0 : INDEX_NONE;
			JumpEventPtr = &JumpEvent;
		}
		break;

	case EHyperTwistViewerControlAction::StepForward:
		if (bHasMoves)
		{
			PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stepping;
			PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::Forward;
			const int32 NewTimeMs = HyperTwistPuzzleViewerComponentInternal::ResolveNextMoveBoundaryTimeMs(
				MoveBoundaryTimesMs,
				PlaybackState.CurrentTimeMs
			);
			if (NewTimeMs != PlaybackState.CurrentTimeMs)
			{
				PlaybackState.CurrentTimeMs = NewTimeMs;
				JumpEvent.JumpKind = EHyperTwistViewerJumpKind::StepForward;
				JumpEvent.PreviousTimeMs = PreviousTimeMs;
				JumpEvent.NewTimeMs = NewTimeMs;
				JumpEvent.PreviousMoveIndex = PreviousMoveIndex;
				JumpEvent.NewMoveIndex = ResolveJumpMoveIndex(NewTimeMs);
				JumpEventPtr = &JumpEvent;
			}
		}
		break;

	case EHyperTwistViewerControlAction::StepBack:
		if (bHasMoves)
		{
			PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stepping;
			PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::Backward;
			const int32 NewTimeMs = HyperTwistPuzzleViewerComponentInternal::ResolvePreviousMoveBoundaryTimeMs(
				MoveBoundaryTimesMs,
				PlaybackState.CurrentTimeMs
			);
			if (NewTimeMs != PlaybackState.CurrentTimeMs)
			{
				PlaybackState.CurrentTimeMs = NewTimeMs;
				JumpEvent.JumpKind = EHyperTwistViewerJumpKind::StepBack;
				JumpEvent.PreviousTimeMs = PreviousTimeMs;
				JumpEvent.NewTimeMs = NewTimeMs;
				JumpEvent.PreviousMoveIndex = PreviousMoveIndex;
				JumpEvent.NewMoveIndex = ResolveJumpMoveIndex(NewTimeMs);
				JumpEventPtr = &JumpEvent;
			}
		}
		break;

	case EHyperTwistViewerControlAction::SeekToIndex:
	{
		if (bHasMoves)
		{
			const int32 NewTimeMs = ResolveSeekTargetTimeMs(Request.SeekTargetIndex);
			if (NewTimeMs > PlaybackState.CurrentTimeMs)
			{
				PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::Forward;
			}
			else if (NewTimeMs < PlaybackState.CurrentTimeMs)
			{
				PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::Backward;
			}

			PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Stepping;
			PlaybackState.CurrentTimeMs = NewTimeMs;

			JumpEvent.JumpKind = EHyperTwistViewerJumpKind::Seek;
			JumpEvent.PreviousTimeMs = PreviousTimeMs;
			JumpEvent.NewTimeMs = NewTimeMs;
			JumpEvent.PreviousMoveIndex = PreviousMoveIndex;
			JumpEvent.NewMoveIndex = ResolveJumpMoveIndex(NewTimeMs);
			JumpEventPtr = &JumpEvent;
		}
		break;
	}

	default:
		break;
	}

	RebuildAndBroadcast(PreviousPlaybackState, PreviousPositionSnapshot, PreviousDirectionState, JumpEventPtr);
}

bool UHyperTwistPuzzleViewerComponent::AdvancePlaybackByMs(const int32 DeltaTimeMs)
{
	if (DeltaTimeMs <= 0 || PlaybackState.Mode != EHyperTwistViewerPlaybackMode::Playing || MoveList.Num() == 0)
	{
		return false;
	}

	const FHyperTwistViewerPlaybackState PreviousPlaybackState = PlaybackState;
	const FHyperTwistViewerPositionSnapshot PreviousPositionSnapshot = PositionSnapshot;
	const FHyperTwistViewerDirectionState PreviousDirectionState = DirectionState;

	PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::Forward;
	const int32 PreviousTimeMs = PlaybackState.CurrentTimeMs;
	PlaybackState.CurrentTimeMs = FMath::Clamp(
		PlaybackState.CurrentTimeMs + DeltaTimeMs,
		0,
		PlaybackState.TotalTimeMs
	);

	if (PlaybackState.TotalTimeMs > 0 && PlaybackState.CurrentTimeMs >= PlaybackState.TotalTimeMs)
	{
		PlaybackState.Mode = EHyperTwistViewerPlaybackMode::Paused;
	}

	RebuildAndBroadcast(PreviousPlaybackState, PreviousPositionSnapshot, PreviousDirectionState);
	return PlaybackState.CurrentTimeMs != PreviousTimeMs;
}

bool UHyperTwistPuzzleViewerComponent::TryBuildClassicCubingAdapterProjection(
	FHyperTwistViewerClassicCubingAdapterProjection& OutProjection
) const
{
	OutProjection = FHyperTwistViewerClassicCubingAdapterProjection();

	if (!SceneContext.IsStructurallyValid())
	{
		return false;
	}

	const FHyperTwistTrainingClassicCubingReferenceBundle Bundle =
		UHyperTwistTrainingClassicCubingLibrary::BuildBundledClassicCubingReferenceBundle();

	FHyperTwistTrainingClassicCubingSemanticContract SemanticContract;
	FHyperTwistTrainingClassicCubingGeometryContract GeometryContract;
	FHyperTwistTrainingClassicCubingViewerAdapterContract ViewerAdapterContract;

	if (!UHyperTwistTrainingClassicCubingLibrary::TryGetSemanticContractById(
			Bundle,
			Bundle.PrimarySemanticContractId,
			SemanticContract
		)
		|| !UHyperTwistTrainingClassicCubingLibrary::TryGetGeometryContractById(
			Bundle,
			Bundle.PrimaryGeometryContractId,
			GeometryContract
		)
		|| !UHyperTwistTrainingClassicCubingLibrary::TryGetViewerAdapterContractById(
			Bundle,
			Bundle.PrimaryViewerAdapterContractId,
			ViewerAdapterContract
		))
	{
		return false;
	}

	OutProjection.ProjectionId = FString::Printf(TEXT("classic-cubing/%s"), *SceneContext.SceneContextId);
	OutProjection.SemanticContractId = SemanticContract.ContractId;
	OutProjection.GeometryContractId = GeometryContract.ContractId;
	OutProjection.ViewerAdapterContractId = ViewerAdapterContract.ContractId;
	OutProjection.SceneContextId = SceneContext.SceneContextId;
	OutProjection.RenderStateProfile = SceneContext.RenderStateProfile;
	OutProjection.BaseState = SceneContext.PuzzleState;
	OutProjection.CurrentDisplayMoveIndex = PlaybackState.CurrentMoveIndex;
	OutProjection.TotalTimelineTimeMs = PlaybackState.TotalTimeMs;
	OutProjection.PositionSnapshot = PositionSnapshot;
	OutProjection.ViewerSurfaceTags = ViewerAdapterContract.ViewerSurfaceTags;
	OutProjection.LifecycleHooks = ViewerAdapterContract.LifecycleHooks;

	const TArray<int32> ProjectionBoundaryTimesMs =
		HyperTwistPuzzleViewerComponentInternal::ResolveProjectionBoundaryTimesMs(MoveList, MoveBoundaryTimesMs);
	OutProjection.ProjectedMoves.Reserve(MoveList.Num());

	for (int32 MoveIndex = 0; MoveIndex < MoveList.Num(); ++MoveIndex)
	{
		FHyperTwistViewerAdapterProjectionStep Step;
		Step.MoveIndex = MoveIndex;
		Step.Transform = MoveList[MoveIndex].Transform;
		Step.DisplayLabel = MoveList[MoveIndex].DisplayLabel;
		Step.StartsAtMs = HyperTwistPuzzleViewerComponentInternal::ResolveProjectionMoveStartTimeMs(
			ProjectionBoundaryTimesMs,
			MoveIndex
		);
		Step.CompletesAtMs = ProjectionBoundaryTimesMs.IsValidIndex(MoveIndex)
			? ProjectionBoundaryTimesMs[MoveIndex]
			: Step.StartsAtMs;
		OutProjection.ProjectedMoves.Add(Step);
	}

	return OutProjection.IsStructurallyValid();
}

bool UHyperTwistPuzzleViewerComponent::TryBootstrapEmbeddedClassicCubingHost(
	const FHyperTwistViewerEmbeddedHostBootstrapRequest& Request,
	FHyperTwistViewerEmbeddedHostBootstrapState& OutBootstrapState
)
{
	OutBootstrapState = FHyperTwistViewerEmbeddedHostBootstrapState();
	VisualizationShellState = FHyperTwistViewerVisualizationShellState();

	if (!Request.IsStructurallyValid())
	{
		HostBootstrapState = FHyperTwistViewerEmbeddedHostBootstrapState();
		return false;
	}

	FHyperTwistViewerClassicCubingAdapterProjection Projection;
	if (!TryBuildClassicCubingAdapterProjection(Projection))
	{
		HostBootstrapState = FHyperTwistViewerEmbeddedHostBootstrapState();
		return false;
	}

	const FHyperTwistTrainingViewerReferenceBundle BrowserViewerBundle =
		UHyperTwistTrainingViewerLibrary::BuildBundledBrowserViewerReferenceBundle();
	const FHyperTwistTrainingSpatialReferenceBundle SpatialBundle =
		UHyperTwistTrainingSpatialLibrary::BuildBundledBrowserSpatialReferenceBundle();

	FHyperTwistTrainingViewerEmbedContract EmbedContract;
	FHyperTwistTrainingSpatialSceneContract SceneContract;

	if (!UHyperTwistTrainingViewerLibrary::TryGetEmbedContractById(
			BrowserViewerBundle,
			BrowserViewerBundle.PrimaryEmbedContractId,
			EmbedContract
		)
		|| !UHyperTwistTrainingSpatialLibrary::TryGetSceneContractById(
			SpatialBundle,
			SpatialBundle.PrimarySceneContractId,
			SceneContract
		))
	{
		HostBootstrapState = FHyperTwistViewerEmbeddedHostBootstrapState();
		return false;
	}

	HostBootstrapState = FHyperTwistViewerEmbeddedHostBootstrapState();
	HostBootstrapState.BootstrapId = FString::Printf(TEXT("%s/%s"), *Request.HostInstanceId, *Projection.SceneContextId);
	HostBootstrapState.HostInstanceId = Request.HostInstanceId;
	HostBootstrapState.AdapterProjectionId = Projection.ProjectionId;
	HostBootstrapState.ViewerAdapterContractId = Projection.ViewerAdapterContractId;
	HostBootstrapState.BrowserEmbedContractId = EmbedContract.ContractId;
	HostBootstrapState.SpatialSceneContractId = SceneContract.ContractId;
	HostBootstrapState.SceneContextId = Projection.SceneContextId;
	HostBootstrapState.RenderStateProfile = Projection.RenderStateProfile;
	HostBootstrapState.BindingMode = Request.BindingMode;
	HostBootstrapState.bReadyForEmbed = true;
	HostBootstrapState.bTimelineSurfaceBound = Request.bBindTimelineSurface;
	HostBootstrapState.bScrubberSurfaceBound = Request.bBindScrubberSurface;
	HostBootstrapState.bStatusSurfaceBound = Request.bBindStatusSurface;
	HostBootstrapState.bPointerInputEnabled = Request.bAllowPointerInput;
	HostBootstrapState.AdapterLifecycleHooks = Projection.LifecycleHooks;
	HostBootstrapState.BrowserLifecycleHookIds = EmbedContract.LifecycleHookIds;
	HostBootstrapState.SpatialLifecycleStages = SceneContract.LifecycleStages;
	HostBootstrapState.ViewerSurfaceTags = Projection.ViewerSurfaceTags;
	HostBootstrapState.SpatialEventSurfaceTags = SceneContract.EventSurfaceTags;

	if (!HostBootstrapState.IsStructurallyValid())
	{
		HostBootstrapState = FHyperTwistViewerEmbeddedHostBootstrapState();
		return false;
	}

	OutBootstrapState = HostBootstrapState;
	return true;
}

bool UHyperTwistPuzzleViewerComponent::TryBuildVisualizationShell(
	const FHyperTwistViewerVisualizationShellRequest& Request,
	FHyperTwistViewerVisualizationShellState& OutVisualizationShellState
)
{
	OutVisualizationShellState = FHyperTwistViewerVisualizationShellState();

	if (!Request.IsStructurallyValid())
	{
		VisualizationShellState = FHyperTwistViewerVisualizationShellState();
		return false;
	}

	FHyperTwistViewerEmbeddedHostBootstrapState BootstrapState;
	if (!TryBootstrapEmbeddedClassicCubingHost(Request.HostBootstrapRequest, BootstrapState))
	{
		VisualizationShellState = FHyperTwistViewerVisualizationShellState();
		return false;
	}

	const FHyperTwistTrainingViewerReferenceBundle BrowserViewerBundle =
		UHyperTwistTrainingViewerLibrary::BuildBundledBrowserViewerReferenceBundle();
	const FHyperTwistTrainingSpatialReferenceBundle SpatialBundle =
		UHyperTwistTrainingSpatialLibrary::BuildBundledBrowserSpatialReferenceBundle();

	FHyperTwistTrainingViewerEmbedContract EmbedContract;
	FHyperTwistTrainingSpatialUiContract UiContract;
	FHyperTwistTrainingSpatialEffectBoundary EffectBoundary;
	FHyperTwistTrainingSpatialStateMotionContract StateMotionContract;

	if (!UHyperTwistTrainingViewerLibrary::TryGetEmbedContractById(
			BrowserViewerBundle,
			BrowserViewerBundle.PrimaryEmbedContractId,
			EmbedContract
		)
		|| !UHyperTwistTrainingSpatialLibrary::TryGetSpatialUiContractById(
			SpatialBundle,
			SpatialBundle.PrimarySpatialUiContractId,
			UiContract
		)
		|| !UHyperTwistTrainingSpatialLibrary::TryGetEffectBoundaryById(
			SpatialBundle,
			SpatialBundle.PrimaryEffectBoundaryId,
			EffectBoundary
		)
		|| !UHyperTwistTrainingSpatialLibrary::TryGetStateMotionContractById(
			SpatialBundle,
			SpatialBundle.PrimaryStateMotionContractId,
			StateMotionContract
		))
	{
		VisualizationShellState = FHyperTwistViewerVisualizationShellState();
		return false;
	}

	VisualizationShellState = FHyperTwistViewerVisualizationShellState();
	VisualizationShellState.VisualizationId = FString::Printf(
		TEXT("visualization/%s/%s"),
		*Request.HostBootstrapRequest.HostInstanceId,
		*BootstrapState.SceneContextId
	);
	VisualizationShellState.BootstrapId = BootstrapState.BootstrapId;
	VisualizationShellState.AdapterProjectionId = BootstrapState.AdapterProjectionId;
	VisualizationShellState.ViewerAdapterContractId = BootstrapState.ViewerAdapterContractId;
	VisualizationShellState.BrowserEmbedContractId = EmbedContract.ContractId;
	VisualizationShellState.SpatialSceneContractId = BootstrapState.SpatialSceneContractId;
	VisualizationShellState.SpatialUiContractId = UiContract.ContractId;
	VisualizationShellState.SpatialEffectBoundaryId = EffectBoundary.BoundaryId;
	VisualizationShellState.SpatialStateMotionContractId = StateMotionContract.ContractId;
	VisualizationShellState.SceneContextId = BootstrapState.SceneContextId;
	VisualizationShellState.RenderStateProfile = BootstrapState.RenderStateProfile;
	VisualizationShellState.PreferredMode =
		HyperTwistPuzzleViewerComponentInternal::ResolvePreferredVisualizationMode(Request.VisualizationPreference);

	if (VisualizationShellState.PreferredMode == EHyperTwistViewerVisualizationMode::ThreeD)
	{
		const bool bSupportsThreeD = HyperTwistPuzzleViewerComponentInternal::SupportsThreeDVisualization(
			BootstrapState,
			EmbedContract,
			UiContract,
			EffectBoundary,
			StateMotionContract
		);

		if (bSupportsThreeD)
		{
			VisualizationShellState.ActiveMode = EHyperTwistViewerVisualizationMode::ThreeD;
			VisualizationShellState.bUsingFallback = false;
			VisualizationShellState.FallbackReason = EHyperTwistViewerVisualizationFallbackReason::None;
			VisualizationShellState.bUsesSpatialRendering = true;
		}
		else if (Request.bAllow2DFallback)
		{
			VisualizationShellState.ActiveMode = EHyperTwistViewerVisualizationMode::TwoD;
			VisualizationShellState.bUsingFallback = true;
			VisualizationShellState.FallbackReason =
				HyperTwistPuzzleViewerComponentInternal::ResolveThreeDFallbackReason(
					BootstrapState,
					EmbedContract,
					UiContract,
					EffectBoundary,
					StateMotionContract
				);
			VisualizationShellState.bUsesSpatialRendering = false;
		}
		else
		{
			VisualizationShellState = FHyperTwistViewerVisualizationShellState();
			return false;
		}
	}
	else
	{
		VisualizationShellState.ActiveMode = EHyperTwistViewerVisualizationMode::TwoD;
		VisualizationShellState.bUsingFallback = false;
		VisualizationShellState.FallbackReason = EHyperTwistViewerVisualizationFallbackReason::None;
		VisualizationShellState.bUsesSpatialRendering = false;
	}

	VisualizationShellState.PresentationMode = HyperTwistPuzzleViewerComponentInternal::ResolvePresentationMode(
		Request.InitialPresentationMode,
		Request.bAllowExpandedView,
		Request.bAllowFullscreen
	);
	VisualizationShellState.bReadyForVisualization = true;
	VisualizationShellState.bSupportsExpandedView = Request.bAllowExpandedView;
	VisualizationShellState.bSupportsFullscreen = Request.bAllowFullscreen;
	VisualizationShellState.ViewerBehaviorTags = EmbedContract.ShippableBehaviorTags;
	VisualizationShellState.ViewerAnnotationSurfaceTags = EmbedContract.AnnotationSurfaceTags;
	VisualizationShellState.SpatialUiComponentFamilies = UiContract.ComponentFamilies;
	VisualizationShellState.SpatialUiStateSurfaceTags = UiContract.StateSurfaceTags;
	VisualizationShellState.SpatialSceneEventSurfaceTags = BootstrapState.SpatialEventSurfaceTags;
	VisualizationShellState.SpatialEffectFamilies = EffectBoundary.EffectFamilies;
	VisualizationShellState.MotionKinds = StateMotionContract.MotionKinds;

	if (!VisualizationShellState.IsStructurallyValid())
	{
		VisualizationShellState = FHyperTwistViewerVisualizationShellState();
		return false;
	}

	OutVisualizationShellState = VisualizationShellState;
	return true;
}

bool UHyperTwistPuzzleViewerComponent::TrySetVisualizationPresentationMode(
	const EHyperTwistViewerPresentationMode NewMode,
	FHyperTwistViewerVisualizationShellState& OutVisualizationShellState
)
{
	OutVisualizationShellState = FHyperTwistViewerVisualizationShellState();

	if (!VisualizationShellState.IsStructurallyValid()
		|| !VisualizationShellState.bReadyForVisualization
		|| !HyperTwistPuzzleViewerComponentInternal::CanApplyPresentationMode(VisualizationShellState, NewMode))
	{
		return false;
	}

	VisualizationShellState.PresentationMode = NewMode;
	OutVisualizationShellState = VisualizationShellState;
	return true;
}

// ---------------------------------------------------------------------------
// Readout
// ---------------------------------------------------------------------------

FHyperTwistViewerMoveEntry UHyperTwistPuzzleViewerComponent::GetCurrentMoveEntry() const
{
	if (MoveList.IsValidIndex(PlaybackState.CurrentMoveIndex))
	{
		return MoveList[PlaybackState.CurrentMoveIndex];
	}
	return FHyperTwistViewerMoveEntry{};
}

// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------

void UHyperTwistPuzzleViewerComponent::RebuildTimelineCache()
{
	MoveBoundaryTimesMs = HyperTwistPuzzleViewerComponentInternal::BuildMoveBoundaryTimesMs(MoveList);
}

int32 UHyperTwistPuzzleViewerComponent::ResolveSeekTargetTimeMs(const int32 TargetIndex) const
{
	if (MoveBoundaryTimesMs.Num() == 0)
	{
		return 0;
	}

	if (TargetIndex <= 0)
	{
		return 0;
	}

	if (TargetIndex >= MoveBoundaryTimesMs.Num() - 1)
	{
		return PlaybackState.TotalTimeMs > 0 ? PlaybackState.TotalTimeMs : MoveBoundaryTimesMs.Last();
	}

	return MoveBoundaryTimesMs[TargetIndex];
}

void UHyperTwistPuzzleViewerComponent::RebuildAndBroadcast(
	const FHyperTwistViewerPlaybackState& PreviousPlaybackState,
	const FHyperTwistViewerPositionSnapshot& PreviousPositionSnapshot,
	const FHyperTwistViewerDirectionState& PreviousDirectionState,
	const FHyperTwistViewerJumpEvent* JumpEvent
)
{
	const bool bHasMoves = MoveList.Num() > 0;

	RebuildTimelineCache();

	PlaybackState.TotalMoveCount = MoveList.Num();
	PlaybackState.bSceneContextLoaded = SceneContext.IsStructurallyValid();
	PlaybackState.bReplayLoaded = bReplayPacketLoaded;
	PlaybackState.ReplayId = bReplayPacketLoaded ? ReplayPacket.ReplayId : FString();
	PlaybackState.PuzzleId = bReplayPacketLoaded
		? ReplayPacket.PuzzleDefinition.PuzzleId
		: (SceneContext.IsStructurallyValid() ? SceneContext.PuzzleState.Definition.PuzzleId : FString());
	PlaybackState.ReplayResult = bReplayPacketLoaded ? ReplaySummary.Result : FString();
	PlaybackState.TotalTimeMs = HyperTwistPuzzleViewerComponentInternal::ResolvePlaybackTerminalTimeMs(
		MoveBoundaryTimesMs,
		ReplaySummary,
		bReplayPacketLoaded
	);
	PlaybackState.InspectionTimeMs = bReplayPacketLoaded ? FMath::Max(0, ReplaySummary.InspectionTimeMs) : 0;
	PlaybackState.CurrentTimeMs = FMath::Clamp(PlaybackState.CurrentTimeMs, 0, PlaybackState.TotalTimeMs);

	if (!bHasMoves)
	{
		PlaybackState.CurrentMoveIndex = INDEX_NONE;
		PlaybackState.Direction = EHyperTwistViewerPlaybackDirection::None;
		PlaybackState.bAtStart = true;
		PlaybackState.bAtEnd = true;
		PlaybackState.CurrentTimeMs = 0;

		if (PlaybackState.bReplayLoaded)
		{
			PlaybackState.StatusLabel = FString::Printf(
				TEXT("%s - 0 moves - %s"),
				*UHyperTwistViewerLibrary::GetPlaybackModeLabel(PlaybackState.Mode),
				*UHyperTwistViewerLibrary::GetTimelineLabel(PlaybackState)
			);
			if (!PlaybackState.ReplayResult.IsEmpty())
			{
				PlaybackState.StatusLabel += FString::Printf(TEXT(" - %s"), *PlaybackState.ReplayResult);
			}
		}
		else
		{
			PlaybackState.StatusLabel = PlaybackState.bSceneContextLoaded
				? TEXT("Scene loaded - no moves")
				: TEXT("No scene loaded");
		}
	}
	else
	{
		DirectionState = HyperTwistPuzzleViewerComponentInternal::BuildDirectionState(
			PlaybackState.Mode,
			PlaybackState.Direction
		);
		PositionSnapshot = HyperTwistPuzzleViewerComponentInternal::BuildPositionSnapshot(
			MoveList,
			MoveBoundaryTimesMs,
			PlaybackState.CurrentTimeMs,
			PlaybackState.TotalTimeMs,
			PlaybackState.Direction
		);
		PlaybackState.CurrentMoveIndex = HyperTwistPuzzleViewerComponentInternal::FindDisplayMoveIndexForTime(
			MoveBoundaryTimesMs,
			PlaybackState.CurrentTimeMs,
			PlaybackState.TotalTimeMs
		);
		PlaybackState.bAtStart = PositionSnapshot.bAtStart;
		PlaybackState.bAtEnd = PositionSnapshot.bAtEnd;
		PlaybackState.StatusLabel = UHyperTwistViewerLibrary::GetControlBarReadout(PlaybackState, GetCurrentMoveEntry());
		if (PlaybackState.bReplayLoaded && !PlaybackState.ReplayResult.IsEmpty())
		{
			PlaybackState.StatusLabel += FString::Printf(TEXT(" - %s"), *PlaybackState.ReplayResult);
		}
	}

	if (!bHasMoves)
	{
		DirectionState = HyperTwistPuzzleViewerComponentInternal::BuildDirectionState(
			PlaybackState.Mode,
			PlaybackState.Direction
		);
		PositionSnapshot = HyperTwistPuzzleViewerComponentInternal::BuildPositionSnapshot(
			MoveList,
			MoveBoundaryTimesMs,
			PlaybackState.CurrentTimeMs,
			PlaybackState.TotalTimeMs,
			PlaybackState.Direction
		);
	}

	OnPlaybackStateChanged.Broadcast(PlaybackState);

	if (!HyperTwistPuzzleViewerComponentInternal::ArePositionSnapshotsEquivalent(PreviousPositionSnapshot, PositionSnapshot))
	{
		OnPositionSnapshotUpdated.Broadcast(PositionSnapshot);
		OnPositionSnapshotUpdatedNative.Broadcast(PositionSnapshot);
	}

	if (!HyperTwistPuzzleViewerComponentInternal::AreDirectionStatesEquivalent(PreviousDirectionState, DirectionState))
	{
		OnDirectionStateChanged.Broadcast(DirectionState);
		OnDirectionStateChangedNative.Broadcast(DirectionState);
	}

	if (JumpEvent != nullptr && JumpEvent->IsStructurallyValid())
	{
		OnPlaybackJumped.Broadcast(*JumpEvent);
		OnPlaybackJumpedNative.Broadcast(*JumpEvent);
	}
}
