#include "HyperTwistTraining/HyperTwistTrainingAnalyticsLibrary.h"

namespace HyperTwistTrainingAnalyticsLibraryInternal
{
	FHyperTwistTrainingSourceAttribution MakeEchartsAttribution(
		const FString& TrackId,
		const FString& Notes
	)
	{
		FHyperTwistTrainingSourceAttribution Attribution;
		Attribution.SourceMode = EHyperTwistTrainingSourceMode::DirectDonor;
		Attribution.SourceRepo = TEXT("apache/echarts");
		Attribution.SourceLicense = TEXT("Apache-2.0");
		Attribution.SourceTrackId = TrackId;
		Attribution.SourceNotes = Notes;
		return Attribution;
	}

	FString ResolveBundleUserId(
		const FHyperTwistTrainingSessionSummary& SessionSummary,
		const FHyperTwistTrainingDeckHistorySummary& DeckHistorySummary,
		const FHyperTwistTrainingTimingTrendSummary& TimingTrendSummary,
		const FHyperTwistTrainingPersonalBestSummary& PersonalBestSummary,
		const FHyperTwistTrainingDeckScopedStats& DeckScopedStats,
		const FHyperTwistTrainingCaseScopedStats& CaseScopedStats,
		const FHyperTwistTrainingLiveTimerState& LiveTimerState
	)
	{
		if (!SessionSummary.UserId.IsEmpty())
		{
			return SessionSummary.UserId;
		}
		if (!DeckHistorySummary.UserId.IsEmpty())
		{
			return DeckHistorySummary.UserId;
		}
		if (!TimingTrendSummary.UserId.IsEmpty())
		{
			return TimingTrendSummary.UserId;
		}
		if (!PersonalBestSummary.UserId.IsEmpty())
		{
			return PersonalBestSummary.UserId;
		}
		if (!DeckScopedStats.UserId.IsEmpty())
		{
			return DeckScopedStats.UserId;
		}
		if (!CaseScopedStats.UserId.IsEmpty())
		{
			return CaseScopedStats.UserId;
		}
		return LiveTimerState.UserId;
	}

	FString ResolveBundleDeckId(
		const FHyperTwistTrainingSessionSummary& SessionSummary,
		const FHyperTwistTrainingDeckHistorySummary& DeckHistorySummary,
		const FHyperTwistTrainingTimingTrendSummary& TimingTrendSummary,
		const FHyperTwistTrainingPersonalBestSummary& PersonalBestSummary,
		const FHyperTwistTrainingDeckScopedStats& DeckScopedStats,
		const FHyperTwistTrainingCaseScopedStats& CaseScopedStats,
		const FHyperTwistTrainingLiveTimerState& LiveTimerState
	)
	{
		if (!SessionSummary.DeckId.IsEmpty())
		{
			return SessionSummary.DeckId;
		}
		if (!DeckHistorySummary.DeckId.IsEmpty())
		{
			return DeckHistorySummary.DeckId;
		}
		if (!TimingTrendSummary.DeckId.IsEmpty())
		{
			return TimingTrendSummary.DeckId;
		}
		if (!PersonalBestSummary.DeckId.IsEmpty())
		{
			return PersonalBestSummary.DeckId;
		}
		if (!DeckScopedStats.DeckId.IsEmpty())
		{
			return DeckScopedStats.DeckId;
		}
		if (!CaseScopedStats.DeckId.IsEmpty())
		{
			return CaseScopedStats.DeckId;
		}
		return LiveTimerState.DeckId;
	}

	FString ResolveBundleCaseId(
		const FHyperTwistTrainingSessionSummary& SessionSummary,
		const FHyperTwistTrainingCaseScopedStats& CaseScopedStats,
		const FHyperTwistTrainingLiveTimerState& LiveTimerState
	)
	{
		if (!CaseScopedStats.CaseId.IsEmpty())
		{
			return CaseScopedStats.CaseId;
		}
		if (!LiveTimerState.CaseId.IsEmpty())
		{
			return LiveTimerState.CaseId;
		}
		return SessionSummary.LastCaseId;
	}

	FString ResolveReferenceUtc(
		const FHyperTwistTrainingSessionSummary& SessionSummary,
		const FHyperTwistTrainingDeckHistorySummary& DeckHistorySummary,
		const FHyperTwistTrainingTimingTrendSummary& TimingTrendSummary,
		const FHyperTwistTrainingPersonalBestSummary& PersonalBestSummary,
		const FHyperTwistTrainingDeckScopedStats& DeckScopedStats,
		const FHyperTwistTrainingCaseScopedStats& CaseScopedStats,
		const FHyperTwistTrainingLiveTimerState& LiveTimerState
	)
	{
		if (!TimingTrendSummary.ReferenceUtc.IsEmpty())
		{
			return TimingTrendSummary.ReferenceUtc;
		}
		if (!PersonalBestSummary.ReferenceUtc.IsEmpty())
		{
			return PersonalBestSummary.ReferenceUtc;
		}
		if (!DeckScopedStats.ReferenceUtc.IsEmpty())
		{
			return DeckScopedStats.ReferenceUtc;
		}
		if (!CaseScopedStats.ReferenceUtc.IsEmpty())
		{
			return CaseScopedStats.ReferenceUtc;
		}
		if (!LiveTimerState.LastUpdatedAtUtc.IsEmpty())
		{
			return LiveTimerState.LastUpdatedAtUtc;
		}
		if (!SessionSummary.LastCompletedAtUtc.IsEmpty())
		{
			return SessionSummary.LastCompletedAtUtc;
		}
		if (!DeckHistorySummary.LastCompletedAtUtc.IsEmpty())
		{
			return DeckHistorySummary.LastCompletedAtUtc;
		}
		return FDateTime::UtcNow().ToIso8601();
	}

	FString FormatDurationMs(const int32 DurationMs)
	{
		const int32 ClampedMs = FMath::Max(DurationMs, 0);
		if (ClampedMs == 0)
		{
			return TEXT("n/a");
		}

		const int32 TotalSeconds = ClampedMs / 1000;
		const int32 Minutes = TotalSeconds / 60;
		const int32 Seconds = TotalSeconds % 60;
		const int32 Milliseconds = ClampedMs % 1000;

		if (Minutes > 0)
		{
			return FString::Printf(TEXT("%d:%02d.%03d"), Minutes, Seconds, Milliseconds);
		}

		return FString::Printf(TEXT("%d.%03ds"), Seconds, Milliseconds);
	}

	FString FormatMetricValue(
		const float NumericValue,
		const EHyperTwistTrainingAnalyticsMetricKind MetricKind
	)
	{
		switch (MetricKind)
		{
		case EHyperTwistTrainingAnalyticsMetricKind::DurationMs:
			return FormatDurationMs(FMath::RoundToInt(NumericValue));
		case EHyperTwistTrainingAnalyticsMetricKind::Percentage:
			return FString::Printf(TEXT("%.1f%%"), NumericValue);
		case EHyperTwistTrainingAnalyticsMetricKind::Ratio:
		case EHyperTwistTrainingAnalyticsMetricKind::Score:
			return FString::Printf(TEXT("%.2f"), NumericValue);
		case EHyperTwistTrainingAnalyticsMetricKind::Count:
		default:
			return FString::FromInt(FMath::RoundToInt(NumericValue));
		}
	}

	FHyperTwistTrainingAnalyticsDataPoint MakeDataPoint(
		const FString& PointId,
		const FString& Label,
		const float NumericValue,
		const EHyperTwistTrainingAnalyticsMetricKind MetricKind,
		const FString& SecondaryLabel,
		const bool bHighlighted,
		const TArray<FString>& Tags
	)
	{
		FHyperTwistTrainingAnalyticsDataPoint Point;
		Point.PointId = PointId;
		Point.Label = Label;
		Point.NumericValue = NumericValue;
		Point.MetricKind = MetricKind;
		Point.SecondaryLabel = SecondaryLabel.IsEmpty() ? FormatMetricValue(NumericValue, MetricKind) : SecondaryLabel;
		Point.bHighlighted = bHighlighted;
		Point.Tags = Tags;
		return Point;
	}

