using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; using PlotLine.Data; using PlotLine.Models; using PlotLine.Services; using PlotLine.ViewModels; namespace PlotLine.Hubs; [Authorize] public sealed class StoryIntelligenceHub( IStoryIntelligenceService storyIntelligence, IStoryIntelligenceResultRepository persistedRuns, IOnboardingStoryIntelligenceService onboardingStoryIntelligence) : Hub { public override async Task OnConnectedAsync() { if (TryGetUserId(out var userId)) { await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId)); } await base.OnConnectedAsync(); } public async Task WatchStoryIntelligenceJob(Guid jobId) { var userId = RequireUserId(); await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId)); return await storyIntelligence.GetProgressForUserAsync(userId, jobId); } public async Task WatchStoryIntelligenceRun(int runId) { var userId = RequireUserId(); await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId)); var run = await persistedRuns.GetRunAsync(runId); return run is null || run.UserID != userId ? null : ToRunProgress(run, "Current status"); } public async Task WatchOnboardingStoryIntelligenceBatch(Guid batchId) { var userId = RequireUserId(); await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId)); return await onboardingStoryIntelligence.GetProgressAsync(batchId); } public static string UserGroup(int userId) => $"story-intelligence:{userId}"; private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType) { var completedScenes = run.CompletedScenes ?? 0; var totalScenes = run.TotalDetectedScenes ?? 0; var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds)); return new StoryIntelligenceRunProgressEvent { RunID = run.StoryIntelligenceRunID, UserID = run.UserID, ProjectID = run.ProjectID, BookID = run.BookID, ChapterID = run.ChapterID, ChapterNumber = run.ChapterNumber, Status = run.Status, CurrentStage = run.CurrentStage ?? string.Empty, CurrentMessage = run.CurrentMessage ?? string.Empty, EventType = eventType, TotalDetectedScenes = run.TotalDetectedScenes, CompletedScenes = run.CompletedScenes, FailedScenes = run.FailedScenes, TotalTokens = run.TotalTokens, TotalDurationMs = elapsedMs, ElapsedTime = FormatDuration(elapsedMs), EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes, totalScenes), ErrorMessage = run.ErrorMessage, UpdatedUtc = run.UpdatedUtc, IsActive = run.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running, IsCompleted = run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings }; } private static string FormatDuration(long? milliseconds) { var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds ?? 0)); if (elapsed.TotalMinutes >= 1) { return $"{Math.Max(1, (int)Math.Round(elapsed.TotalMinutes)):N0} min"; } return $"{Math.Max(0, (int)Math.Round(elapsed.TotalSeconds)):N0} sec"; } private static string? EstimateRemaining(long elapsedMs, int completedScenes, int totalScenes) { if (completedScenes <= 0 || totalScenes <= completedScenes) { return totalScenes > 0 && totalScenes == completedScenes ? "Nearly finished" : "Calculating"; } var averageSceneMs = elapsedMs / Math.Max(1, completedScenes); return FormatDuration(averageSceneMs * (totalScenes - completedScenes)); } private int RequireUserId() => TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use Story Intelligence."); private bool TryGetUserId(out int userId) => int.TryParse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier), out userId); }