	FHyperTwistTrainingAnalyticsSeries MakeSeries(
		const FString& SeriesId,
		const FString& Label,
		const FString& Summary,
		const EHyperTwistTrainingAnalyticsMetricKind MetricKind,
		const TArray<FHyperTwistTrainingAnalyticsDataPoint>& DataPoints
	)
	{
		FHyperTwistTrainingAnalyticsSeries Series;
		Series.SeriesId = SeriesId;
		Series.Label = Label;
		Series.Summary = Summary;
		Series.MetricKind = MetricKind;
		Series.DataPoints = DataPoints;
		return Series;
	}

	FHyperTwistTrainingAnalyticsExportSurface MakeExportSurface(
		const FString& FileStem,
		const TArray<FString>& AccessibilitySummaryLines,
		const TArray<FString>& RecommendedInteractions,
		const TArray<FString>& OptionalSidecars
	)
	{
		FHyperTwistTrainingAnalyticsExportSurface Surface;
		Surface.FileStem = FileStem;
		Surface.ColumnOrder =
		{
			TEXT("Series"),
			TEXT("Point"),
			TEXT("Value"),
			TEXT("SecondaryLabel"),
			TEXT("Metric"),
			TEXT("Tags")
		};
		Surface.AccessibilitySummaryLines = AccessibilitySummaryLines;
		Surface.RecommendedInteractions = RecommendedInteractions;
		Surface.OptionalSidecars = OptionalSidecars;
		return Surface;
	}

	void AddReport(
		FHyperTwistTrainingAnalyticsReportBundle& Bundle,
		const FHyperTwistTrainingAnalyticsReportDefinition& Report
	)
	{
		if (!Report.IsStructurallyValid())
		{
			return;
		}

		if (Bundle.PrimaryReportId.IsEmpty())
		{
			Bundle.PrimaryReportId = Report.ReportId;
		}

		Bundle.Reports.Add(Report);
	}

	FString ResolveSessionKindLabel(const EHyperTwistTrainingSessionKind SessionKind)
	{
		const UEnum* SessionKindEnum = StaticEnum<EHyperTwistTrainingSessionKind>();
		return SessionKindEnum != nullptr
			? SessionKindEnum->GetDisplayNameTextByValue(static_cast<int64>(SessionKind)).ToString()
			: TEXT("Unknown");
	}

	bool SessionUsesLiveCoachOverlay(const FHyperTwistTrainingSession& Session)
	{
		return Session.SessionState == EHyperTwistTrainingSessionState::Ready
			|| Session.SessionState == EHyperTwistTrainingSessionState::Active
			|| Session.SessionState == EHyperTwistTrainingSessionState::Paused
			|| Session.SessionState == EHyperTwistTrainingSessionState::Reviewing;
	}

	EHyperTwistTrainingSessionKind ResolveSessionKind(
		const FHyperTwistTrainingSession& Session,
		const FHyperTwistTrainingTemplateLaunchProvenance& TemplateLaunchProvenance,
		const FHyperTwistTrainingCoachPanelState& CoachPanelState,
		const FHyperTwistTrainingMethodDrillRunState& MethodDrillRunState
	)
	{
		if (!Session.IsStructurallyValid())
		{
			return EHyperTwistTrainingSessionKind::None;
		}

		if (TemplateLaunchProvenance.IsStructurallyValid())
		{
			const bool bMethodDrillFollowUp =
				MethodDrillRunState.IsStructurallyValid()
				&& !TemplateLaunchProvenance.MethodSegmentId.IsEmpty()
				&& TemplateLaunchProvenance.MethodSegmentId
					== MethodDrillRunState.DrillDefinition.MethodDescriptor.SegmentId;
			if (bMethodDrillFollowUp)
			{
				return EHyperTwistTrainingSessionKind::MethodDrillFollowUp;
			}
			if (!TemplateLaunchProvenance.SourceReviewPlanId.IsEmpty()
				|| TemplateLaunchProvenance.bPreferReviewRun
				|| TemplateLaunchProvenance.bCoachReview)
			{
				return EHyperTwistTrainingSessionKind::ReviewRun;
			}

			return EHyperTwistTrainingSessionKind::TemplateLaunch;
		}

		if (SessionUsesLiveCoachOverlay(Session))
		{
			if (!CoachPanelState.ActiveQueueEntryId.IsEmpty())
			{
				return EHyperTwistTrainingSessionKind::CoachQueue;
			}
			if (CoachPanelState.PreferredGuidanceLane == EHyperTwistTrainingCoachGuidanceLane::FollowUp)
			{
				return EHyperTwistTrainingSessionKind::CoachFollowUp;
			}
			if (CoachPanelState.PreferredGuidanceLane == EHyperTwistTrainingCoachGuidanceLane::Recommended)
			{
				return EHyperTwistTrainingSessionKind::CoachRecommended;
			}
		}

		if (Session.Mode == EHyperTwistTrainingDeliveryMode::CoachReviewed)
		{
			return EHyperTwistTrainingSessionKind::ReviewRun;
		}

		return EHyperTwistTrainingSessionKind::DirectRun;
	}

	FString BuildGameSessionId(
		const EHyperTwistTrainingSessionKind SessionKind,
		const FString& TrainingSessionId
	)
	{
		if (TrainingSessionId.IsEmpty())
		{
			return TEXT("");
		}

		switch (SessionKind)
		{
		case EHyperTwistTrainingSessionKind::ReviewRun:
			return FString::Printf(TEXT("game-session/review/%s"), *TrainingSessionId);
		case EHyperTwistTrainingSessionKind::CoachRecommended:
			return FString::Printf(TEXT("game-session/coach-recommended/%s"), *TrainingSessionId);
		case EHyperTwistTrainingSessionKind::CoachFollowUp:
			return FString::Printf(TEXT("game-session/coach-follow-up/%s"), *TrainingSessionId);
		case EHyperTwistTrainingSessionKind::CoachQueue:
			return FString::Printf(TEXT("game-session/coach-queue/%s"), *TrainingSessionId);
		case EHyperTwistTrainingSessionKind::TemplateLaunch:
			return FString::Printf(TEXT("game-session/template/%s"), *TrainingSessionId);
		case EHyperTwistTrainingSessionKind::MethodDrillFollowUp:
			return FString::Printf(TEXT("game-session/method-drill-follow-up/%s"), *TrainingSessionId);
		case EHyperTwistTrainingSessionKind::DirectRun:
			return FString::Printf(TEXT("game-session/training/%s"), *TrainingSessionId);
		case EHyperTwistTrainingSessionKind::None:
		default:
			return FString();
		}
	}

	FString ResolveGameSessionSourceLabel(
		const EHyperTwistTrainingSessionKind SessionKind,
		const FHyperTwistTrainingTemplateLaunchProvenance& TemplateLaunchProvenance,
		const FHyperTwistTrainingCoachPanelState& CoachPanelState
	)
	{
		if (SessionKind == EHyperTwistTrainingSessionKind::CoachQueue)
		{
			return !CoachPanelState.ActiveQueueEntryId.IsEmpty()
				? CoachPanelState.ActiveQueueEntryId
				: CoachPanelState.NextQueueSourceLabel;
		}
		if (SessionKind == EHyperTwistTrainingSessionKind::CoachFollowUp
			|| SessionKind == EHyperTwistTrainingSessionKind::CoachRecommended)
		{
			return CoachPanelState.PreferredGuidanceSourceLabel;
		}
		if (TemplateLaunchProvenance.IsStructurallyValid())
		{
			if (!TemplateLaunchProvenance.GuidanceLabel.IsEmpty())
			{
				return TemplateLaunchProvenance.GuidanceLabel;
			}
			if (!TemplateLaunchProvenance.SourceReviewPlanId.IsEmpty())
			{
				return TemplateLaunchProvenance.SourceReviewPlanId;
			}
			return TemplateLaunchProvenance.TemplateId;
		}
		if (!CoachPanelState.PreferredLaunchBudgetSourceLabel.IsEmpty())
		{
			return CoachPanelState.PreferredLaunchBudgetSourceLabel;
		}

		return TEXT("training-session");
	}

	FHyperTwistTrainingSolveSession ResolveSolveSession(
		const FHyperTwistTrainingSession& Session,
		const FHyperTwistTrainingAttempt& SolveAttempt,
		const FHyperTwistTrainingAnalyticsReportBundle& ReportBundle
	)
	{
		FHyperTwistTrainingSolveSession SolveSession;
		if (SolveAttempt.IsStructurallyValid())
		{
			SolveSession.AttemptId = SolveAttempt.AttemptId;
			SolveSession.TrainingSessionId = SolveAttempt.TrainingSessionId;
			SolveSession.CaseId = SolveAttempt.CaseId;
			SolveSession.RecordedAtUtc = SolveAttempt.CompletedAtUtc;
			SolveSession.DeliveryMode = Session.Mode;
			SolveSession.Result = SolveAttempt.Result;
			SolveSession.TotalTimeMs = SolveAttempt.TotalTimeMs;
			SolveSession.ExecutionTimeMs = SolveAttempt.ExecutionTimeMs;
			SolveSession.RecognitionTimeMs = SolveAttempt.RecognitionTimeMs;
			SolveSession.ReplayId = SolveAttempt.ReplayId;
			SolveSession.bLiveTimerBacked = Session.Mode == EHyperTwistTrainingDeliveryMode::Timer;
			return SolveSession;
		}

		if (ReportBundle.LiveTimerState.IsStructurallyValid() && !ReportBundle.LiveTimerState.CaseId.IsEmpty())
		{
			SolveSession.TrainingSessionId = ReportBundle.LiveTimerState.TrainingSessionId;
			SolveSession.CaseId = ReportBundle.LiveTimerState.CaseId;
			SolveSession.RecordedAtUtc = ReportBundle.LiveTimerState.LastUpdatedAtUtc;
			SolveSession.DeliveryMode = Session.Mode;
			SolveSession.TotalTimeMs = ReportBundle.LiveTimerState.SolveElapsedMs;
			SolveSession.ExecutionTimeMs = ReportBundle.LiveTimerState.SolveElapsedMs;
			SolveSession.bLiveTimerBacked = true;
		}

		return SolveSession;
	}

	FString ResolvePanelId(const FString& ReportId)
	{
		if (ReportId == TEXT("analytics/session-outcome-balance"))
		{
			return TEXT("panel/training-session-overview");
		}
		if (ReportId == TEXT("analytics/deck-history-progress"))
		{
			return TEXT("panel/session-history-progress");
		}
		if (ReportId == TEXT("analytics/timing-trend-rolling-windows"))
		{
			return TEXT("panel/timing-trend");
		}
		if (ReportId == TEXT("analytics/personal-best-benchmark"))
		{
			return TEXT("panel/personal-best");
		}
		if (ReportId == TEXT("analytics/current-case-vs-deck"))
		{
			return TEXT("panel/current-case-comparison");
		}
		if (ReportId == TEXT("analytics/live-split-ladder"))
		{
			return TEXT("panel/live-split-ladder");
		}

		FString NormalizedReportId = ReportId;
		NormalizedReportId.ReplaceInline(TEXT("analytics/"), TEXT(""));
		NormalizedReportId.ReplaceInline(TEXT("_"), TEXT("-"));
		return FString::Printf(TEXT("panel/%s"), *NormalizedReportId);
	}

	FHyperTwistTrainingAnalyticsPanelContract BuildPanelContract(
		const FHyperTwistTrainingAnalyticsReportDefinition& Report,
		const FHyperTwistTrainingSessionDomainBundle& SessionDomainBundle,
		const FString& PrimaryReportId
	)
	{
		FHyperTwistTrainingAnalyticsPanelContract Contract;
		if (!Report.IsStructurallyValid())
		{
			return Contract;
		}

		Contract.PanelId = ResolvePanelId(Report.ReportId);
		Contract.ReportId = Report.ReportId;
		Contract.Title = Report.Title;
		Contract.Summary = Report.Summary;
		Contract.VisualizationKindLabel =
			StaticEnum<EHyperTwistTrainingAnalyticsVisualizationKind>()->GetDisplayNameTextByValue(
				static_cast<int64>(Report.VisualizationKind)
			).ToString();
		Contract.SessionKindLabel = SessionDomainBundle.GameSession.SessionKindLabel;
		Contract.GameSessionId = SessionDomainBundle.GameSession.GameSessionId;
		Contract.bPrimaryPanel = Report.ReportId == PrimaryReportId;
		return Contract;
	}

	FHyperTwistTrainingAnalyticsReportDefinition BuildSessionOutcomeReport(
		const FHyperTwistTrainingSessionSummary& SessionSummary,
		const FString& DeckId
	)
	{
		FHyperTwistTrainingAnalyticsReportDefinition Report;
		if (SessionSummary.TrainingSessionId.IsEmpty())
		{
			return Report;
		}

		Report.ReportId = TEXT("analytics/session-outcome-balance");
		Report.Title = TEXT("Session Outcome Balance");
		Report.Summary = TEXT("First-party session outcome report with export and accessibility metadata preserved from the retained echarts reporting lane.");
		Report.VisualizationKind = EHyperTwistTrainingAnalyticsVisualizationKind::BarChart;
		Report.Series =
		{
			MakeSeries(
				TEXT("session-outcome-counts"),
				TEXT("Outcome Counts"),
				TEXT("Completed and non-completed attempt outcomes for the active training session."),
				EHyperTwistTrainingAnalyticsMetricKind::Count,
				{
					MakeDataPoint(TEXT("completed"), TEXT("Completed"), SessionSummary.CompletedAttemptCount, EHyperTwistTrainingAnalyticsMetricKind::Count, TEXT(""), true, {TEXT("attempts"), TEXT("completed")}),
					MakeDataPoint(TEXT("success"), TEXT("Success"), SessionSummary.SuccessCount, EHyperTwistTrainingAnalyticsMetricKind::Count, TEXT(""), true, {TEXT("success")}),
					MakeDataPoint(TEXT("failure"), TEXT("Failure"), SessionSummary.FailureCount, EHyperTwistTrainingAnalyticsMetricKind::Count, TEXT(""), false, {TEXT("failure")}),
					MakeDataPoint(TEXT("timeout"), TEXT("Timeout"), SessionSummary.TimeoutCount, EHyperTwistTrainingAnalyticsMetricKind::Count, TEXT(""), false, {TEXT("timeout")}),
					MakeDataPoint(TEXT("dnf"), TEXT("DNF"), SessionSummary.DNFCount, EHyperTwistTrainingAnalyticsMetricKind::Count, TEXT(""), false, {TEXT("dnf")}),
					MakeDataPoint(TEXT("aborted"), TEXT("Aborted"), SessionSummary.AbortedCount, EHyperTwistTrainingAnalyticsMetricKind::Count, TEXT(""), false, {TEXT("aborted")})
				}
			)
		};
		Report.InsightLines =
		{
			FString::Printf(TEXT("Deck %s has %d attempts in the active session, with %d completed outcomes."), DeckId.IsEmpty() ? TEXT("n/a") : *DeckId, SessionSummary.AttemptCount, SessionSummary.CompletedAttemptCount),
			FString::Printf(TEXT("Success rate is %.1f%% with an average total time of %s."), SessionSummary.SuccessRate, *FormatDurationMs(SessionSummary.AverageTotalTimeMs))
		};
		Report.ExportSurface = MakeExportSurface(
			TEXT("hypertwist-session-outcome-balance"),
			{
				FString::Printf(TEXT("Active session %s has %d completed attempts and %.1f percent success."), SessionSummary.TrainingSessionId.IsEmpty() ? TEXT("n/a") : *SessionSummary.TrainingSessionId, SessionSummary.CompletedAttemptCount, SessionSummary.SuccessRate),
				FString::Printf(TEXT("Average total time is %s and best total time is %s."), *FormatDurationMs(SessionSummary.AverageTotalTimeMs), *FormatDurationMs(SessionSummary.BestTotalTimeMs))
			},
			{TEXT("tabular-data-view"), TEXT("save-as-image"), TEXT("server-rendered-summary")},
			{TEXT("zrender-2d-substrate")}
		);
		Report.SourceAttribution = MakeEchartsAttribution(
			TEXT("analytics/session-outcome-balance"),
			TEXT("Report contract preserves bounded echarts option, data-store, export, and accessibility behavior for session outcome dashboards.")
		);
		return Report;
	}

	FHyperTwistTrainingAnalyticsReportDefinition BuildDeckHistoryReport(
		const FHyperTwistTrainingDeckHistorySummary& DeckHistorySummary
	)
	{
		FHyperTwistTrainingAnalyticsReportDefinition Report;
		if (DeckHistorySummary.SessionCount <= 0)
		{
			return Report;
		}

		Report.ReportId = TEXT("analytics/deck-history-progress");
		Report.Title = TEXT("Deck History Progress");
		Report.Summary = TEXT("Deck-level progress report that separates load, completion, and timing quality instead of coupling analytics to a chart runtime.");
		Report.VisualizationKind = EHyperTwistTrainingAnalyticsVisualizationKind::ComparisonTable;
		Report.Series =
		{
			MakeSeries(
				TEXT("deck-history-volume"),
				TEXT("History Volume"),
				TEXT("Session and attempt totals across the retained deck history lane."),
				EHyperTwistTrainingAnalyticsMetricKind::Count,
				{
					MakeDataPoint(TEXT("sessions"), TEXT("Sessions"), DeckHistorySummary.SessionCount, EHyperTwistTrainingAnalyticsMetricKind::Count, TEXT(""), true, {TEXT("sessions")}),
					MakeDataPoint(TEXT("completed-sessions"), TEXT("Completed Sessions"), DeckHistorySummary.CompletedSessionCount, EHyperTwistTrainingAnalyticsMetricKind::Count, TEXT(""), true, {TEXT("sessions"), TEXT("completed")}),
					MakeDataPoint(TEXT("attempts"), TEXT("Attempts"), DeckHistorySummary.TotalAttemptCount, EHyperTwistTrainingAnalyticsMetricKind::Count, TEXT(""), false, {TEXT("attempts")}),
					MakeDataPoint(TEXT("plus-two"), TEXT("+2 Penalties"), DeckHistorySummary.Plus2PenaltyCount, EHyperTwistTrainingAnalyticsMetricKind::Count, TEXT(""), false, {TEXT("penalty")})
				}
			),
			MakeSeries(
				TEXT("deck-history-timing"),
				TEXT("Timing Benchmarks"),
				TEXT("Best and average timing surfaces across retained deck history records."),
				EHyperTwistTrainingAnalyticsMetricKind::DurationMs,
				{
					MakeDataPoint(TEXT("best-total"), TEXT("Best Total"), DeckHistorySummary.BestTotalTimeMs, EHyperTwistTrainingAnalyticsMetricKind::DurationMs, TEXT(""), true, {TEXT("timing"), TEXT("best")}),
					MakeDataPoint(TEXT("best-raw"), TEXT("Best Raw"), DeckHistorySummary.BestRawSolveTimeMs, EHyperTwistTrainingAnalyticsMetricKind::DurationMs, TEXT(""), true, {TEXT("timing"), TEXT("raw")}),
					MakeDataPoint(TEXT("avg-total"), TEXT("Average Total"), DeckHistorySummary.AverageTotalTimeMs, EHyperTwistTrainingAnalyticsMetricKind::DurationMs, TEXT(""), false, {TEXT("timing"), TEXT("average")}),
					MakeDataPoint(TEXT("avg-raw"), TEXT("Average Raw"), DeckHistorySummary.AverageRawSolveTimeMs, EHyperTwistTrainingAnalyticsMetricKind::DurationMs, TEXT(""), false, {TEXT("timing"), TEXT("average"), TEXT("raw")}),
					MakeDataPoint(TEXT("avg-inspection"), TEXT("Average Inspection"), DeckHistorySummary.AverageInspectionTimeMs, EHyperTwistTrainingAnalyticsMetricKind::DurationMs, TEXT(""), false, {TEXT("timing"), TEXT("inspection")})
				}
			)
		};
		Report.InsightLines =
		{
			FString::Printf(TEXT("Deck history covers %d sessions and %d attempts."), DeckHistorySummary.SessionCount, DeckHistorySummary.TotalAttemptCount),
			FString::Printf(TEXT("Best total time is %s, while average total time is %s."), *FormatDurationMs(DeckHistorySummary.BestTotalTimeMs), *FormatDurationMs(DeckHistorySummary.AverageTotalTimeMs))
		};
		Report.ExportSurface = MakeExportSurface(
			TEXT("hypertwist-deck-history-progress"),
			{
				FString::Printf(TEXT("Deck %s history covers %d sessions, %d completed sessions, and %d attempts."), DeckHistorySummary.DeckId.IsEmpty() ? TEXT("n/a") : *DeckHistorySummary.DeckId, DeckHistorySummary.SessionCount, DeckHistorySummary.CompletedSessionCount, DeckHistorySummary.TotalAttemptCount),
				FString::Printf(TEXT("Average total time is %s, with the best total time at %s."), *FormatDurationMs(DeckHistorySummary.AverageTotalTimeMs), *FormatDurationMs(DeckHistorySummary.BestTotalTimeMs))
			},
			{TEXT("tabular-data-view"), TEXT("save-as-image"), TEXT("server-rendered-summary")},
			{TEXT("zrender-2d-substrate")}
		);
		Report.SourceAttribution = MakeEchartsAttribution(
			TEXT("analytics/deck-history-progress"),
			TEXT("Report contract preserves bounded echarts option grouping, data-store separation, and export tooling for deck history and progress summaries.")
		);
		return Report;
	}

	FHyperTwistTrainingAnalyticsReportDefinition BuildTimingTrendReport(
		const FHyperTwistTrainingTimingTrendSummary& TimingTrendSummary
	)
	{
		FHyperTwistTrainingAnalyticsReportDefinition Report;
		if (TimingTrendSummary.SolveCount <= 0)
		{
			return Report;
		}

		TArray<FHyperTwistTrainingAnalyticsDataPoint> CurrentAveragePoints;
		TArray<FHyperTwistTrainingAnalyticsDataPoint> BestAveragePoints;
		for (const FHyperTwistTrainingRollingWindowStat& Window : TimingTrendSummary.RollingWindows)
		{
			CurrentAveragePoints.Add(MakeDataPoint(
				FString::Printf(TEXT("current-%s"), *Window.WindowLabel),
				Window.WindowLabel,
				Window.CurrentAverageTotalTimeMs,
				EHyperTwistTrainingAnalyticsMetricKind::DurationMs,
				TEXT(""),
				Window.bWindowFilled,
				{TEXT("rolling-window"), TEXT("current")}
			));
			BestAveragePoints.Add(MakeDataPoint(
				FString::Printf(TEXT("best-%s"), *Window.WindowLabel),
				Window.WindowLabel,
				Window.BestAverageTotalTimeMs,
				EHyperTwistTrainingAnalyticsMetricKind::DurationMs,
				TEXT(""),
				Window.bWindowFilled,
				{TEXT("rolling-window"), TEXT("best")}
			));
		}

		Report.ReportId = TEXT("analytics/timing-trend-rolling-windows");
		Report.Title = TEXT("Timing Trend and Rolling Windows");
		Report.Summary = TEXT("Timing trend report preserving bounded echarts data zoom, thumbnail overview, and rolling-window reporting semantics.");
		Report.VisualizationKind = EHyperTwistTrainingAnalyticsVisualizationKind::RollingWindowTrend;
		Report.Series =
		{
			MakeSeries(
				TEXT("current-window-total"),
				TEXT("Current Window Total"),
				TEXT("Current rolling averages across retained timing windows."),
				EHyperTwistTrainingAnalyticsMetricKind::DurationMs,
				CurrentAveragePoints
			),
			MakeSeries(
				TEXT("best-window-total"),
				TEXT("Best Window Total"),
				TEXT("Best rolling averages across retained timing windows."),
				EHyperTwistTrainingAnalyticsMetricKind::DurationMs,
				BestAveragePoints
			)
		};
		Report.InsightLines =
		{
			FString::Printf(TEXT("Last solve total time is %s; mean total time is %s."), *FormatDurationMs(TimingTrendSummary.LastSolveTotalTimeMs), *FormatDurationMs(TimingTrendSummary.MeanTotalTimeMs)),
			FString::Printf(TEXT("Best single total time is %s and worst single total time is %s."), *FormatDurationMs(TimingTrendSummary.BestSingleTotalTimeMs), *FormatDurationMs(TimingTrendSummary.WorstSingleTotalTimeMs))
		};
		Report.ExportSurface = MakeExportSurface(
			TEXT("hypertwist-timing-trend-rolling-windows"),
			{
				FString::Printf(TEXT("Deck %s has %d timed solves in the current trend window."), TimingTrendSummary.DeckId.IsEmpty() ? TEXT("n/a") : *TimingTrendSummary.DeckId, TimingTrendSummary.SolveCount),
				FString::Printf(TEXT("Best single total time is %s, last solve total time is %s, and rolling windows are available for detailed inspection."), *FormatDurationMs(TimingTrendSummary.BestSingleTotalTimeMs), *FormatDurationMs(TimingTrendSummary.LastSolveTotalTimeMs))
			},
			{TEXT("tabular-data-view"), TEXT("data-zoom-history"), TEXT("thumbnail-overview"), TEXT("save-as-image"), TEXT("server-rendered-summary")},
			{TEXT("zrender-2d-substrate"), TEXT("echarts-gl-optional-3d-explainer")}
		);
		Report.SourceAttribution = MakeEchartsAttribution(
			TEXT("analytics/timing-trend-rolling-windows"),
			TEXT("Report contract preserves bounded echarts data zoom history, thumbnail overview, and rolling-window option semantics; optional richer 3D explainer views remain subordinate to echarts-gl.")
		);
		return Report;
	}

	FHyperTwistTrainingAnalyticsReportDefinition BuildPersonalBestReport(
		const FHyperTwistTrainingPersonalBestSummary& PersonalBestSummary,
		const FHyperTwistTrainingTimingTrendSummary& TimingTrendSummary
	)
	{
		FHyperTwistTrainingAnalyticsReportDefinition Report;
		if (!PersonalBestSummary.bHasTimedPersonalBest)
		{
			return Report;
		}

		Report.ReportId = TEXT("analytics/personal-best-benchmark");
		Report.Title = TEXT("Personal Best Benchmark");
		Report.Summary = TEXT("Personal-best benchmark report for single and session-average timing lanes with export and accessibility metadata.");
		Report.VisualizationKind = EHyperTwistTrainingAnalyticsVisualizationKind::StatGrid;
		Report.Series =
		{
			MakeSeries(
				TEXT("personal-best-benchmarks"),
				TEXT("Personal Bests"),
				TEXT("Single and session-average personal best timing benchmarks."),
				EHyperTwistTrainingAnalyticsMetricKind::DurationMs,
				{
					MakeDataPoint(TEXT("best-single-total"), TEXT("Best Single Total"), PersonalBestSummary.BestSingleTotalTimeMs, EHyperTwistTrainingAnalyticsMetricKind::DurationMs, TEXT(""), true, {TEXT("pb"), TEXT("single")}),
					MakeDataPoint(TEXT("best-single-raw"), TEXT("Best Single Raw"), PersonalBestSummary.BestSingleRawSolveTimeMs, EHyperTwistTrainingAnalyticsMetricKind::DurationMs, TEXT(""), false, {TEXT("pb"), TEXT("single"), TEXT("raw")}),
					MakeDataPoint(TEXT("best-session-total"), TEXT("Best Session Average"), PersonalBestSummary.BestSessionAverageTotalTimeMs, EHyperTwistTrainingAnalyticsMetricKind::DurationMs, TEXT(""), true, {TEXT("pb"), TEXT("session-average")}),
					MakeDataPoint(TEXT("best-session-raw"), TEXT("Best Session Raw Average"), PersonalBestSummary.BestSessionAverageRawSolveTimeMs, EHyperTwistTrainingAnalyticsMetricKind::DurationMs, TEXT(""), false, {TEXT("pb"), TEXT("session-average"), TEXT("raw")}),
					MakeDataPoint(TEXT("mean-total"), TEXT("Current Mean Total"), TimingTrendSummary.MeanTotalTimeMs, EHyperTwistTrainingAnalyticsMetricKind::DurationMs, TEXT(""), false, {TEXT("mean"), TEXT("current")})
				}
			)
		};
		Report.InsightLines =
		{
			FString::Printf(TEXT("Best single total time is %s and best session average is %s."), *FormatDurationMs(PersonalBestSummary.BestSingleTotalTimeMs), *FormatDurationMs(PersonalBestSummary.BestSessionAverageTotalTimeMs)),
			!PersonalBestSummary.LastPersonalBestAtUtc.IsEmpty()
				? FString::Printf(TEXT("Last personal-best improvement was recorded at %s."), *PersonalBestSummary.LastPersonalBestAtUtc)
				: TEXT("No personal-best timestamp has been recorded yet.")
		};
		Report.ExportSurface = MakeExportSurface(
			TEXT("hypertwist-personal-best-benchmark"),
			{
				FString::Printf(TEXT("Deck %s has an active timed personal-best record."), PersonalBestSummary.DeckId.IsEmpty() ? TEXT("n/a") : *PersonalBestSummary.DeckId),
				FString::Printf(TEXT("Best single total time is %s and best session average is %s."), *FormatDurationMs(PersonalBestSummary.BestSingleTotalTimeMs), *FormatDurationMs(PersonalBestSummary.BestSessionAverageTotalTimeMs))
			},
			{TEXT("tabular-data-view"), TEXT("save-as-image"), TEXT("server-rendered-summary")},
			{TEXT("zrender-2d-substrate")}
		);
		Report.SourceAttribution = MakeEchartsAttribution(
			TEXT("analytics/personal-best-benchmark"),
			TEXT("Report contract preserves bounded echarts stat-grid and export semantics for personal-best and benchmark summary surfaces.")
		);
		return Report;
	}

	FHyperTwistTrainingAnalyticsReportDefinition BuildCaseComparisonReport(
		const FHyperTwistTrainingDeckScopedStats& DeckScopedStats,
		const FHyperTwistTrainingCaseScopedStats& CaseScopedStats,
		const FString& ResolvedCaseId
	)
	{
		FHyperTwistTrainingAnalyticsReportDefinition Report;
		if (DeckScopedStats.AttemptCount <= 0 || ResolvedCaseId.IsEmpty())
		{
			return Report;
		}

		const bool bHasCaseScopedStats = CaseScopedStats.AttemptCount > 0;
		Report.ReportId = TEXT("analytics/current-case-vs-deck");
		Report.Title = TEXT("Current Case vs Deck");
		Report.Summary = TEXT("Current-case comparison report for case-level difficulty, timing, and recognition burden relative to the active deck.");
		Report.VisualizationKind = EHyperTwistTrainingAnalyticsVisualizationKind::ComparisonTable;
		Report.Series =
		{
			MakeSeries(
				TEXT("deck-baseline"),
				TEXT("Deck Baseline"),
				TEXT("Deck-wide baseline for attempts, success, and timing."),
				EHyperTwistTrainingAnalyticsMetricKind::Count,
				{
					MakeDataPoint(TEXT("deck-attempts"), TEXT("Attempts"), DeckScopedStats.AttemptCount, EHyperTwistTrainingAnalyticsMetricKind::Count, TEXT(""), false, {TEXT("deck"), TEXT("attempts")}),
					MakeDataPoint(TEXT("deck-success"), TEXT("Success"), DeckScopedStats.SuccessCount, EHyperTwistTrainingAnalyticsMetricKind::Count, TEXT(""), false, {TEXT("deck"), TEXT("success")}),
					MakeDataPoint(TEXT("deck-avg-total"), TEXT("Average Total"), DeckScopedStats.AverageTotalTimeMs, EHyperTwistTrainingAnalyticsMetricKind::DurationMs, TEXT(""), false, {TEXT("deck"), TEXT("timing")}),
					MakeDataPoint(TEXT("deck-avg-raw"), TEXT("Average Raw"), DeckScopedStats.AverageRawSolveTimeMs, EHyperTwistTrainingAnalyticsMetricKind::DurationMs, TEXT(""), false, {TEXT("deck"), TEXT("timing"), TEXT("raw")}),
					MakeDataPoint(TEXT("deck-recognition"), TEXT("Recognition Attempts"), DeckScopedStats.RecognitionAttemptCount, EHyperTwistTrainingAnalyticsMetricKind::Count, TEXT(""), false, {TEXT("deck"), TEXT("recognition")})
				}
			),
			MakeSeries(
				TEXT("case-focus"),
				TEXT("Current Case Focus"),
				TEXT("Current case view, including empty-state handling when the case has not accumulated its own history yet."),
				EHyperTwistTrainingAnalyticsMetricKind::Count,
				{
					MakeDataPoint(TEXT("case-attempts"), TEXT("Attempts"), bHasCaseScopedStats ? CaseScopedStats.AttemptCount : 0, EHyperTwistTrainingAnalyticsMetricKind::Count, bHasCaseScopedStats ? TEXT("") : TEXT("no retained case history yet"), true, {TEXT("case"), TEXT("attempts")}),
					MakeDataPoint(TEXT("case-success"), TEXT("Success"), bHasCaseScopedStats ? CaseScopedStats.SuccessCount : 0, EHyperTwistTrainingAnalyticsMetricKind::Count, bHasCaseScopedStats ? TEXT("") : TEXT("no retained case history yet"), false, {TEXT("case"), TEXT("success")}),
					MakeDataPoint(TEXT("case-avg-total"), TEXT("Average Total"), bHasCaseScopedStats ? CaseScopedStats.AverageTotalTimeMs : 0, EHyperTwistTrainingAnalyticsMetricKind::DurationMs, bHasCaseScopedStats ? TEXT("") : TEXT("no retained case history yet"), bHasCaseScopedStats, {TEXT("case"), TEXT("timing")}),
					MakeDataPoint(TEXT("case-avg-raw"), TEXT("Average Raw"), bHasCaseScopedStats ? CaseScopedStats.AverageRawSolveTimeMs : 0, EHyperTwistTrainingAnalyticsMetricKind::DurationMs, bHasCaseScopedStats ? TEXT("") : TEXT("no retained case history yet"), false, {TEXT("case"), TEXT("timing"), TEXT("raw")}),
					MakeDataPoint(TEXT("case-recognition"), TEXT("Recognition Attempts"), bHasCaseScopedStats ? CaseScopedStats.RecognitionAttemptCount : 0, EHyperTwistTrainingAnalyticsMetricKind::Count, bHasCaseScopedStats ? TEXT("") : TEXT("no retained case history yet"), false, {TEXT("case"), TEXT("recognition")})
				}
			)
		};
		Report.InsightLines =
		{
			bHasCaseScopedStats
				? FString::Printf(TEXT("Case %s has %d attempts against a deck baseline of %d attempts."), *ResolvedCaseId, CaseScopedStats.AttemptCount, DeckScopedStats.AttemptCount)
				: FString::Printf(TEXT("Case %s has no retained scoped history yet; the report falls back to the deck baseline until more attempts are recorded."), *ResolvedCaseId),
			FString::Printf(TEXT("Deck average total time is %s; case average total time is %s."), *FormatDurationMs(DeckScopedStats.AverageTotalTimeMs), *FormatDurationMs(bHasCaseScopedStats ? CaseScopedStats.AverageTotalTimeMs : 0))
		};
		Report.ExportSurface = MakeExportSurface(
			TEXT("hypertwist-current-case-vs-deck"),
			{
				FString::Printf(TEXT("Case %s is being compared against deck %s."), *ResolvedCaseId, DeckScopedStats.DeckId.IsEmpty() ? TEXT("n/a") : *DeckScopedStats.DeckId),
				bHasCaseScopedStats
					? FString::Printf(TEXT("Case history includes %d attempts with an average total time of %s."), CaseScopedStats.AttemptCount, *FormatDurationMs(CaseScopedStats.AverageTotalTimeMs))
					: TEXT("No retained case history is available yet; deck baseline values remain the current reporting fallback.")
			},
			{TEXT("tabular-data-view"), TEXT("save-as-image"), TEXT("server-rendered-summary")},
			{TEXT("zrender-2d-substrate")}
		);
		Report.SourceAttribution = MakeEchartsAttribution(
			TEXT("analytics/current-case-vs-deck"),
			TEXT("Report contract preserves bounded echarts comparison-table, export, and accessibility behavior for case-vs-deck analytics surfaces.")
		);
		return Report;
	}

	FHyperTwistTrainingAnalyticsReportDefinition BuildLiveSplitReport(
		const FHyperTwistTrainingLiveTimerState& LiveTimerState
	)
	{
		FHyperTwistTrainingAnalyticsReportDefinition Report;
		if (!LiveTimerState.IsStructurallyValid() || LiveTimerState.SplitCaptures.Num() == 0)
		{
			return Report;
		}

		TArray<FHyperTwistTrainingAnalyticsDataPoint> CumulativePoints;
		TArray<FHyperTwistTrainingAnalyticsDataPoint> PhaseDurationPoints;
		int32 PreviousTimestampMs = 0;
		for (const FHyperTwistTrainingSplitCapture& SplitCapture : LiveTimerState.SplitCaptures)
		{
			if (!SplitCapture.IsStructurallyValid())
			{
				continue;
			}

			const int32 CumulativeTimestampMs = FMath::Max(0, SplitCapture.TimestampMs);
			const int32 PhaseDurationMs = FMath::Max(0, CumulativeTimestampMs - PreviousTimestampMs);
			const FString ResolvedLabel = !SplitCapture.Label.IsEmpty() ? SplitCapture.Label : SplitCapture.PhaseId;
			CumulativePoints.Add(MakeDataPoint(
				FString::Printf(TEXT("cumulative-%s"), *SplitCapture.PhaseId),
				ResolvedLabel,
				CumulativeTimestampMs,
				EHyperTwistTrainingAnalyticsMetricKind::DurationMs,
				TEXT(""),
				true,
				{TEXT("split"), TEXT("cumulative")}
			));
			PhaseDurationPoints.Add(MakeDataPoint(
				FString::Printf(TEXT("phase-%s"), *SplitCapture.PhaseId),
				ResolvedLabel,
				PhaseDurationMs,
				EHyperTwistTrainingAnalyticsMetricKind::DurationMs,
				TEXT(""),
				false,
				{TEXT("split"), TEXT("phase-duration")}
			));
			PreviousTimestampMs = CumulativeTimestampMs;
		}

		if (LiveTimerState.SolveElapsedMs > PreviousTimestampMs)
		{
			const int32 FinalPhaseMs = FMath::Max(0, LiveTimerState.SolveElapsedMs - PreviousTimestampMs);
			PhaseDurationPoints.Add(MakeDataPoint(
				TEXT("phase-final"),
				TEXT("Final Segment"),
				FinalPhaseMs,
				EHyperTwistTrainingAnalyticsMetricKind::DurationMs,
				TEXT(""),
				false,
				{TEXT("split"), TEXT("phase-duration"), TEXT("final")}
			));
		}

		Report.ReportId = TEXT("analytics/live-split-ladder");
		Report.Title = TEXT("Live Split Ladder");
		Report.Summary = TEXT("Split-ladder report preserving bounded cumulative and phase-duration reporting semantics for timer-scoped progress surfaces.");
		Report.VisualizationKind = EHyperTwistTrainingAnalyticsVisualizationKind::SplitLadder;
		Report.Series =
		{
			MakeSeries(
				TEXT("split-cumulative"),
				TEXT("Split Cumulative"),
				TEXT("Cumulative split timestamps from the active live timer state."),
				EHyperTwistTrainingAnalyticsMetricKind::DurationMs,
				CumulativePoints
			),
			MakeSeries(
				TEXT("split-phase-duration"),
				TEXT("Split Phase Duration"),
				TEXT("Derived phase durations between cumulative split captures."),
				EHyperTwistTrainingAnalyticsMetricKind::DurationMs,
				PhaseDurationPoints
			)
		};
		Report.InsightLines =
		{
			FString::Printf(TEXT("Active timer phase is %s with %d captured splits."), *StaticEnum<EHyperTwistTrainingLiveTimerPhase>()->GetNameStringByValue(static_cast<int64>(LiveTimerState.Phase)), LiveTimerState.SplitCaptures.Num()),
			FString::Printf(TEXT("Current solve elapsed time is %s."), *FormatDurationMs(LiveTimerState.SolveElapsedMs))
		};
		Report.ExportSurface = MakeExportSurface(
			TEXT("hypertwist-live-split-ladder"),
			{
				FString::Printf(TEXT("Live timer session %s currently has %d captured manual splits."), LiveTimerState.TrainingSessionId.IsEmpty() ? TEXT("n/a") : *LiveTimerState.TrainingSessionId, LiveTimerState.SplitCaptures.Num()),
				FString::Printf(TEXT("Current solve elapsed time is %s."), *FormatDurationMs(LiveTimerState.SolveElapsedMs))
			},
			{TEXT("tabular-data-view"), TEXT("save-as-image"), TEXT("server-rendered-summary")},
			{TEXT("zrender-2d-substrate"), TEXT("echarts-gl-optional-3d-explainer")}
		);
		Report.SourceAttribution = MakeEchartsAttribution(
			TEXT("analytics/live-split-ladder"),
			TEXT("Report contract preserves bounded echarts ladder, overview, and export semantics for timer split analytics; optional richer 3D split explainers remain subordinate to echarts-gl.")
		);
		return Report;
	}

	FString ResolveMetricName(const EHyperTwistTrainingAnalyticsMetricKind MetricKind)
	{
		switch (MetricKind)
		{
		case EHyperTwistTrainingAnalyticsMetricKind::DurationMs:
			return TEXT("duration-ms");
		case EHyperTwistTrainingAnalyticsMetricKind::Percentage:
			return TEXT("percentage");
		case EHyperTwistTrainingAnalyticsMetricKind::Ratio:
			return TEXT("ratio");
		case EHyperTwistTrainingAnalyticsMetricKind::Score:
			return TEXT("score");
		case EHyperTwistTrainingAnalyticsMetricKind::Count:
		default:
			return TEXT("count");
		}
	}
}

FHyperTwistTrainingSessionDomainBundle UHyperTwistTrainingAnalyticsLibrary::BuildSessionDomainBundle(
	const FHyperTwistTrainingSession& Session,
	const FHyperTwistTrainingSessionSummary& TrainingSession,
	const FHyperTwistTrainingAttempt& SolveAttempt,
	const FHyperTwistTrainingTemplateLaunchProvenance& TemplateLaunchProvenance,
	const FHyperTwistTrainingCoachPanelState& CoachPanelState,
	const FHyperTwistTrainingMethodDrillRunState& MethodDrillRunState,
	const FHyperTwistTrainingAnalyticsReportBundle& ReportBundle
)
{
	FHyperTwistTrainingSessionDomainBundle Bundle;
	Bundle.Session = Session;
	Bundle.TrainingSession = TrainingSession;
	Bundle.SessionKind = HyperTwistTrainingAnalyticsLibraryInternal::ResolveSessionKind(
		Session,
		TemplateLaunchProvenance,
		CoachPanelState,
		MethodDrillRunState
	);
	Bundle.SolveSession = HyperTwistTrainingAnalyticsLibraryInternal::ResolveSolveSession(
		Session,
		SolveAttempt,
		ReportBundle
	);
	Bundle.bHasSolveSession = Bundle.SolveSession.IsStructurallyValid();

	Bundle.GameSession.GameSessionId = HyperTwistTrainingAnalyticsLibraryInternal::BuildGameSessionId(
		Bundle.SessionKind,
		TrainingSession.TrainingSessionId.IsEmpty()
			? Session.TrainingSessionId
			: TrainingSession.TrainingSessionId
	);
	Bundle.GameSession.TrainingSessionId = TrainingSession.TrainingSessionId.IsEmpty()
		? Session.TrainingSessionId
		: TrainingSession.TrainingSessionId;
	Bundle.GameSession.UserId = !TrainingSession.UserId.IsEmpty() ? TrainingSession.UserId : Session.UserId;
	Bundle.GameSession.DeckId = !TrainingSession.DeckId.IsEmpty() ? TrainingSession.DeckId : Session.DeckId;
	Bundle.GameSession.TemplateId = TemplateLaunchProvenance.TemplateId;
	Bundle.GameSession.QueueEntryId = CoachPanelState.ActiveQueueEntryId;
	Bundle.GameSession.SourceLabel = HyperTwistTrainingAnalyticsLibraryInternal::ResolveGameSessionSourceLabel(
		Bundle.SessionKind,
		TemplateLaunchProvenance,
		CoachPanelState
	);
	Bundle.GameSession.SessionKind = Bundle.SessionKind;
	Bundle.GameSession.SessionKindLabel =
		HyperTwistTrainingAnalyticsLibraryInternal::ResolveSessionKindLabel(Bundle.SessionKind);
	Bundle.GameSession.PrimaryAnalyticsReportId = ReportBundle.PrimaryReportId;
	Bundle.GameSession.StartedAtUtc = Session.StartedAtUtc;
	Bundle.GameSession.CompletedAtUtc = !TrainingSession.LastCompletedAtUtc.IsEmpty()
		? TrainingSession.LastCompletedAtUtc
		: Session.EndedAtUtc;
	Bundle.GameSession.bCoachOrchestrated =
		Bundle.SessionKind == EHyperTwistTrainingSessionKind::CoachRecommended
		|| Bundle.SessionKind == EHyperTwistTrainingSessionKind::CoachFollowUp
		|| Bundle.SessionKind == EHyperTwistTrainingSessionKind::CoachQueue;
	Bundle.GameSession.bTemplateLaunched = TemplateLaunchProvenance.IsStructurallyValid();
	Bundle.GameSession.bReviewBacked = !TemplateLaunchProvenance.SourceReviewPlanId.IsEmpty()
		|| TemplateLaunchProvenance.bPreferReviewRun
		|| TemplateLaunchProvenance.bCoachReview
		|| Bundle.SessionKind == EHyperTwistTrainingSessionKind::ReviewRun;
	Bundle.GameSession.bHasLinkedMethodDrill =
		Bundle.SessionKind == EHyperTwistTrainingSessionKind::MethodDrillFollowUp
		|| (!TemplateLaunchProvenance.MethodSegmentId.IsEmpty()
			&& MethodDrillRunState.IsStructurallyValid()
			&& TemplateLaunchProvenance.MethodSegmentId
				== MethodDrillRunState.DrillDefinition.MethodDescriptor.SegmentId);
	return Bundle;
}

FHyperTwistTrainingAnalyticsReportBundle UHyperTwistTrainingAnalyticsLibrary::BuildActiveTrainingAnalyticsReportBundle(
	const FHyperTwistTrainingSessionSummary& SessionSummary,
	const FHyperTwistTrainingDeckHistorySummary& DeckHistorySummary,
	const FHyperTwistTrainingTimingTrendSummary& TimingTrendSummary,
	const FHyperTwistTrainingPersonalBestSummary& PersonalBestSummary,
	const FHyperTwistTrainingDeckScopedStats& DeckScopedStats,
	const FHyperTwistTrainingCaseScopedStats& CaseScopedStats,
	const FHyperTwistTrainingLiveTimerState& LiveTimerState,
	const FHyperTwistReplayReviewAnalytics& ReplayReviewAnalytics,
	const FHyperTwistRecognitionReplaySummary& RecognitionReplaySummary
)
{
	FHyperTwistTrainingAnalyticsReportBundle Bundle;
	Bundle.UserId = HyperTwistTrainingAnalyticsLibraryInternal::ResolveBundleUserId(
		SessionSummary,
		DeckHistorySummary,
		TimingTrendSummary,
		PersonalBestSummary,
		DeckScopedStats,
		CaseScopedStats,
		LiveTimerState
	);
	Bundle.DeckId = HyperTwistTrainingAnalyticsLibraryInternal::ResolveBundleDeckId(
		SessionSummary,
		DeckHistorySummary,
		TimingTrendSummary,
		PersonalBestSummary,
		DeckScopedStats,
		CaseScopedStats,
		LiveTimerState
	);
	Bundle.CaseId = HyperTwistTrainingAnalyticsLibraryInternal::ResolveBundleCaseId(
		SessionSummary,
		CaseScopedStats,
		LiveTimerState
	);
	Bundle.ReferenceUtc = HyperTwistTrainingAnalyticsLibraryInternal::ResolveReferenceUtc(
		SessionSummary,
		DeckHistorySummary,
		TimingTrendSummary,
		PersonalBestSummary,
		DeckScopedStats,
		CaseScopedStats,
		LiveTimerState
	);
	Bundle.SessionSummary = SessionSummary;
	Bundle.DeckHistorySummary = DeckHistorySummary;
	Bundle.TimingTrendSummary = TimingTrendSummary;
	Bundle.PersonalBestSummary = PersonalBestSummary;
	Bundle.DeckScopedStats = DeckScopedStats;
	Bundle.CaseScopedStats = CaseScopedStats;
	Bundle.LiveTimerState = LiveTimerState;
	Bundle.ReplayReviewAnalytics = ReplayReviewAnalytics;
	Bundle.RecognitionReplaySummary = RecognitionReplaySummary;

	HyperTwistTrainingAnalyticsLibraryInternal::AddReport(
		Bundle,
		HyperTwistTrainingAnalyticsLibraryInternal::BuildSessionOutcomeReport(SessionSummary, Bundle.DeckId)
	);
	HyperTwistTrainingAnalyticsLibraryInternal::AddReport(
		Bundle,
		HyperTwistTrainingAnalyticsLibraryInternal::BuildDeckHistoryReport(DeckHistorySummary)
	);
	HyperTwistTrainingAnalyticsLibraryInternal::AddReport(
		Bundle,
		HyperTwistTrainingAnalyticsLibraryInternal::BuildTimingTrendReport(TimingTrendSummary)
	);
	HyperTwistTrainingAnalyticsLibraryInternal::AddReport(
		Bundle,
		HyperTwistTrainingAnalyticsLibraryInternal::BuildPersonalBestReport(PersonalBestSummary, TimingTrendSummary)
	);
	HyperTwistTrainingAnalyticsLibraryInternal::AddReport(
		Bundle,
		HyperTwistTrainingAnalyticsLibraryInternal::BuildCaseComparisonReport(
			DeckScopedStats,
			CaseScopedStats,
			Bundle.CaseId
		)
	);
	HyperTwistTrainingAnalyticsLibraryInternal::AddReport(
		Bundle,
		HyperTwistTrainingAnalyticsLibraryInternal::BuildLiveSplitReport(LiveTimerState)
	);

	return Bundle;
}

FHyperTwistTrainingAnalyticsPanelContractBundle UHyperTwistTrainingAnalyticsLibrary::BuildAnalyticsPanelContractBundle(
	const FHyperTwistTrainingSessionDomainBundle& SessionDomainBundle,
	const FHyperTwistTrainingAnalyticsReportBundle& ReportBundle
)
{
	FHyperTwistTrainingAnalyticsPanelContractBundle Bundle;
	Bundle.PrimaryReportId = ReportBundle.PrimaryReportId;
	Bundle.SessionKindLabel = SessionDomainBundle.GameSession.SessionKindLabel;
	Bundle.GameSessionId = SessionDomainBundle.GameSession.GameSessionId;

	for (const FHyperTwistTrainingAnalyticsReportDefinition& Report : ReportBundle.Reports)
	{
		const FHyperTwistTrainingAnalyticsPanelContract Contract =
			HyperTwistTrainingAnalyticsLibraryInternal::BuildPanelContract(
				Report,
				SessionDomainBundle,
				ReportBundle.PrimaryReportId
			);
		if (!Contract.IsStructurallyValid())
		{
			continue;
		}

		if (Bundle.PrimaryPanelId.IsEmpty() || Contract.bPrimaryPanel)
		{
			Bundle.PrimaryPanelId = Contract.PanelId;
		}

		Bundle.Panels.Add(Contract);
	}

	return Bundle;
}

bool UHyperTwistTrainingAnalyticsLibrary::TryGetReportById(
	const FHyperTwistTrainingAnalyticsReportBundle& Bundle,
	const FString& ReportId,
	FHyperTwistTrainingAnalyticsReportDefinition& OutReport
)
{
	for (const FHyperTwistTrainingAnalyticsReportDefinition& Report : Bundle.Reports)
	{
		if (Report.ReportId == ReportId)
		{
			OutReport = Report;
			return true;
		}
	}

	OutReport = FHyperTwistTrainingAnalyticsReportDefinition();
	return false;
}

FString UHyperTwistTrainingAnalyticsLibrary::BuildReportDataViewTsv(
	const FHyperTwistTrainingAnalyticsReportDefinition& Report
)
{
	if (!Report.IsStructurallyValid())
	{
		return TEXT("");
	}

	FString Output = TEXT("Series\tPoint\tValue\tSecondaryLabel\tMetric\tTags");
	for (const FHyperTwistTrainingAnalyticsSeries& Series : Report.Series)
	{
		for (const FHyperTwistTrainingAnalyticsDataPoint& Point : Series.DataPoints)
		{
			Output += FString::Printf(
				TEXT("\n%s\t%s\t%s\t%s\t%s\t%s"),
				*Series.Label,
				*Point.Label,
				*HyperTwistTrainingAnalyticsLibraryInternal::FormatMetricValue(Point.NumericValue, Point.MetricKind),
				*Point.SecondaryLabel,
				*HyperTwistTrainingAnalyticsLibraryInternal::ResolveMetricName(Point.MetricKind),
				*FString::Join(Point.Tags, TEXT(","))
			);
		}
	}

	return Output;
}
