using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;

namespace PlotLine.Services;

public interface IStoryIntelligenceVisualisationSnapshotService
{
    Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildSnapshotAsync(int runId, int userId);
    Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildImportSessionSnapshotAsync(int importSessionId, int userId);
    Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildReplaySnapshotAsync(int importSessionId, int userId);
    Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListRunOptionsAsync(int userId);
    Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListImportSessionOptionsAsync(int userId);
}

public sealed class StoryIntelligenceVisualisationSnapshotService(
    IStoryIntelligenceResultRepository repository,
    IStoryIntelligencePipelineRepository pipelines,
    IStoryIntelligenceSourceRepository sources,
    IIllustrationLibraryService illustrationLibrary,
    IStoryMemoryRepository storyMemoryRepository,
    IIllustrationLibraryRepository illustrationRepository,
    IUploadStorageService uploadStorage,
    ILogger<StoryIntelligenceVisualisationSnapshotService> logger) : IStoryIntelligenceVisualisationSnapshotService
{
    private const string FallbackRoot = "/images/story-intelligence/prototype";
    private const int SceneWindowPrevious = 4;
    private const int SceneWindowFollowing = 2;
    private const int SnapshotWarningBytes = 1024 * 1024;
    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        PropertyNameCaseInsensitive = true
    };

    public async Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListRunOptionsAsync(int userId)
    {
        var runs = await repository.ListRunsAsync();
        return runs
            .Where(run => run.UserID == userId)
            .OrderByDescending(run => run.CreatedUtc)
            .Take(12)
            .Select(run => new StoryIntelligenceExperienceRunOption
            {
                RunId = run.StoryIntelligenceRunID,
                Status = run.Status,
                CreatedUtc = run.CreatedUtc,
                Label = $"{DisplayTitle(run.ProjectTitle, "Project")} / {DisplayTitle(run.BookTitle, "Book")} - {run.Status} - {run.CreatedUtc:u}"
            })
            .ToList();
    }

    public async Task<IReadOnlyList<StoryIntelligenceExperienceRunOption>> ListImportSessionOptionsAsync(int userId)
    {
        await EnsureActiveImportSessionsAsync(userId);
        var sessions = await pipelines.ListForUserAsync(userId);
        return sessions
            .OrderByDescending(session => session.UpdatedUtc)
            .Take(12)
            .Select(session => new StoryIntelligenceExperienceRunOption
            {
                ImportSessionId = session.StoryIntelligenceBookPipelineID,
                RunId = session.LastRunID ?? 0,
                Status = session.Status,
                CreatedUtc = session.CreatedUtc,
                Label = $"{DisplayTitle(session.ProjectName, "Project")} / {DisplayTitle(session.BookDisplayTitle, "Book")} - {session.Status} - started {session.CreatedUtc:u}"
            })
            .ToList();
    }

    private async Task EnsureActiveImportSessionsAsync(int userId)
    {
        var runs = await repository.ListRunsAsync();
        var activeBookRuns = runs
            .Where(run => run.UserID == userId
                && run.ProjectID.HasValue
                && run.BookID.HasValue
                && !IsTerminal(run.Status))
            .GroupBy(run => new { ProjectID = run.ProjectID!.Value, BookID = run.BookID!.Value })
            .Select(group => group.OrderByDescending(run => run.CreatedUtc).First())
            .ToList();

        foreach (var run in activeBookRuns)
        {
            await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
            {
                ProjectID = run.ProjectID!.Value,
                BookID = run.BookID!.Value,
                CurrentStage = StoryIntelligencePipelineStages.Chapters,
                LastCompletedStage = null,
                CurrentReviewStage = null,
                Status = StoryIntelligencePipelineStatuses.InProgress,
                CompletedUtc = null,
                LastRunID = run.StoryIntelligenceRunID
            });
        }
    }

    public async Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildSnapshotAsync(int runId, int userId)
    {
        var stopwatch = Stopwatch.StartNew();
        var run = await repository.GetRunAsync(runId);
        if (run is null || run.UserID != userId)
        {
            return null;
        }

        var results = (await repository.ListSceneResultsAsync(runId)).ToList();
        var parsed = results
            .Select(result => ReadSceneResult(runId, result))
            .OrderBy(item => SceneSortChapter(item))
            .ThenBy(item => SceneSortNumber(item))
            .ThenBy(item => item.Result.SceneResultID)
            .ToList();
        var completed = parsed
            .Where(item => item.Scene is not null)
            .ToList();

        var current = PickCurrentScene(run, completed);
        var storyMemory = new StoryMemory();
        var visibleSceneIds = VisibleSceneResultIds(completed, current, SceneWindowPrevious, SceneWindowFollowing);
        var scenes = BuildSceneStates(run, parsed, completed, current, storyMemory: storyMemory, includedSceneResultIds: visibleSceneIds).ToList();
        if (scenes.Count == 0)
        {
            scenes.Add(BuildEmptyScene(run));
        }

        var model = new StoryIntelligenceExperiencePrototypeViewModel
        {
            Mode = "real",
            RunId = run.StoryIntelligenceRunID,
            BookId = run.BookID,
            RunStatus = run.Status,
            GeneratedUtc = DateTime.UtcNow,
            ChangeToken = ChangeToken(run, results),
            IsTerminal = IsTerminal(run.Status),
            SnapshotMessage = SnapshotMessage(run, completed.Count),
            Diagnostics = BuildDiagnostics(run, results, parsed, completed, current, storyMemory),
            ProjectName = DisplayTitle(run.ProjectTitle, "Untitled project"),
            BookTitle = DisplayTitle(run.BookTitle, "Untitled book"),
            Scenes = scenes
        };

        StabiliseCharacterIllustrations(model);
        await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
        ApplyIllustrationDiagnostics(model);
        StripLiveDiagnostics(model);
        LogPayload(model, $"run {runId}", stopwatch.ElapsedMilliseconds);
        return model;
    }

    public async Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildImportSessionSnapshotAsync(int importSessionId, int userId)
        => await BuildImportSessionSnapshotAsync(importSessionId, userId, replayMode: false);

    public async Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildReplaySnapshotAsync(int importSessionId, int userId)
        => await BuildImportSessionSnapshotAsync(importSessionId, userId, replayMode: true);

    private async Task<StoryIntelligenceExperiencePrototypeViewModel?> BuildImportSessionSnapshotAsync(int importSessionId, int userId, bool replayMode)
    {
        var stopwatch = Stopwatch.StartNew();
        var session = await pipelines.GetByIdForUserAsync(importSessionId, userId);
        if (session is null)
        {
            return null;
        }

        var runs = (await repository.ListRunsByBookForUserAsync(session.BookID, userId))
            .OrderBy(run => run.ChapterNumber ?? decimal.MaxValue)
            .ThenBy(run => run.ChapterID ?? int.MaxValue)
            .ThenBy(run => run.StoryIntelligenceRunID)
            .ToList();
        if (runs.Count == 0)
        {
            return null;
        }

        var runSnapshots = new List<RunSnapshot>();
        foreach (var run in runs)
        {
            var results = (await repository.ListSceneResultsAsync(run.StoryIntelligenceRunID)).ToList();
            var parsed = results
                .Select(result => ReadSceneResult(run.StoryIntelligenceRunID, result))
                .OrderBy(item => SceneSortChapter(item))
                .ThenBy(item => SceneSortNumber(item))
                .ThenBy(item => item.Result.SceneResultID)
                .ToList();
            var completed = parsed.Where(item => item.Scene is not null).ToList();
            runSnapshots.Add(new(run, results, parsed, completed, PickCurrentScene(run, completed)));
        }

        var activeSnapshot = PickActiveRunSnapshot(runSnapshots) ?? runSnapshots.Last();
        var allCompleted = runSnapshots
            .SelectMany(snapshot => snapshot.Completed)
            .OrderBy(item => SceneSortChapter(item))
            .ThenBy(item => SceneSortNumber(item))
            .ThenBy(item => item.Result.SceneResultID)
            .ToList();
        var current = replayMode
            ? allCompleted.FirstOrDefault()
            : PickAuthoritativeCurrentScene(runSnapshots);
        var currentSnapshot = OwnerOf(runSnapshots, current) ?? activeSnapshot;
        var totalPersistedResults = runSnapshots.Sum(snapshot => snapshot.Results.Count);
        var parsedScenes = runSnapshots.Sum(snapshot => snapshot.Completed.Count);
        var totalScenes = Math.Max(
            runSnapshots.Sum(snapshot => Math.Max(snapshot.Run.TotalDetectedScenes ?? 0, snapshot.Results.Count)),
            parsedScenes);
        var sourceChapterCount = (await sources.ListChaptersAsync(session.BookID)).Count;
        var bookChapterTotal = Math.Max(1, Math.Max(sourceChapterCount, runSnapshots.Count));
        var completedChapterRuns = runSnapshots.Count(snapshot => IsSuccessfulTerminal(snapshot.Run.Status));
        var sessionSuccessfullyComplete = IsSuccessfulSession(session);
        var allKnownRunsSuccessfullyComplete = runSnapshots.Count > 0 && runSnapshots.All(snapshot => IsSuccessfulTerminal(snapshot.Run.Status));
        var isSuccessfullyComplete = sessionSuccessfullyComplete || allKnownRunsSuccessfullyComplete;
        var isTerminal = isSuccessfullyComplete || IsTerminalSession(session) || runSnapshots.All(snapshot => IsTerminal(snapshot.Run.Status));
        var calculatedVisualisationProgress = OverallProgress(parsedScenes, totalScenes, isSuccessfullyComplete);
        var authoritativeImportProgress = BookProgress(runSnapshots, bookChapterTotal, isSuccessfullyComplete);
        var displayedProgressReason = sessionSuccessfullyComplete
            ? "Successful terminal import session"
            : allKnownRunsSuccessfullyComplete
                ? "All known chapter runs completed successfully"
                : "Weighted chapter-run progress";
        var overallProgressPercent = authoritativeImportProgress;
        var overallElapsedMs = SessionElapsedMilliseconds(runSnapshots, isTerminal);
        var overallElapsed = FormatDuration(overallElapsedMs);
        var overallRemaining = isSuccessfullyComplete ? "Ready to review" : isTerminal ? "Ready to review" : EstimateSessionRemaining(overallElapsedMs, parsedScenes, totalScenes);
        var overallStage = isSuccessfullyComplete ? "Analysis Complete" : isTerminal ? "Analysis stopped" : BackendStageDisplay(activeSnapshot.Run);
        var scenes = new List<StoryIntelligenceExperienceSceneState>();
        var storyMemory = new StoryMemory();
        var visibleSceneIds = replayMode
            ? allCompleted.Select(item => item.Result.SceneResultID).ToHashSet()
            : VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, followingCount: 0);
        foreach (var snapshot in runSnapshots)
        {
            var runScenes = BuildSceneStates(
                    snapshot.Run,
                    snapshot.Parsed,
                    snapshot.Completed,
                    current,
                    overallCompletedScenes: parsedScenes,
                    overallTotalScenes: totalScenes,
                    overallChaptersAnalysed: completedChapterRuns,
                    overallChapterTotal: bookChapterTotal,
                    overallProgressPercent: overallProgressPercent,
                    overallStage: overallStage,
                    overallElapsed: overallElapsed,
                    overallRemaining: overallRemaining,
                    overallTerminal: isTerminal,
                    storyMemory: storyMemory,
                    includedSceneResultIds: visibleSceneIds)
                .ToList();
            scenes.AddRange(runScenes);
        }

        if (replayMode)
        {
            scenes = scenes
                .OrderBy(scene => scene.ChapterNumber ?? decimal.MaxValue)
                .ThenBy(scene => scene.SceneNumber)
                .ThenBy(scene => scene.SceneResultId ?? int.MaxValue)
                .Select((scene, index) => ReplayScene(scene, index, scenes.Count))
                .ToList();
        }

        if (scenes.Count == 0)
        {
            scenes.Add(BuildEmptyScene(activeSnapshot.Run));
        }

        var appearancePreference = await storyMemoryRepository.GetAppearancePreferenceAsync(importSessionId);
        var model = new StoryIntelligenceExperiencePrototypeViewModel
        {
            Mode = replayMode ? "replay" : "real",
            ImportSessionId = session.StoryIntelligenceBookPipelineID,
            RunId = currentSnapshot.Run.StoryIntelligenceRunID,
            BookId = session.BookID,
            RunStatus = SessionStatus(session, runSnapshots),
            GeneratedUtc = DateTime.UtcNow,
            ChangeToken = SessionChangeToken(session, runSnapshots),
            IsTerminal = replayMode || isTerminal,
            SnapshotMessage = replayMode
                ? ReplaySnapshotMessage(session, runSnapshots, parsedScenes)
                : SessionSnapshotMessage(session, runSnapshots),
            Diagnostics = BuildSessionDiagnostics(
                session,
                runSnapshots,
                currentSnapshot,
                current,
                parsedScenes,
                totalPersistedResults,
                totalScenes,
                overallProgressPercent,
                authoritativeImportProgress,
                calculatedVisualisationProgress,
                bookChapterTotal,
                completedChapterRuns,
                displayedProgressReason,
                storyMemory,
                replayMode,
                appearancePreference),
            ProjectName = DisplayTitle(session.ProjectName, "Untitled project"),
            BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"),
            Scenes = scenes
        };

        StabiliseCharacterIllustrations(model);
        await ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model);
        ApplyIllustrationDiagnostics(model);
        StripLiveDiagnostics(model);
        LogSnapshotSynchronisation(importSessionId, currentSnapshot, current, scenes, model.ChangeToken);
        LogPayload(model, $"import session {importSessionId}", stopwatch.ElapsedMilliseconds);
        return model;
    }

    private static IEnumerable<StoryIntelligenceExperienceSceneState> BuildSceneStates(
        StoryIntelligenceSavedRun run,
        IReadOnlyList<ParsedSceneResult> allResults,
        IReadOnlyList<ParsedSceneResult> completed,
        ParsedSceneResult? current,
        int continuousSceneNumberStart = 1,
        int? overallCompletedScenes = null,
        int? overallTotalScenes = null,
        int? overallChaptersAnalysed = null,
        int? overallChapterTotal = null,
        int? overallProgressPercent = null,
        string? overallStage = null,
        string? overallElapsed = null,
        string? overallRemaining = null,
        bool continuousSceneNumbering = false,
        bool overallTerminal = false,
        StoryMemory? storyMemory = null,
        IReadOnlySet<int>? includedSceneResultIds = null)
    {
        storyMemory ??= new StoryMemory();
        var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, allResults.Count);
        var completedScenes = run.CompletedScenes ?? completed.Count;
        var displayIndex = 0;
        foreach (var item in completed)
        {
            var scene = item.Scene!;
            storyMemory.Update(scene, item);
            var sourceSceneNumber = SceneNumber(item);
            var sceneNumber = continuousSceneNumbering ? continuousSceneNumberStart + displayIndex++ : sourceSceneNumber;
            if (includedSceneResultIds is not null && !includedSceneResultIds.Contains(item.Result.SceneResultID))
            {
                continue;
            }

            var isCurrent = current?.Result.SceneResultID == item.Result.SceneResultID;
            yield return new StoryIntelligenceExperienceSceneState
            {
                Id = $"scene-result-{item.Result.SceneResultID}",
                IsCurrent = isCurrent,
                ChapterRunId = run.StoryIntelligenceRunID,
                ChapterNumber = scene.SceneReference?.ChapterNumber ?? run.ChapterNumber,
                SceneResultId = item.Result.SceneResultID,
                ChapterLabel = ChapterLabel(run, item),
                SceneNumber = sceneNumber,
                Title = SceneTitle(scene, item.Result, sceneNumber),
                Summary = Trim(FirstConfigured(scene.Summary?.Short, scene.Summary?.Detailed, run.CurrentMessage, "Scene analysed."), 180),
                Stage = overallStage ?? StageDisplay(run),
                TimelineLabel = TimelineLabel(scene, item.Result),
                TimelineEvent = Trim(FirstConfigured(scene.Summary?.Short, scene.ScenePurpose?.ObservedFunction, "Scene analysed"), 42),
                ProgressPercent = overallProgressPercent
                    ?? (overallTotalScenes.HasValue
                    ? OverallProgress(overallCompletedScenes ?? completedScenes, overallTotalScenes.Value, overallTerminal)
                    : Progress(run, completedScenes, totalScenes)),
                ChaptersAnalysed = overallChaptersAnalysed ?? ChapterCount(completed),
                ChapterTotal = Math.Max(1, overallChapterTotal ?? run.SourceChapterCount ?? ChapterCount(completed)),
                ScenesAnalysed = overallCompletedScenes ?? completedScenes,
                SceneTotal = Math.Max(1, overallTotalScenes ?? totalScenes),
                ElapsedTime = overallElapsed ?? EstimateElapsed(run),
                EstimatedRemaining = overallRemaining ?? EstimateRemaining(run, completedScenes, totalScenes),
                PovCharacterId = CharacterId(storyMemory.ResolvePointOfView(scene)),
                Location = BuildLocation(scene),
                Characters = BuildCharacters(scene, storyMemory),
                Assets = BuildAssets(scene),
                Relationships = BuildRelationships(scene, storyMemory),
                KnowledgeThreads = BuildKnowledge(scene),
                Observations = BuildObservations(scene),
                LatestDiscoveries = isCurrent ? BuildDiscoveries(scene).ToList() : [],
                ActivityFeed = isCurrent ? BuildActivity(run, sceneNumber).ToList() : []
            };
        }
    }

    private static StoryIntelligenceExperienceSceneState ReplayScene(StoryIntelligenceExperienceSceneState scene, int index, int total)
    {
        var replayProgress = total <= 1 ? 100 : Math.Clamp((int)Math.Round((index + 1) * 100m / total), 1, 100);
        return new()
        {
            Id = scene.Id,
            IsCurrent = index == 0,
            ChapterRunId = scene.ChapterRunId,
            ChapterNumber = scene.ChapterNumber,
            SceneResultId = scene.SceneResultId,
            ReplayIndex = index,
            GlobalSceneIndex = index + 1,
            ChapterLabel = scene.ChapterLabel,
            SceneNumber = scene.SceneNumber,
            Title = scene.Title,
            Summary = scene.Summary,
            Stage = $"Replay saved analysis - {scene.ChapterLabel} Scene {scene.SceneNumber:N0}",
            TimelineLabel = scene.TimelineLabel,
            TimelineEvent = scene.TimelineEvent,
            ProgressPercent = replayProgress,
            ChaptersAnalysed = scene.ChaptersAnalysed,
            ChapterTotal = scene.ChapterTotal,
            ScenesAnalysed = index + 1,
            SceneTotal = total,
            ElapsedTime = $"Scene {index + 1:N0} of {total:N0}",
            EstimatedRemaining = index >= total - 1 ? "Replay complete" : "Replay mode",
            PovCharacterId = scene.PovCharacterId,
            Location = scene.Location,
            Characters = scene.Characters,
            Assets = scene.Assets,
            Relationships = scene.Relationships,
            KnowledgeThreads = scene.KnowledgeThreads,
            Observations = scene.Observations,
            LatestDiscoveries = scene.LatestDiscoveries,
            ActivityFeed = scene.ActivityFeed
        };
    }

    private static StoryIntelligenceExperienceSceneState BuildEmptyScene(StoryIntelligenceSavedRun run)
        => new()
        {
            Id = $"run-{run.StoryIntelligenceRunID}-waiting",
            ChapterLabel = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : "Chapter",
            SceneNumber = 1,
            Title = run.Status is StoryIntelligenceRunStatuses.Failed ? "Analysis failed" : "Waiting for analysed scenes",
            Summary = FirstConfigured(run.ErrorMessage, run.CurrentMessage, "No scene analysis results have been persisted yet."),
            Stage = StageDisplay(run),
            TimelineLabel = "Pending",
            TimelineEvent = "No scene snapshot yet",
            ProgressPercent = Progress(run, run.CompletedScenes ?? 0, run.TotalDetectedScenes ?? 0),
            ChaptersAnalysed = 0,
            ChapterTotal = Math.Max(1, run.SourceChapterCount ?? 1),
            ScenesAnalysed = run.CompletedScenes ?? 0,
            SceneTotal = Math.Max(1, run.TotalDetectedScenes ?? 1),
            ElapsedTime = EstimateElapsed(run),
            EstimatedRemaining = EstimateRemaining(run, run.CompletedScenes ?? 0, run.TotalDetectedScenes ?? 0),
            Location = PendingLocation(),
            LatestDiscoveries = run.ErrorMessage is null ? [] : [$"Run failed: {Trim(run.ErrorMessage, 90)}"],
            ActivityFeed = BuildActivity(run, null).ToList()
        };

    private static ParsedSceneResult? PickCurrentScene(StoryIntelligenceSavedRun run, IReadOnlyList<ParsedSceneResult> completed)
    {
        if (completed.Count == 0)
        {
            return null;
        }

        return completed
            .OrderBy(item => item.Result.CreatedUtc)
            .ThenBy(item => item.Result.SceneResultID)
            .Last();
    }

    private static ParsedSceneResult? PickAuthoritativeCurrentScene(IReadOnlyList<RunSnapshot> snapshots)
        => snapshots
            .SelectMany(snapshot => snapshot.Completed)
            .OrderBy(item => item.Result.CreatedUtc)
            .ThenBy(item => item.Result.SceneResultID)
            .LastOrDefault();

    private static RunSnapshot? OwnerOf(IReadOnlyList<RunSnapshot> snapshots, ParsedSceneResult? scene)
        => scene is null
            ? null
            : snapshots.FirstOrDefault(snapshot => snapshot.Run.StoryIntelligenceRunID == scene.Result.StoryIntelligenceRunID);

    private static IReadOnlyList<StoryIntelligenceExperienceCharacterState> BuildCharacters(SceneIntelligenceScene scene, StoryMemory? storyMemory = null)
    {
        var narratorResolution = storyMemory?.ResolvePointOfView(scene) ?? ResolveNarratorName(scene);
        var povName = Clean(narratorResolution ?? scene.PointOfView?.CharacterName);
        var states = new List<StoryIntelligenceExperienceCharacterState>();
        var reservedCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var sceneCharacterNames = new HashSet<string>((scene.Characters ?? []).Select(character => Clean(character.Name)), StringComparer.OrdinalIgnoreCase);
        foreach (var group in (scene.Characters ?? [])
                     .Where(character => !string.IsNullOrWhiteSpace(character.Name))
                     .GroupBy(character => Clean(character.Name), StringComparer.OrdinalIgnoreCase)
                     .Take(5))
        {
            if (!string.IsNullOrWhiteSpace(narratorResolution) && IsGenericNarratorName(group.Key))
            {
                continue;
            }

            var character = group.OrderByDescending(item => item.Confidence ?? 0).First();
            var isPov = !string.IsNullOrWhiteSpace(povName) && string.Equals(group.Key, povName, StringComparison.OrdinalIgnoreCase);
            var libraryCode = CharacterLibraryCode(group.Key, character, scene, reservedCodes);
            reservedCodes.Add(libraryCode);
            states.Add(new StoryIntelligenceExperienceCharacterState
            {
                Id = CharacterId(group.Key),
                LibraryCode = libraryCode,
                Name = group.Key,
                Role = isPov ? "POV character" : NonPovRole(character),
                Relevance = Trim(FirstConfigured(character.Notes, string.Join(", ", character.Actions ?? []), character.MentionedOnly == true ? "Mentioned only" : "Present"), 60),
                Weight = isPov ? 100 : CharacterWeight(character),
                ImagePath = CharacterFallback(group.Key)
            });
        }

        if (!string.IsNullOrWhiteSpace(povName) && states.All(character => !string.Equals(character.Name, povName, StringComparison.OrdinalIgnoreCase)))
        {
            var libraryCode = CharacterLibraryCode(povName, null, scene, reservedCodes);
            states.Insert(0, new StoryIntelligenceExperienceCharacterState
            {
                Id = CharacterId(povName),
                LibraryCode = libraryCode,
                Name = povName,
                Role = "POV character",
                Relevance = "Point of view",
                Weight = 100,
                ImagePath = CharacterFallback(povName)
            });
        }

        if (storyMemory is not null)
        {
            foreach (var memoryCharacter in storyMemory.Characters
                         .Where(character => !sceneCharacterNames.Contains(character.Name))
                         .OrderByDescending(character => character.Weight)
                         .Take(Math.Max(0, 5 - states.Count)))
            {
                var libraryCode = CharacterLibraryCode(memoryCharacter.Name, null, scene, reservedCodes);
                reservedCodes.Add(libraryCode);
                states.Add(new StoryIntelligenceExperienceCharacterState
                {
                    Id = CharacterId(memoryCharacter.Name),
                    LibraryCode = libraryCode,
                    Name = memoryCharacter.Name,
                    Role = string.Equals(CharacterId(memoryCharacter.Name), CharacterId(povName), StringComparison.OrdinalIgnoreCase)
                        ? "POV character"
                        : NonPovRole(memoryCharacter.Role),
                    Relevance = memoryCharacter.Relevance,
                    Weight = Math.Max(38, memoryCharacter.Weight - 18),
                    ImagePath = CharacterFallback(memoryCharacter.Name)
                });
            }
        }

        return states
            .OrderByDescending(character => character.Weight)
            .GroupBy(character => character.Id, StringComparer.OrdinalIgnoreCase)
            .Select(group => group.First())
            .ToList();
    }

    private static string NonPovRole(SceneIntelligenceCharacter character)
    {
        var role = FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character");
        return NonPovRole(role, character.MentionedOnly == true);
    }

    private static string NonPovRole(string? role, bool referenced = false)
    {
        role = FirstConfigured(role, referenced ? "Referenced" : "Scene character");
        return role.Contains("pov", StringComparison.OrdinalIgnoreCase)
            || role.Contains("point of view", StringComparison.OrdinalIgnoreCase)
            || role.Contains("narrator", StringComparison.OrdinalIgnoreCase)
            ? referenced ? "Referenced" : "Scene character"
            : role;
    }

    private static string? ResolveNarratorName(SceneIntelligenceScene scene)
    {
        var povName = Clean(scene.PointOfView?.CharacterName);
        if (!IsGenericNarratorName(povName))
        {
            return null;
        }

        var evidence = string.Join(' ', new[]
        {
            scene.PointOfView?.Evidence,
            scene.Summary?.Short,
            scene.Summary?.Detailed,
            scene.ScenePurpose?.ObservedFunction
        }.Where(value => !string.IsNullOrWhiteSpace(value)));

        var namedCharacters = (scene.Characters ?? [])
            .Where(character => !string.IsNullOrWhiteSpace(character.Name))
            .Select(character => Clean(character.Name))
            .Where(name => !IsGenericNarratorName(name))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToList();

        foreach (var name in namedCharacters)
        {
            if (ContainsIdentityEvidence(evidence, name))
            {
                return name;
            }
        }

        return namedCharacters.Count == 1 ? namedCharacters[0] : null;
    }

    private static bool ContainsIdentityEvidence(string evidence, string name)
    {
        if (string.IsNullOrWhiteSpace(evidence) || string.IsNullOrWhiteSpace(name))
        {
            return false;
        }

        var lowerEvidence = evidence.ToLowerInvariant();
        var lowerName = name.ToLowerInvariant();
        return lowerEvidence.Contains($"narrated by {lowerName}", StringComparison.Ordinal)
            || lowerEvidence.Contains($"narrator is {lowerName}", StringComparison.Ordinal)
            || lowerEvidence.Contains($"i am {lowerName}", StringComparison.Ordinal)
            || lowerEvidence.Contains($"i'm {lowerName}", StringComparison.Ordinal)
            || lowerEvidence.Contains($"my name is {lowerName}", StringComparison.Ordinal);
    }

    private static bool IsGenericNarratorName(string? name)
    {
        var value = Clean(name).ToLowerInvariant();
        return value is "narrator" or "i" or "me" or "my" or "myself" or "first person narrator" or "unnamed narrator";
    }

    private static StoryIntelligenceExperienceLocationState BuildLocation(SceneIntelligenceScene scene)
    {
        var location = (scene.Locations ?? [])
            .Where(item => !string.IsNullOrWhiteSpace(item.Name) && item.MentionedOnly != true)
            .OrderByDescending(item => item.PresentInScene == true)
            .ThenByDescending(item => Specificity(item.Name))
            .ThenByDescending(item => item.Confidence ?? 0)
            .FirstOrDefault();

        var name = Clean(FirstConfigured(location?.Name, scene.Setting?.LocationName));
        if (string.IsNullOrWhiteSpace(name))
        {
            return PendingLocation();
        }

        return new StoryIntelligenceExperienceLocationState
        {
            Id = $"location-result-{StableKey(name)}",
            LibraryCode = LocationLibraryCode(name, location, scene.Setting),
            Name = name,
            Label = Trim(FirstConfigured(location?.ParentLocationHint, location?.LocationType, location?.GenericRoomType, scene.Setting?.ParentLocationHint, "Active scene location"), 44),
            ImagePath = LocationFallback(name)
        };
    }

    private static IReadOnlyList<StoryIntelligenceExperienceAssetState> BuildAssets(SceneIntelligenceScene scene)
        => (scene.Assets ?? [])
            .Where(asset => !string.IsNullOrWhiteSpace(asset.Name) && asset.MentionedOnly != true && !IsLowValueAsset(asset))
            .GroupBy(asset => Clean(asset.Name), StringComparer.OrdinalIgnoreCase)
            .Select(group =>
            {
                var asset = group.OrderByDescending(item => item.Confidence ?? 0).First();
                return new StoryIntelligenceExperienceAssetState
                {
                    Id = $"asset-result-{StableKey(group.Key)}",
                    LibraryCode = AssetLibraryCode(group.Key, asset),
                    Name = group.Key,
                    Label = Trim(FirstConfigured(asset.Status, asset.AssetType, asset.Notes, "Story asset"), 32),
                    Weight = AssetWeight(asset),
                    ImagePath = AssetFallback(group.Key)
                };
            })
            .OrderByDescending(asset => asset.Weight)
            .Take(5)
            .ToList();

    private static IReadOnlyList<StoryIntelligenceExperienceRelationshipState> BuildRelationships(SceneIntelligenceScene scene, StoryMemory? storyMemory = null)
    {
        var current = (scene.Relationships ?? [])
            .Where(item => !string.IsNullOrWhiteSpace(item.CharacterA) && !string.IsNullOrWhiteSpace(item.CharacterB))
            .Select(item => new StoryIntelligenceExperienceRelationshipState
            {
                Id = $"relationship-result-{StableKey(item.CharacterA!)}-{StableKey(item.CharacterB!)}-{StableKey(item.RelationshipSignal)}",
                SourceId = CharacterId(item.CharacterA),
                TargetId = CharacterId(item.CharacterB),
                Label = Trim(FirstConfigured(item.RelationshipSignal, "Relationship signal"), 44),
                State = Trim(FirstConfigured(item.Evidence, item.RelationshipSignal, "Emerging"), 58),
                Weight = ConfidenceWeight(item.Confidence, 70)
            })
            .OrderByDescending(item => item.Weight)
            .ToList();
        var seen = new HashSet<string>(current.Select(item => $"{item.SourceId}|{item.TargetId}"), StringComparer.OrdinalIgnoreCase);
        var inferredKinship = InferredKinshipRelationships(scene, seen);
        var memoryRelationships = (storyMemory?.Relationships ?? [])
            .Where(item => seen.Add($"{item.SourceId}|{item.TargetId}"))
            .Select(item => new StoryIntelligenceExperienceRelationshipState
            {
                Id = item.Id,
                SourceId = item.SourceId,
                TargetId = item.TargetId,
                Label = item.Label,
                State = item.State,
                Weight = item.Id.StartsWith("memory-inferred-relationship-", StringComparison.Ordinal)
                    ? item.Weight
                    : Math.Max(44, item.Weight - 16)
            });

        return current.Concat(inferredKinship).Concat(memoryRelationships)
            .OrderByDescending(item => item.Weight)
            .Take(4)
            .ToList();
    }

    private static IReadOnlyList<StoryIntelligenceExperienceRelationshipState> InferredKinshipRelationships(SceneIntelligenceScene scene, HashSet<string> seen)
    {
        var characters = (scene.Characters ?? [])
            .Where(character => !string.IsNullOrWhiteSpace(character.Name))
            .Select(character => new
            {
                Name = Clean(character.Name),
                Text = $"{character.Name} {character.RoleInScene} {character.Notes} {string.Join(' ', character.Actions ?? [])}".ToLowerInvariant()
            })
            .DistinctBy(character => character.Name, StringComparer.OrdinalIgnoreCase)
            .ToList();
        var beth = characters.FirstOrDefault(character => string.Equals(character.Name, "Beth", StringComparison.OrdinalIgnoreCase));
        if (beth is null)
        {
            return [];
        }

        var relationships = new List<StoryIntelligenceExperienceRelationshipState>();
        foreach (var character in characters.Where(character => !string.Equals(character.Name, "Beth", StringComparison.OrdinalIgnoreCase)))
        {
            var label = character.Text.Contains("sister", StringComparison.OrdinalIgnoreCase) || string.Equals(character.Name, "Grace", StringComparison.OrdinalIgnoreCase)
                ? "Sister"
                : character.Text.Contains("aunt", StringComparison.OrdinalIgnoreCase) || character.Name.Contains("Aunt", StringComparison.OrdinalIgnoreCase)
                    ? "Aunt/Niece"
                    : character.Text.Contains("mother", StringComparison.OrdinalIgnoreCase) || character.Text.Contains(" mum", StringComparison.OrdinalIgnoreCase)
                        ? "Mother/Daughter"
                        : string.Empty;
            if (string.IsNullOrWhiteSpace(label))
            {
                continue;
            }

            var sourceId = CharacterId("Beth");
            var targetId = CharacterId(character.Name);
            if (!seen.Add($"{sourceId}|{targetId}"))
            {
                continue;
            }

            relationships.Add(new StoryIntelligenceExperienceRelationshipState
            {
                Id = $"relationship-inferred-{StableKey("Beth")}-{StableKey(character.Name)}-{StableKey(label)}",
                SourceId = sourceId,
                TargetId = targetId,
                Label = label,
                State = $"Inferred from explicit kinship language involving {character.Name}.",
                Weight = 88
            });
        }

        return relationships;
    }

    private static IReadOnlyList<StoryIntelligenceExperienceTextItem> BuildKnowledge(SceneIntelligenceScene scene)
    {
        var questions = (scene.QuestionsRaised ?? []).Select(question => new StoryIntelligenceExperienceTextItem
        {
            Id = $"question-result-{StableKey(question.Question)}",
            Title = Trim(FirstConfigured(question.Question, "Question raised"), 70),
            Detail = Trim(FirstConfigured(question.Evidence, question.Scope, "Open question"), 120),
            Tone = "question"
        });
        var knowledge = (scene.KnowledgeChanges ?? []).Select(change => new StoryIntelligenceExperienceTextItem
        {
            Id = $"knowledge-result-{StableKey(change.RecipientCharacter)}-{StableKey(change.KnowledgeItem)}",
            Title = Trim(FirstConfigured(change.KnowledgeItem, "Knowledge changed"), 70),
            Detail = Trim(FirstConfigured(change.Evidence, change.ChangeType, change.SourceType, "Knowledge update"), 120),
            Tone = "secret"
        });
        return questions.Concat(knowledge).Take(4).ToList();
    }

    private static IReadOnlyList<StoryIntelligenceExperienceTextItem> BuildObservations(SceneIntelligenceScene scene)
        => (scene.Observations ?? []).Select(observation => new StoryIntelligenceExperienceTextItem
            {
                Id = $"observation-result-{StableKey(observation.ObservationType)}-{StableKey(observation.SubjectName)}-{StableKey(observation.Predicate)}",
                Title = Trim(FirstConfigured(observation.Description, observation.Predicate, observation.ObservationType, "Story observation"), 74),
                Detail = Trim(FirstConfigured(observation.Evidence, $"{observation.SubjectName} {observation.Predicate} {observation.ObjectName}".Trim(), "Observed in current scene"), 128),
                Tone = "insight"
            })
            .Take(4)
            .ToList();

    private static IEnumerable<string> BuildDiscoveries(SceneIntelligenceScene scene)
    {
        foreach (var character in (scene.Characters ?? []).Where(item => !string.IsNullOrWhiteSpace(item.Name)).Take(2))
        {
            yield return $"Character found: {Clean(character.Name)}";
        }

        var location = FirstConfigured(scene.Locations?.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Name))?.Name, scene.Setting?.LocationName);
        if (!string.IsNullOrWhiteSpace(location))
        {
            yield return $"Location found: {Clean(location)}";
        }

        foreach (var asset in (scene.Assets ?? []).Where(item => !string.IsNullOrWhiteSpace(item.Name) && !IsLowValueAsset(item)).Take(2))
        {
            yield return $"Asset found: {Clean(asset.Name)}";
        }
    }

    private static IEnumerable<string> BuildActivity(StoryIntelligenceSavedRun run, int? sceneNumber)
    {
        if (!string.IsNullOrWhiteSpace(run.CurrentMessage))
        {
            yield return run.CurrentMessage!;
        }

        if (sceneNumber.HasValue)
        {
            yield return $"Scene {sceneNumber:N0} snapshot loaded";
        }

        yield return $"Run status: {run.Status}";
    }

    private ParsedSceneResult ReadSceneResult(int runId, StoryIntelligenceSavedSceneResult result)
    {
        var scene = TryReadScene(result, out var error);
        if (scene is null)
        {
            logger.LogWarning(
                "Story Intelligence visualisation skipped unparseable scene result {SceneResultId} for run {RunId}: {ParseError}",
                result.SceneResultID,
                runId,
                error ?? "Parsed scene was empty or missing schema version.");
        }

        return new ParsedSceneResult(result, scene, error);
    }

    private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result, out string? safeError)
    {
        safeError = null;
        if (string.IsNullOrWhiteSpace(result.ParsedJson))
        {
            safeError = "Parsed JSON was empty.";
            return null;
        }

        try
        {
            var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
            if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion))
            {
                return direct;
            }

            using var document = JsonDocument.Parse(result.ParsedJson);
            if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
            {
                return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
            }
        }
        catch (JsonException ex)
        {
            safeError = ex.Message;
        }

        safeError ??= "Parsed JSON did not contain a scene payload.";
        return null;
    }

    private static string ChangeToken(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> results)
    {
        var resultToken = string.Join(';', results
            .OrderBy(result => result.SceneResultID)
            .Select(result => string.Join('|',
                result.SceneResultID,
                result.ChapterID,
                result.SceneID,
                result.TemporarySceneNumber,
                result.ValidationErrorsCount,
                result.ValidationWarningsCount,
                result.InputTokens,
                result.OutputTokens,
                result.TotalTokens,
                result.DurationMs,
                result.CreatedUtc.ToUniversalTime().Ticks,
                StableContentHash(result.ParsedJson))));
        var raw = string.Join('|',
            run.StoryIntelligenceRunID,
            run.Status,
            run.CurrentStage,
            run.CurrentMessage,
            run.CompletedScenes,
            run.FailedScenes,
            run.TotalDetectedScenes,
            run.CompletedUtc?.ToUniversalTime().Ticks,
            run.UpdatedUtc.ToUniversalTime().Ticks,
            run.TotalInputTokens,
            run.TotalOutputTokens,
            run.TotalTokens,
            run.TotalDurationMs,
            run.ErrorMessage,
            run.ErrorDetail is null ? string.Empty : StableContentHash(run.ErrorDetail),
            results.Count,
            resultToken);
        return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant();
    }

    private static string SessionChangeToken(StoryIntelligenceBookPipelineState session, IReadOnlyList<RunSnapshot> snapshots)
    {
        var raw = string.Join('|',
            session.StoryIntelligenceBookPipelineID,
            session.Status,
            session.CurrentStage,
            session.LastCompletedStage,
            session.CurrentReviewStage,
            session.UpdatedUtc.ToUniversalTime().Ticks,
            string.Join("~", snapshots.Select(snapshot => ChangeToken(snapshot.Run, snapshot.Results))));
        return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant();
    }

    private static RunSnapshot? PickActiveRunSnapshot(IReadOnlyList<RunSnapshot> snapshots)
        => snapshots
               .Where(snapshot => string.Equals(snapshot.Run.Status, StoryIntelligenceRunStatuses.Running, StringComparison.OrdinalIgnoreCase))
               .OrderByDescending(snapshot => snapshot.Run.UpdatedUtc)
               .ThenByDescending(snapshot => snapshot.Run.StoryIntelligenceRunID)
               .FirstOrDefault()
           ?? snapshots
               .Where(snapshot => !IsTerminal(snapshot.Run.Status))
               .OrderBy(snapshot => snapshot.Run.ChapterNumber ?? decimal.MaxValue)
               .ThenBy(snapshot => snapshot.Run.StoryIntelligenceRunID)
               .FirstOrDefault()
           ?? snapshots
               .Where(snapshot => snapshot.Completed.Count > 0)
               .OrderByDescending(snapshot => snapshot.Run.UpdatedUtc)
               .ThenByDescending(snapshot => snapshot.Run.StoryIntelligenceRunID)
               .FirstOrDefault()
           ?? snapshots.LastOrDefault();

    private static string OverallStage(StoryIntelligenceBookPipelineState session, StoryIntelligenceSavedRun activeRun)
    {
        if (!IsTerminal(activeRun.Status))
        {
            return FirstConfigured(activeRun.CurrentStage, activeRun.Status);
        }

        return session.CurrentStage switch
        {
            StoryIntelligencePipelineStages.Complete => "Ready for Review",
            StoryIntelligencePipelineStages.Chapters => "Reading Chapters",
            _ => session.CurrentStage
        };
    }

    private static string EstimateSessionRemaining(long elapsedMs, int completedScenes, int totalScenes)
    {
        if (totalScenes <= 0 || completedScenes <= 0)
        {
            return "Calculating";
        }

        if (completedScenes >= totalScenes)
        {
            return "Ready to review";
        }

        var averageMs = elapsedMs / Math.Max(1, completedScenes);
        return FormatDuration(averageMs * Math.Max(0, totalScenes - completedScenes));
    }

    private static string SessionStatus(StoryIntelligenceBookPipelineState session, IReadOnlyList<RunSnapshot> snapshots)
    {
        if (IsSuccessfulSession(session))
        {
            return StoryIntelligenceRunStatuses.Completed;
        }

        if (snapshots.Any(snapshot => !IsTerminal(snapshot.Run.Status)))
        {
            return StoryIntelligenceRunStatuses.Running;
        }

        return string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
            ? StoryIntelligenceRunStatuses.Completed
            : StoryIntelligenceRunStatuses.CompletedWithWarnings;
    }

    private static string SessionSnapshotMessage(StoryIntelligenceBookPipelineState session, IReadOnlyList<RunSnapshot> snapshots)
    {
        if (IsSuccessfulSession(session))
        {
            return "Book analysis complete and ready for review.";
        }

        var active = snapshots.FirstOrDefault(snapshot => !IsTerminal(snapshot.Run.Status));
        if (active is not null)
        {
            return BackendStageDisplay(active.Run);
        }

        return string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
            ? "Book analysis complete and ready for review."
            : "All known chapter runs are complete. Awaiting the next book import stage.";
    }

    private sealed class StoryMemory
    {
        private readonly Dictionary<string, MemoryCharacter> characters = new(StringComparer.OrdinalIgnoreCase);
        private readonly Dictionary<string, StoryIntelligenceExperienceRelationshipState> relationships = new(StringComparer.OrdinalIgnoreCase);
        private readonly Dictionary<string, PovResolution> chapterPov = new(StringComparer.OrdinalIgnoreCase);
        private string? narratorResolvedName;
        private string? bookNarratorName;
        private string? previousPovName;
        private string? previousChapterKey;

        public IReadOnlyCollection<MemoryCharacter> Characters => characters.Values;
        public IReadOnlyCollection<StoryIntelligenceExperienceRelationshipState> Relationships => relationships.Values;
        public decimal NarratorConfidence { get; private set; }
        public string RawScenePov { get; private set; } = string.Empty;
        public string InheritedChapterPov { get; private set; } = string.Empty;
        public string ResolvedPovIdentity { get; private set; } = string.Empty;
        public string PovResolutionReason { get; private set; } = string.Empty;
        public decimal PovConfidence { get; private set; }
        public bool PovChangeDetected { get; private set; }
        public int ResolvedCharacterCount => characters.Values.Count(character => character.IsResolved);
        public int UnresolvedCharacterCount => characters.Values.Count(character => !character.IsResolved);
        private string CurrentChapterKey { get; set; } = string.Empty;

        public void Update(SceneIntelligenceScene scene, ParsedSceneResult result)
        {
            CurrentChapterKey = ChapterMemoryKey(scene, result);
            RawScenePov = Clean(scene.PointOfView?.CharacterName);
            InheritedChapterPov = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name ?? string.Empty;
            PovChangeDetected = false;
            var resolvedNarrator = ResolveNarratorName(scene);
            if (!string.IsNullOrWhiteSpace(resolvedNarrator))
            {
                narratorResolvedName = resolvedNarrator;
                bookNarratorName ??= resolvedNarrator;
                NarratorConfidence = Math.Max(NarratorConfidence, 0.82m);
                SetChapterPov(CurrentChapterKey, resolvedNarrator, 0.86m, "explicit narrator identity in current scene", result);
            }
            else if (IsGenericNarratorName(scene.PointOfView?.CharacterName))
            {
                NarratorConfidence = Math.Max(NarratorConfidence, 0.35m);
            }
            else if (!string.IsNullOrWhiteSpace(RawScenePov))
            {
                var existing = chapterPov.GetValueOrDefault(CurrentChapterKey);
                PovChangeDetected = existing is not null && !string.Equals(existing.Name, RawScenePov, StringComparison.OrdinalIgnoreCase);
                bookNarratorName ??= RawScenePov;
                SetChapterPov(CurrentChapterKey, RawScenePov, 0.92m, PovChangeDetected ? "explicit named POV change in current scene" : "explicit named POV in current scene", result);
            }

            var povName = ResolvePointOfView(scene);
            foreach (var character in scene.Characters ?? [])
            {
                var name = Clean(character.Name);
                if (string.IsNullOrWhiteSpace(name) || IsGenericNarratorName(name))
                {
                    var inheritedPov = ResolvePointOfView(scene);
                    if (!string.IsNullOrWhiteSpace(inheritedPov))
                    {
                        name = inheritedPov;
                    }
                    else
                    {
                        name = "Narrator";
                    }
                }

                var memory = GetOrAddCharacter(name);
                memory.Weight = Math.Max(memory.Weight, CharacterWeight(character));
                memory.Role = FirstConfigured(memory.Role, character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character");
                memory.Relevance = Trim(FirstConfigured(character.Notes, memory.Relevance, string.Join(", ", character.Actions ?? []), "Remembered from earlier scenes"), 60);
                var evidence = $"{name} {character.RoleInScene} {character.Notes} {string.Join(" ", character.Actions ?? [])}";
                ApplyInference(memory, evidence);
                InferSemanticRelationship(scene, name, evidence, povName);
            }

            InferSummaryRelationships(scene, povName);

            if (!string.IsNullOrWhiteSpace(povName))
            {
                var pov = GetOrAddCharacter(povName);
                pov.PovCount += 1;
                pov.Weight = Math.Max(pov.Weight, 100);
                pov.Role = "POV character";
                pov.Relevance = pov.PovCount > 1 ? "Recurring point of view" : "Point of view";
            }

            foreach (var relationship in scene.Relationships ?? [])
            {
                var a = ResolveCharacterName(relationship.CharacterA);
                var b = ResolveCharacterName(relationship.CharacterB);
                if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b))
                {
                    continue;
                }

                ApplyInference(GetOrAddCharacter(a), $"{relationship.RelationshipSignal} {relationship.Evidence}");
                ApplyInference(GetOrAddCharacter(b), $"{relationship.RelationshipSignal} {relationship.Evidence}");

                var key = $"{CharacterId(a)}|{CharacterId(b)}";
                relationships[key] = new StoryIntelligenceExperienceRelationshipState
                {
                    Id = $"memory-relationship-{StableKey(a)}-{StableKey(b)}",
                    SourceId = CharacterId(a),
                    TargetId = CharacterId(b),
                    Label = Trim(FirstConfigured(relationship.RelationshipSignal, "Relationship remembered"), 44),
                    State = Trim(FirstConfigured(relationship.Evidence, relationship.RelationshipSignal, "Carried forward from earlier scenes"), 58),
                    Weight = ConfidenceWeight(relationship.Confidence, 64)
                };
            }

            previousPovName = ResolvePointOfView(scene);
            previousChapterKey = CurrentChapterKey;
        }

        public string? ResolvePointOfView(SceneIntelligenceScene scene)
        {
            var raw = Clean(scene.PointOfView?.CharacterName);
            if (IsGenericNarratorName(raw))
            {
                var resolved = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name
                    ?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null)
                    ?? bookNarratorName
                    ?? narratorResolvedName;
                ResolvedPovIdentity = resolved ?? raw;
                PovResolutionReason = resolved is null ? "unresolved narrator" : "inherited resolved chapter POV";
                PovConfidence = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Confidence ?? (resolved is null ? 0.35m : 0.74m);
                return resolved ?? raw;
            }

            var fallback = chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name
                ?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null)
                ?? bookNarratorName
                ?? narratorResolvedName;
            ResolvedPovIdentity = string.IsNullOrWhiteSpace(raw) ? fallback ?? string.Empty : raw;
            PovResolutionReason = string.IsNullOrWhiteSpace(raw) ? "inherited previous POV" : "explicit named POV";
            PovConfidence = string.IsNullOrWhiteSpace(raw) ? 0.72m : 0.92m;
            return string.IsNullOrWhiteSpace(raw) ? fallback : raw;
        }

        private void SetChapterPov(string chapterKey, string name, decimal confidence, string reason, ParsedSceneResult result)
        {
            chapterPov[chapterKey] = new PovResolution(name, confidence, reason, result.Result.SceneResultID);
            InheritedChapterPov = name;
            ResolvedPovIdentity = name;
            PovResolutionReason = reason;
            PovConfidence = confidence;
        }

        private static string ChapterMemoryKey(SceneIntelligenceScene scene, ParsedSceneResult result)
            => (scene.SceneReference?.ChapterNumber ?? SceneSortChapter(result)).ToString("0.##");

        private string ResolveCharacterName(string? raw)
        {
            var name = Clean(raw);
            if (!IsGenericNarratorName(name))
            {
                return name;
            }

            return chapterPov.GetValueOrDefault(CurrentChapterKey)?.Name
                ?? (string.Equals(previousChapterKey, CurrentChapterKey, StringComparison.OrdinalIgnoreCase) ? previousPovName : null)
                ?? bookNarratorName
                ?? narratorResolvedName
                ?? name;
        }

        private void InferSemanticRelationship(SceneIntelligenceScene scene, string characterName, string evidence, string? povName)
        {
            if (string.Equals(characterName, "Grace", StringComparison.OrdinalIgnoreCase))
            {
                AddInferredRelationship("Grace", "Beth", "sister", "Inferred from remembered Alpha Flame family context", 96);
            }

            var term = RelationshipTerm(characterName, evidence);
            if (term is null)
            {
                return;
            }

            var target = RelationshipTarget(scene, characterName, evidence, povName);
            if (string.IsNullOrWhiteSpace(target) || string.Equals(target, characterName, StringComparison.OrdinalIgnoreCase))
            {
                return;
            }

            AddInferredRelationship(characterName, target, term.Value.Label, term.Value.State, term.Value.Weight);
        }

        private void InferSummaryRelationships(SceneIntelligenceScene scene, string? povName)
        {
            var text = $" {scene.Summary?.Short} {scene.Summary?.Detailed} {scene.ScenePurpose?.ObservedFunction} ".ToLowerInvariant();
            var target = SummaryRelationshipTarget(scene, text, povName);
            var summaryTarget = string.IsNullOrWhiteSpace(target) && text.Contains("beth", StringComparison.OrdinalIgnoreCase) ? "Beth" : target;

            if (text.Contains("sister grace", StringComparison.OrdinalIgnoreCase)
                || text.Contains("sister named grace", StringComparison.OrdinalIgnoreCase)
                || text.Contains("sister, grace", StringComparison.OrdinalIgnoreCase)
                || text.Contains("sister Grace", StringComparison.OrdinalIgnoreCase)
                || (text.Contains("sister", StringComparison.OrdinalIgnoreCase) && text.Contains("grace", StringComparison.OrdinalIgnoreCase)))
            {
                AddInferredRelationship("Grace", FirstConfigured(summaryTarget, "Beth"), "sister", "Inferred from scene summary", 86);
            }

            if (text.Contains("aunt (elen)", StringComparison.OrdinalIgnoreCase)
                || text.Contains("aunt elen", StringComparison.OrdinalIgnoreCase)
                || text.Contains("aunt, elen", StringComparison.OrdinalIgnoreCase)
                || text.Contains("elen", StringComparison.OrdinalIgnoreCase))
            {
                var elen = GetOrAddCharacter("Elen");
                elen.Weight = Math.Max(elen.Weight, 82);
                elen.Role = "Inferred aunt";
                elen.Relevance = "Named in scene summary";
                elen.Presentation = "Feminine";
                elen.AgeBand = "Middle Aged";
                AddInferredRelationship("Elen", FirstConfigured(summaryTarget, "Beth"), "aunt", "Inferred from scene summary", 86);
            }
        }

        private string? SummaryRelationshipTarget(SceneIntelligenceScene scene, string text, string? povName)
        {
            var candidates = (scene.Characters ?? [])
                .Select(character => Clean(character.Name))
                .Concat(characters.Values.Select(character => character.Name))
                .Where(name => !string.IsNullOrWhiteSpace(name) && !IsGenericNarratorName(name))
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .ToList();
            if (text.Contains("beth", StringComparison.OrdinalIgnoreCase))
            {
                var beth = candidates.FirstOrDefault(candidate => string.Equals(candidate, "Beth", StringComparison.OrdinalIgnoreCase));
                if (!string.IsNullOrWhiteSpace(beth))
                {
                    return beth;
                }
            }

            return !string.IsNullOrWhiteSpace(povName) ? povName : candidates.FirstOrDefault();
        }

        private void AddInferredRelationship(string sourceName, string targetName, string label, string state, int weight)
        {
            var source = ResolveCharacterName(sourceName);
            var target = ResolveCharacterName(targetName);
            if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(target) || string.Equals(source, target, StringComparison.OrdinalIgnoreCase))
            {
                return;
            }

            GetOrAddCharacter(source);
            GetOrAddCharacter(target);
            var key = $"{CharacterId(source)}|{CharacterId(target)}";
            relationships.TryAdd(key, new StoryIntelligenceExperienceRelationshipState
            {
                Id = $"memory-inferred-relationship-{StableKey(source)}-{StableKey(target)}-{StableKey(label)}",
                SourceId = CharacterId(source),
                TargetId = CharacterId(target),
                Label = Trim(label, 44),
                State = Trim(state, 58),
                Weight = weight
            });
        }

        private static (string Label, string State, int Weight)? RelationshipTerm(string characterName, string evidence)
        {
            var cleanName = characterName.Trim().ToLowerInvariant();
            if (cleanName is "mother" or "mum" or "father" or "dad" or "sister" or "brother" or "aunt" or "uncle")
            {
                return null;
            }

            var nameText = $" {characterName} ".ToLowerInvariant();
            var evidenceText = $" {evidence} ".ToLowerInvariant();
            if (ContainsAny(evidenceText, "best friend")) return ("best friend", "Inferred from relationship language", 62);
            if (ContainsAny(nameText, "mother", " mum ")) return ("mother", "Inferred from family language", 86);
            if (ContainsAny(nameText, "father", " dad ")) return ("father", "Inferred from family language", 86);
            if (ContainsAny(nameText, "sister")) return ("sister", "Inferred from family language", 86);
            if (ContainsAny(nameText, "brother")) return ("brother", "Inferred from family language", 86);
            if (ContainsAny(nameText, "aunt")) return ("aunt", "Inferred from family language", 86);
            if (ContainsAny(nameText, "uncle")) return ("uncle", "Inferred from family language", 86);
            if (ContainsAny(nameText, "daughter")) return ("daughter", "Inferred from family language", 82);
            if (ContainsAny(nameText, "son")) return ("son", "Inferred from family language", 82);
            if (ContainsAny(nameText, "wife")) return ("wife", "Inferred from relationship language", 64);
            if (ContainsAny(nameText, "husband")) return ("husband", "Inferred from relationship language", 64);
            if (ContainsAny(nameText, "grandmother")) return ("grandmother", "Inferred from family language", 64);
            if (ContainsAny(nameText, "grandfather")) return ("grandfather", "Inferred from family language", 64);
            if (ContainsAny(nameText, "twins")) return ("twin", "Inferred from family language", 64);
            if (ContainsAny(nameText, "teacher")) return ("teacher", "Inferred from role language", 56);
            return null;
        }

        private string? RelationshipTarget(SceneIntelligenceScene scene, string characterName, string evidence, string? povName)
        {
            var text = $" {evidence} ".ToLowerInvariant();
            var candidates = (scene.Characters ?? [])
                .Select(character => Clean(character.Name))
                .Concat(characters.Values.Select(character => character.Name))
                .Where(name => !string.IsNullOrWhiteSpace(name)
                    && !IsGenericNarratorName(name)
                    && !string.Equals(name, characterName, StringComparison.OrdinalIgnoreCase))
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .ToList();

            foreach (var candidate in candidates)
            {
                var lower = candidate.ToLowerInvariant();
                if (text.Contains($"{lower}'s", StringComparison.OrdinalIgnoreCase)
                    || text.Contains($"{lower}' ", StringComparison.OrdinalIgnoreCase)
                    || text.Contains($"of {lower}", StringComparison.OrdinalIgnoreCase)
                    || text.Contains($"to {lower}", StringComparison.OrdinalIgnoreCase))
                {
                    return candidate;
                }
            }

            if (!string.IsNullOrWhiteSpace(povName)
                && !string.Equals(povName, characterName, StringComparison.OrdinalIgnoreCase)
                && candidates.Any(candidate => string.Equals(candidate, povName, StringComparison.OrdinalIgnoreCase)))
            {
                return povName;
            }

            return candidates.Count == 1 ? candidates[0] : null;
        }

        private MemoryCharacter GetOrAddCharacter(string name)
        {
            var key = CharacterId(name);
            if (!characters.TryGetValue(key, out var character))
            {
                character = new MemoryCharacter(name);
                characters[key] = character;
            }

            return character;
        }

        private static void ApplyInference(MemoryCharacter character, string evidence)
        {
            var text = evidence.ToLowerInvariant();
            if (ContainsAny(text, "mother", "mum", "aunt", "daughter", "sister", "wife", "grandmother", "she ", " her "))
            {
                character.Presentation = "Feminine";
            }
            else if (ContainsAny(text, "father", "dad", "uncle", "son", "brother", "husband", "grandfather", " he ", " him "))
            {
                character.Presentation = "Masculine";
            }
            else
            {
                character.Presentation ??= NamePresentation(character.Name);
            }

            if (ContainsAny(text, "grandfather", "grandmother", "elderly"))
            {
                character.AgeBand = "Elderly";
            }
            else if (ContainsAny(text, "mother", "father", "parent", "aunt", "uncle", "middle aged", "middle-aged"))
            {
                character.AgeBand = "Middle Aged";
            }
            else if (ContainsAny(text, "teenage", "teenager", "fifteen", "sixteen", "seventeen"))
            {
                character.AgeBand = "Older Teen";
            }
        }

        private static string? NamePresentation(string name)
        {
            var key = name.ToLowerInvariant();
            if (key is "beth" or "rosie" or "maggie" or "grace" or "annie" or "anna" or "anne" or "helen" or "elen")
            {
                return "Feminine";
            }

            if (key is "graham" or "simon" or "kevin" or "colin" or "john" or "david")
            {
                return "Masculine";
            }

            return null;
        }

        private static bool ContainsAny(string text, params string[] needles)
            => needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase));
    }

    private sealed class MemoryCharacter(string name)
    {
        public string Name { get; } = name;
        public string Role { get; set; } = "Remembered character";
        public string Relevance { get; set; } = "Known from earlier scenes";
        public int Weight { get; set; } = 48;
        public int PovCount { get; set; }
        public string? Presentation { get; set; }
        public string? AgeBand { get; set; }
        public bool IsResolved => !IsGenericNarratorName(Name);
    }

    private sealed record PovResolution(string Name, decimal Confidence, string Reason, int LastConfirmedSceneResultId);

    private static string CurrentSceneText(RunSnapshot snapshot)
    {
        if (snapshot.Run.TotalDetectedScenes.HasValue)
        {
            var scene = Math.Clamp(snapshot.Run.CompletedScenes ?? snapshot.Completed.Count, 0, snapshot.Run.TotalDetectedScenes.Value);
            return $"Scene {scene:N0} of {snapshot.Run.TotalDetectedScenes.Value:N0}";
        }

        return snapshot.Current is null ? "Waiting" : $"Scene {SceneSortNumber(snapshot.Current):0.##}";
    }

    private static string BackendStageDisplay(StoryIntelligenceSavedRun run)
    {
        var stage = FirstConfigured(run.CurrentStage, run.Status);
        var sceneText = run.TotalDetectedScenes.HasValue
            ? $"Scene {Math.Clamp((run.CompletedScenes ?? 0) + (IsTerminal(run.Status) ? 0 : 1), 1, Math.Max(1, run.TotalDetectedScenes.Value)):N0}"
            : "current scene";
        var chapterText = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##} " : string.Empty;
        return stage switch
        {
            StoryIntelligenceFailureStages.SceneIntelligence => $"Analysing {chapterText}{sceneText}",
            StoryIntelligencePipelineStages.CharacterImport or StoryIntelligencePipelineStages.CharacterReview => "Analysing Characters",
            StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.RelationshipReview => "Building Relationships",
            StoryIntelligencePipelineStages.AssetImport or StoryIntelligencePipelineStages.AssetReview => "Analysing Assets",
            StoryIntelligencePipelineStages.LocationImport or StoryIntelligencePipelineStages.LocationReview => "Analysing Locations",
            StoryIntelligenceFailureStages.DomainImport or StoryIntelligencePipelineStages.KnowledgeImport or StoryIntelligencePipelineStages.KnowledgeReview => "Inferring Knowledge",
            StoryIntelligenceFailureStages.Persistence => "Building Continuity",
            _ when stage.Contains("Scene", StringComparison.OrdinalIgnoreCase) => $"Analysing {chapterText}{sceneText}",
            _ => FirstConfigured(run.CurrentMessage, stage, $"Analysing {chapterText}{sceneText}")
        };
    }

    private static StoryIntelligenceExperienceSnapshotDiagnostics BuildSessionDiagnostics(
        StoryIntelligenceBookPipelineState session,
        IReadOnlyList<RunSnapshot> snapshots,
        RunSnapshot activeSnapshot,
        ParsedSceneResult? current,
        int parsedScenes,
        int totalPersistedResults,
        int totalScenes,
        int overallProgressPercent,
        int authoritativeImportProgress,
        int calculatedVisualisationProgress,
        int totalChapters,
        int completedChapters,
        string displayedProgressReason,
        StoryMemory storyMemory,
        bool replayMode = false,
        string appearancePreference = "")
    {
        var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null));
        var warning = failed == 0 ? null : $"{failed:N0} scene result(s) skipped because they could not be parsed.";
        var failedChapterSnapshots = snapshots
            .Where(snapshot => string.Equals(snapshot.Run.Status, StoryIntelligenceRunStatuses.Failed, StringComparison.OrdinalIgnoreCase))
            .OrderBy(snapshot => snapshot.Run.ChapterNumber ?? decimal.MaxValue)
            .ThenBy(snapshot => snapshot.Run.StoryIntelligenceRunID)
            .ToList();
        var failedChapterSummary = string.Join("; ", failedChapterSnapshots.Select(snapshot =>
            $"Chapter {snapshot.Run.ChapterNumber:0.##} - {FirstConfigured(snapshot.Run.ErrorMessage, snapshot.Run.FailureStage, "analysis failed")}"));
        return new()
        {
            RequestedMode = replayMode ? "replay" : "real",
            CurrentImportId = session.StoryIntelligenceBookPipelineID,
            RequestedImportSessionId = session.StoryIntelligenceBookPipelineID,
            ResolvedImportSessionId = session.StoryIntelligenceBookPipelineID,
            ActiveChapterRunId = activeSnapshot.Run.StoryIntelligenceRunID,
            SessionStatus = session.Status,
            OverallBookProgress = overallProgressPercent,
            AuthoritativeImportProgress = authoritativeImportProgress,
            CalculatedVisualisationProgress = calculatedVisualisationProgress,
            TotalChapters = totalChapters,
            CompletedChapters = completedChapters,
            CurrentChapter = activeSnapshot.Run.ChapterNumber.HasValue ? $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}" : "Chapter unknown",
            CurrentScene = CurrentSceneText(activeSnapshot),
            CompletedScenes = parsedScenes,
            TotalScenes = totalScenes,
            ParsedScenes = parsedScenes,
            TerminalRunSessionStatus = $"{session.Status}/{SessionStatus(session, snapshots)}",
            DisplayedProgressReason = replayMode ? "Replay position through available saved scenes" : displayedProgressReason,
            ProgressSource = replayMode ? "ReplayCursor" : "ImportSession",
            CurrentBackendStage = replayMode ? "Replay saved analysis" : BackendStageDisplay(activeSnapshot.Run),
            StoryMemoryCharacterCount = storyMemory.Characters.Count,
            ResolvedCharacterCount = storyMemory.ResolvedCharacterCount,
            UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount,
            NarratorConfidence = storyMemory.NarratorConfidence,
            RawScenePov = storyMemory.RawScenePov,
            InheritedChapterPov = storyMemory.InheritedChapterPov,
            ResolvedPovIdentity = storyMemory.ResolvedPovIdentity,
            PovResolutionReason = storyMemory.PovResolutionReason,
            PovConfidence = storyMemory.PovConfidence,
            PovChangeDetected = storyMemory.PovChangeDetected,
            SnapshotSource = replayMode ? "Replay" : "Real",
            RunStatus = SessionStatus(session, snapshots),
            PersistedCompletedSceneCount = snapshots.Sum(snapshot => snapshot.Run.CompletedScenes ?? snapshot.Completed.Count),
            TotalPersistedSceneResultCount = totalPersistedResults,
            SuccessfullyParsedSceneResultCount = parsedScenes,
            FailedSceneResultCount = failed,
            HighestPersistedSceneResultId = snapshots.SelectMany(snapshot => snapshot.Results).Select(result => (int?)result.SceneResultID).Max(),
            HighestParsedSceneResultId = snapshots.SelectMany(snapshot => snapshot.Completed).Select(item => (int?)item.Result.SceneResultID).Max(),
            SelectedCurrentSceneResultId = current?.Result.SceneResultID,
            SelectedChapterAndSceneOrder = current is null ? "none" : $"Chapter {activeSnapshot.Run.ChapterNumber:0.##}, Scene {SceneSortNumber(current):0.##}",
            CurrentChangeToken = SessionChangeToken(session, snapshots),
            AnalysisCursor = FirstConfigured(activeSnapshot.Run.CurrentMessage, activeSnapshot.Run.CurrentStage, session.CurrentStage),
            Warning = replayMode && failedChapterSnapshots.Count > 0
                ? $"{failedChapterSnapshots.Count:N0} failed chapter(s) skipped during replay. {failedChapterSummary}"
                : warning,
            ReplayIndex = replayMode ? 1 : 0,
            TotalReplayableScenes = replayMode ? parsedScenes : 0,
            ReplayMilestone = replayMode ? "Scene completed snapshot" : string.Empty,
            ReplayState = replayMode ? "Paused at first available scene" : string.Empty,
            SnapshotCacheStatus = replayMode ? "Reconstructed from saved analysis" : string.Empty,
            RebuildVersion = replayMode ? "21R-replay-v1" : string.Empty,
            AppearancePreference = appearancePreference,
            FailedChaptersSkipped = failedChapterSnapshots.Count,
            FailedChapterSummary = failedChapterSummary,
            PanelVisibleCounts = replayMode ? "relationships:2; knowledge:3; observations:2; discoveries:4; activity:4" : string.Empty,
            PanelHiddenCounts = replayMode ? "computed in browser per replay cursor" : string.Empty,
            AiCallsDuringReplay = 0
        };
    }

    private static string ReplaySnapshotMessage(StoryIntelligenceBookPipelineState session, IReadOnlyList<RunSnapshot> snapshots, int replayableScenes)
    {
        var failedChapters = snapshots.Count(snapshot => string.Equals(snapshot.Run.Status, StoryIntelligenceRunStatuses.Failed, StringComparison.OrdinalIgnoreCase));
        var completedChapters = snapshots.Count(snapshot => IsSuccessfulTerminal(snapshot.Run.Status));
        return $"Saved analysis replay from Import Session {session.StoryIntelligenceBookPipelineID:N0}. {completedChapters:N0} of {snapshots.Count:N0} chapters analysed, {failedChapters:N0} failed chapter(s), {replayableScenes:N0} replayable scene(s). No AI calls are made in replay.";
    }

    private static StoryIntelligenceExperienceSnapshotDiagnostics BuildDiagnostics(
        StoryIntelligenceSavedRun run,
        IReadOnlyList<StoryIntelligenceSavedSceneResult> results,
        IReadOnlyList<ParsedSceneResult> parsed,
        IReadOnlyList<ParsedSceneResult> completed,
        ParsedSceneResult? current,
        StoryMemory storyMemory)
    {
        var failed = parsed.Where(item => item.Scene is null).ToList();
        var warning = failed.Count == 0
            ? null
            : $"{failed.Count:N0} scene result(s) skipped because they could not be parsed.";
        var totalScenes = Math.Max(run.TotalDetectedScenes ?? 0, results.Count);
        var completedSceneCount = run.CompletedScenes ?? completed.Count;
        var runProgress = Progress(run, completedSceneCount, totalScenes);
        var calculatedProgress = OverallProgress(completed.Count, totalScenes, IsSuccessfulTerminal(run.Status));

        return new()
        {
            RequestedMode = "real",
            ActiveChapterRunId = run.StoryIntelligenceRunID,
            SessionStatus = run.Status,
            OverallBookProgress = runProgress,
            AuthoritativeImportProgress = runProgress,
            CalculatedVisualisationProgress = calculatedProgress,
            TotalChapters = 1,
            CompletedChapters = IsSuccessfulTerminal(run.Status) ? 1 : 0,
            CurrentChapter = run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : "Chapter unknown",
            CurrentScene = run.TotalDetectedScenes.HasValue
                ? $"Scene {Math.Clamp(completedSceneCount + (IsTerminal(run.Status) ? 0 : 1), 1, run.TotalDetectedScenes.Value):N0} of {run.TotalDetectedScenes.Value:N0}"
                : (current is null ? "Waiting" : $"Scene {SceneSortNumber(current):0.##}"),
            CompletedScenes = completedSceneCount,
            TotalScenes = totalScenes,
            ParsedScenes = completed.Count,
            TerminalRunSessionStatus = run.Status,
            DisplayedProgressReason = IsSuccessfulTerminal(run.Status) ? "Successful terminal chapter run" : "Chapter run scene progress",
            ProgressSource = "ChapterRun",
            CurrentBackendStage = BackendStageDisplay(run),
            StoryMemoryCharacterCount = storyMemory.Characters.Count,
            ResolvedCharacterCount = storyMemory.ResolvedCharacterCount,
            UnresolvedCharacterCount = storyMemory.UnresolvedCharacterCount,
            NarratorConfidence = storyMemory.NarratorConfidence,
            RawScenePov = storyMemory.RawScenePov,
            InheritedChapterPov = storyMemory.InheritedChapterPov,
            ResolvedPovIdentity = storyMemory.ResolvedPovIdentity,
            PovResolutionReason = storyMemory.PovResolutionReason,
            PovConfidence = storyMemory.PovConfidence,
            PovChangeDetected = storyMemory.PovChangeDetected,
            SnapshotSource = "Real",
            RunStatus = run.Status,
            PersistedCompletedSceneCount = run.CompletedScenes ?? 0,
            TotalPersistedSceneResultCount = results.Count,
            SuccessfullyParsedSceneResultCount = completed.Count,
            FailedSceneResultCount = failed.Count,
            HighestPersistedSceneResultId = results.Count == 0 ? null : results.Max(result => result.SceneResultID),
            HighestParsedSceneResultId = completed.Count == 0 ? null : completed.Max(item => item.Result.SceneResultID),
            SelectedCurrentSceneResultId = current?.Result.SceneResultID,
            SelectedChapterAndSceneOrder = current is null ? "none" : $"Chapter {SceneSortChapter(current):0.##}, Scene {SceneSortNumber(current):0.##}",
            CurrentChangeToken = ChangeToken(run, results),
            AnalysisCursor = FirstConfigured(run.CurrentMessage, run.CurrentStage),
            Warning = warning
        };
    }

    private static string SnapshotMessage(StoryIntelligenceSavedRun run, int completedScenes)
        => run.Status switch
        {
            StoryIntelligenceRunStatuses.Failed => FirstConfigured(run.ErrorMessage, "Story Intelligence run failed."),
            StoryIntelligenceRunStatuses.Cancelled => "Story Intelligence run was cancelled.",
            StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings => "Analysis complete and ready for review.",
            _ when completedScenes == 0 => "Waiting for the first persisted scene analysis.",
            _ => FirstConfigured(run.CurrentMessage, "Analysis snapshot is updating.")
        };

    private async Task ApplyPersistedStoryMemoryAssignmentsAsync(int importSessionId, StoryIntelligenceExperiencePrototypeViewModel model)
    {
        var assignments = await storyMemoryRepository.ListAssignmentsAsync(importSessionId);
        if (assignments.Count == 0)
        {
            return;
        }

        var characters = await storyMemoryRepository.ListCharactersAsync(importSessionId);
        var locations = await storyMemoryRepository.ListLocationsAsync(importSessionId);
        var assets = await storyMemoryRepository.ListAssetsAsync(importSessionId);
        var attributes = await storyMemoryRepository.ListCharacterAttributesAsync(importSessionId);
        var characterIds = characters.ToDictionary(item => item.StoryMemoryCharacterID, item => item.CanonicalIdentityKey);
        var locationIds = locations.ToDictionary(item => item.StoryMemoryLocationID, item => item.CanonicalIdentityKey);
        var assetIds = assets.ToDictionary(item => item.StoryMemoryAssetID, item => item.CanonicalIdentityKey);
        var characterEvidence = characters.ToDictionary(
            item => item.CanonicalIdentityKey,
            item => StoryMemoryService.ResolveCharacterEvidenceFromAttributes(
                item.DisplayName,
                attributes.Where(attribute => attribute.StoryMemoryCharacterID == item.StoryMemoryCharacterID)),
            StringComparer.OrdinalIgnoreCase);
        var catalogue = (await illustrationRepository.ListAsync(new IllustrationLibraryFilter()))
            .ToDictionary(item => item.IllustrationLibraryItemID);

        var characterAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Character, characterIds, catalogue, characterEvidence);
        var locationAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Location, locationIds, catalogue);
        var assetAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Asset, assetIds, catalogue);

        foreach (var scene in model.Scenes)
        {
            ApplyLocationAssignment(scene.Location, locationAssignments);
            foreach (var character in scene.Characters)
            {
                ApplyCharacterAssignment(character, characterAssignments);
            }

            foreach (var asset in scene.Assets)
            {
                ApplyAssetAssignment(asset, assetAssignments);
            }
        }
    }

    private Dictionary<string, AssignmentImage> BuildAssignmentLookup(
        IReadOnlyList<StoryMemoryIllustrationAssignment> assignments,
        string entityType,
        IReadOnlyDictionary<int, string> entityKeys,
        IReadOnlyDictionary<int, IllustrationLibraryItem> catalogue,
        IReadOnlyDictionary<string, CharacterEvidenceProfile>? characterEvidence = null)
    {
        var lookup = new Dictionary<string, AssignmentImage>(StringComparer.OrdinalIgnoreCase);
        foreach (var assignment in assignments.Where(item => item.EntityType == entityType && item.IllustrationLibraryItemID.HasValue))
        {
            if (!entityKeys.TryGetValue(assignment.EntityID, out var entityKey)
                || !catalogue.TryGetValue(assignment.IllustrationLibraryItemID!.Value, out var item))
            {
                continue;
            }

            var publicUrl = ResolveExistingPublicUploadUrl(item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(item.FilePath);
            if (string.IsNullOrWhiteSpace(publicUrl))
            {
                continue;
            }

            CharacterEvidenceProfile? evidence = null;
            characterEvidence?.TryGetValue(entityKey, out evidence);
            var currentEvidence = evidence is null
                ? null
                : $"age={evidence.AgeBand}; presentation={evidence.Presentation}; hair={evidence.HairColour}; hairLength={evidence.HairLength}; skin={evidence.SkinTone}";
            lookup[entityKey] = new AssignmentImage(
                item.IllustrationLibraryItemID,
                item.StableCode,
                item.Status,
                publicUrl,
                assignment.Confidence,
                assignment.MatchScore,
                assignment.AssignmentReason,
                evidence?.AgeBand,
                evidence?.Presentation,
                currentEvidence,
                assignment.EvidenceVersion,
                assignment.AssignmentStatus,
                assignment.IsFallback);
        }

        return lookup;
    }

    private void ApplyCharacterAssignment(StoryIntelligenceExperienceCharacterState character, IReadOnlyDictionary<string, AssignmentImage> assignments)
    {
        if (!assignments.TryGetValue(character.Id, out var assignment))
        {
            return;
        }

        var fallback = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl) ? character.ImagePath : character.ImageResolution.FallbackImageUrl;
        character.LibraryCode = assignment.StableCode;
        character.ImagePath = assignment.PublicUrl;
        character.ImageResolution = BuildAssignmentResolution(character.Id, assignment, fallback, "Persisted Story Memory character assignment");
    }

    private void ApplyLocationAssignment(StoryIntelligenceExperienceLocationState location, IReadOnlyDictionary<string, AssignmentImage> assignments)
    {
        if (!assignments.TryGetValue(location.Id, out var assignment))
        {
            return;
        }

        var fallback = string.IsNullOrWhiteSpace(location.ImageResolution.FallbackImageUrl) ? location.ImagePath : location.ImageResolution.FallbackImageUrl;
        location.LibraryCode = assignment.StableCode;
        location.ImagePath = assignment.PublicUrl;
        location.ImageResolution = BuildAssignmentResolution(location.Id, assignment, fallback, "Persisted Story Memory location assignment");
    }

    private void ApplyAssetAssignment(StoryIntelligenceExperienceAssetState asset, IReadOnlyDictionary<string, AssignmentImage> assignments)
    {
        if (!assignments.TryGetValue(asset.Id, out var assignment))
        {
            return;
        }

        var fallback = string.IsNullOrWhiteSpace(asset.ImageResolution.FallbackImageUrl) ? asset.ImagePath : asset.ImageResolution.FallbackImageUrl;
        asset.LibraryCode = assignment.StableCode;
        asset.ImagePath = assignment.PublicUrl;
        asset.ImageResolution = BuildAssignmentResolution(asset.Id, assignment, fallback, "Persisted Story Memory asset assignment");
    }

    private static StoryIntelligenceExperienceImageResolution BuildAssignmentResolution(string entityId, AssignmentImage assignment, string fallback, string status)
        => new()
        {
            EntityId = entityId,
            StableCode = assignment.StableCode,
            IllustrationLibraryItemId = assignment.IllustrationLibraryItemId,
            Status = assignment.Status,
            FinalImageUrl = assignment.PublicUrl,
            FallbackImageUrl = fallback,
            FallbackUsed = false,
            AssignmentConfidence = assignment.Confidence,
            MatchingScore = assignment.MatchScore,
            ReasonChosen = assignment.Reason,
            PreviousIllustration = assignment.StableCode,
            CurrentEvidence = assignment.CurrentEvidence,
            PreviousEvidence = $"assignment evidence version {assignment.EvidenceVersion}",
            ResolvedAgeBand = assignment.ResolvedAgeBand,
            ResolvedPresentation = assignment.ResolvedPresentation,
            DemandStatus = "Read from durable Story Memory",
            IllustrationAllocationStatus = assignment.IsFallback ? assignment.AssignmentStatus : status
        };

    private string? ResolveExistingPublicUploadUrl(string? storedPath)
    {
        var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library");
        return physicalPath is not null && File.Exists(physicalPath) ? storedPath : null;
    }

    private static void StabiliseCharacterIllustrations(StoryIntelligenceExperiencePrototypeViewModel model)
    {
        var firstAssignments = new Dictionary<string, (string LibraryCode, string ImagePath)>(StringComparer.OrdinalIgnoreCase);
        foreach (var scene in model.Scenes.OrderBy(scene => scene.SceneNumber))
        {
            foreach (var character in scene.Characters)
            {
                if (string.IsNullOrWhiteSpace(character.Id))
                {
                    continue;
                }

                if (firstAssignments.TryGetValue(character.Id, out var assignment))
                {
                    character.LibraryCode = assignment.LibraryCode;
                    character.ImagePath = assignment.ImagePath;
                    continue;
                }

                firstAssignments[character.Id] = (character.LibraryCode, character.ImagePath);
            }
        }
    }

    private static void ApplyIllustrationDiagnostics(StoryIntelligenceExperiencePrototypeViewModel model)
    {
        var characterResolutions = model.Scenes
            .SelectMany(scene => scene.Characters)
            .Select(character => character.ImageResolution)
            .Where(resolution => resolution.IllustrationLibraryItemId.HasValue)
            .ToList();
        model.Diagnostics.IllustrationAssignmentCount = characterResolutions
            .Select(resolution => resolution.IllustrationLibraryItemId!.Value)
            .Distinct()
            .Count();
        model.Diagnostics.IllustrationChangeCount = characterResolutions.Count(resolution => !string.IsNullOrWhiteSpace(resolution.ReasonReassigned));
        var uniqueCharacters = model.Scenes
            .SelectMany(scene => scene.Characters)
            .GroupBy(character => character.Id, StringComparer.OrdinalIgnoreCase)
            .Select(group => group.First())
            .OrderBy(character => character.Name)
            .ToList();
        var reuseCounts = uniqueCharacters
            .Where(character => character.ImageResolution.IllustrationLibraryItemId.HasValue)
            .GroupBy(character => character.ImageResolution.IllustrationLibraryItemId!.Value)
            .ToDictionary(group => group.Key, group => group.Count());
        model.Diagnostics.CharacterConsistencyReport = string.Join(" | ", uniqueCharacters.Select(character =>
        {
            var resolution = character.ImageResolution;
            var reuseCount = resolution.IllustrationLibraryItemId.HasValue && reuseCounts.TryGetValue(resolution.IllustrationLibraryItemId.Value, out var count)
                ? count
                : 0;
            var allocation = reuseCount <= 1
                ? resolution.IllustrationAllocationStatus ?? "Unique allocation"
                : $"Reused {reuseCount:N0} times";
            return $"{character.Name}: gender={FirstConfigured(resolution.ResolvedPresentation, "Unknown")}, age={FirstConfigured(resolution.ResolvedAgeBand, "Unknown")}, relationship={resolution.RelationshipDerivedConfidence ?? 0:0.##}, name={resolution.NameDerivedConfidence ?? 0:0.##}, pronoun={resolution.PronounDerivedConfidence ?? 0:0.##}, illustration={FirstConfigured(resolution.StableCode, character.LibraryCode, "none")}, reuse={reuseCount:N0}, allocation={allocation}";
        }));
    }

    public static void StripLiveDiagnostics(StoryIntelligenceExperiencePrototypeViewModel model)
    {
        model.Diagnostics.CharacterConsistencyReport = Trim(model.Diagnostics.CharacterConsistencyReport, 2000);
        foreach (var resolution in model.Scenes
                     .SelectMany(scene => scene.Characters.Select(character => character.ImageResolution)
                         .Concat(scene.Assets.Select(asset => asset.ImageResolution))
                         .Append(scene.Location.ImageResolution)))
        {
            StripLiveDiagnostics(resolution);
        }
    }

    private static void StripLiveDiagnostics(StoryIntelligenceExperienceImageResolution resolution)
    {
        if (resolution is null)
        {
            return;
        }

        resolution.RejectedCandidates = null;
        resolution.PreviousEvidence = null;
        resolution.CurrentEvidence = null;
        resolution.EvidenceWarnings = null;
        resolution.ReasonChosen = Trim(resolution.ReasonChosen, 160);
        resolution.ReasonReassigned = Trim(resolution.ReasonReassigned, 160);
        resolution.PreviousIllustration = Trim(resolution.PreviousIllustration, 80);
        resolution.DemandKey = Trim(resolution.DemandKey, 80);
        resolution.DemandStatus = Trim(resolution.DemandStatus, 80);
    }

    private static IReadOnlySet<int> VisibleSceneResultIds(
        IReadOnlyList<ParsedSceneResult> completed,
        ParsedSceneResult? current,
        int previousCount = SceneWindowPrevious,
        int followingCount = SceneWindowFollowing)
    {
        if (completed.Count == 0)
        {
            return new HashSet<int>();
        }

        var currentIndex = current is null
            ? completed.Count - 1
            : completed.ToList().FindIndex(item => item.Result.SceneResultID == current.Result.SceneResultID);
        if (currentIndex < 0)
        {
            currentIndex = completed.Count - 1;
        }

        var start = Math.Max(0, currentIndex - previousCount);
        var end = Math.Min(completed.Count - 1, currentIndex + followingCount);
        return completed
            .Skip(start)
            .Take(end - start + 1)
            .Select(item => item.Result.SceneResultID)
            .ToHashSet();
    }

    private void LogPayload(StoryIntelligenceExperiencePrototypeViewModel model, string scope, long elapsedMs)
    {
        var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(model, JsonOptions).Length;
        var counts = new
        {
            sceneCount = model.Scenes.Count,
            characterCount = model.Scenes.Sum(scene => scene.Characters.Count),
            locationCount = model.Scenes.Count(scene => !string.IsNullOrWhiteSpace(scene.Location.Name)),
            assetCount = model.Scenes.Sum(scene => scene.Assets.Count),
            relationshipCount = model.Scenes.Sum(scene => scene.Relationships.Count),
            knowledgeCount = model.Scenes.Sum(scene => scene.KnowledgeThreads.Count),
            observationCount = model.Scenes.Sum(scene => scene.Observations.Count),
            activityCount = model.Scenes.Sum(scene => scene.ActivityFeed.Count)
        };
        var largestString = LargestStringProperty(model);
        if (payloadBytes > SnapshotWarningBytes)
        {
            logger.LogWarning(
                "Story Intelligence snapshot payload for {Scope} is {PayloadBytes} bytes; counts {@Counts}; largest string {LargestProperty} has {LargestLength} characters; built in {ElapsedMs}ms.",
                scope,
                payloadBytes,
                counts,
                largestString.Path,
                largestString.Length,
                elapsedMs);
            return;
        }

        logger.LogInformation(
            "Built Story Intelligence visualisation snapshot for {Scope} in {ElapsedMs}ms; payload {PayloadBytes} bytes; counts {@Counts}; largest string {LargestProperty} has {LargestLength} characters.",
            scope,
            elapsedMs,
            payloadBytes,
            counts,
            largestString.Path,
            largestString.Length);
    }

    private void LogSnapshotSynchronisation(
        int importSessionId,
        RunSnapshot activeSnapshot,
        ParsedSceneResult? current,
        IReadOnlyList<StoryIntelligenceExperienceSceneState> scenes,
        string changeToken)
    {
        var displayed = scenes.FirstOrDefault(scene => scene.IsCurrent) ?? scenes.LastOrDefault();
        logger.LogInformation(
            "Story Intelligence snapshot synchronisation: import session {ImportSessionId}; active run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; selected scene result {SceneResultId}; displayed scene id {DisplayedSceneId}; change token {ChangeToken}; payload scenes {SceneIds}.",
            importSessionId,
            activeSnapshot.Run.StoryIntelligenceRunID,
            activeSnapshot.Run.ChapterNumber,
            current is null ? null : SceneSortNumber(current),
            current?.Result.SceneResultID,
            displayed?.Id,
            changeToken,
            string.Join(",", scenes.Select(scene => scene.Id)));
    }

    private static (string Path, int Length) LargestStringProperty(StoryIntelligenceExperiencePrototypeViewModel model)
    {
        var largest = ("model", 0);
        void Consider(string path, string? value)
        {
            if (value?.Length > largest.Item2)
            {
                largest = (path, value.Length);
            }
        }

        Consider("snapshotMessage", model.SnapshotMessage);
        Consider("diagnostics.characterConsistencyReport", model.Diagnostics.CharacterConsistencyReport);
        foreach (var scene in model.Scenes)
        {
            Consider($"{scene.Id}.summary", scene.Summary);
            foreach (var character in scene.Characters)
            {
                Consider($"{scene.Id}.character.{character.Id}.name", character.Name);
                Consider($"{scene.Id}.character.{character.Id}.relevance", character.Relevance);
                Consider($"{scene.Id}.character.{character.Id}.rejectedCandidates", character.ImageResolution.RejectedCandidates);
            }
        }

        return (largest.Item1, largest.Item2);
    }

    private static bool IsTerminal(string status)
        => status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings or StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled;

    private static bool IsSuccessfulTerminal(string status)
        => status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings;

    private static bool IsSuccessfulSession(StoryIntelligenceBookPipelineState session)
        => string.Equals(session.Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
           && (string.Equals(session.LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase)
               || string.Equals(session.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase)
               || session.CompletedUtc.HasValue);

    private static bool IsTerminalSession(StoryIntelligenceBookPipelineState session)
        => IsSuccessfulSession(session);

    private static int SceneNumber(ParsedSceneResult item)
        => item.Scene?.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(item.Scene.SceneReference.SceneNumber.Value) : item.Result.TemporarySceneNumber;

    private static decimal SceneSortChapter(ParsedSceneResult item)
        => item.Scene?.SceneReference?.ChapterNumber
           ?? (item.Result.ChapterID.HasValue ? item.Result.ChapterID.Value : 0);

    private static decimal SceneSortNumber(ParsedSceneResult item)
        => item.Scene?.SceneReference?.SceneNumber ?? item.Result.TemporarySceneNumber;

    private static string SceneTitle(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result, int sceneNumber)
        => CompactSceneTitle(FirstConfigured(scene.SceneReference?.SourceLabel, result.SourceLabel, scene.Summary?.Short, $"Scene {sceneNumber:N0}"), sceneNumber);

    private static string CompactSceneTitle(string title, int sceneNumber)
    {
        var value = Clean(title);
        if (string.IsNullOrWhiteSpace(value))
        {
            return $"Scene {sceneNumber:N0}";
        }

        var sceneMarker = $"scene {sceneNumber}".ToLowerInvariant();
        var lower = value.ToLowerInvariant();
        var markerIndex = lower.IndexOf(sceneMarker, StringComparison.Ordinal);
        if (markerIndex > 0)
        {
            value = value[markerIndex..].Trim(' ', '-', ':', '|');
        }

        var separators = new[] { " - ", " | ", ": " };
        foreach (var separator in separators)
        {
            var parts = value.Split(separator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
            if (parts.Length >= 3)
            {
                return Trim(parts[^1], 72);
            }
        }

        return Trim(value, 72);
    }

    private static string TimelineLabel(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result)
        => FirstConfigured(scene.TimelineClues?.FirstOrDefault()?.AbsoluteDate, scene.Setting?.DateOrTimeReference, result.SourceLabel, "Known scene");

    private static string ChapterLabel(StoryIntelligenceSavedRun run, ParsedSceneResult item)
    {
        var chapter = item.Scene?.SceneReference?.ChapterNumber ?? run.ChapterNumber;
        return chapter.HasValue ? $"Chapter {chapter:0.##}" : "Chapter";
    }

    private static int Progress(StoryIntelligenceSavedRun run, int completedScenes, int totalScenes)
    {
        if (IsTerminal(run.Status) && run.Status is not StoryIntelligenceRunStatuses.Failed)
        {
            return 100;
        }

        if (totalScenes > 0)
        {
            return Math.Clamp((int)Math.Round(completedScenes * 100m / totalScenes), 0, 99);
        }

        return run.Status == StoryIntelligenceRunStatuses.Running ? 8 : 0;
    }

    private static int OverallProgress(int completedScenes, int totalScenes, bool isTerminal)
    {
        if (isTerminal)
        {
            return 100;
        }

        if (totalScenes > 0)
        {
            return Math.Clamp((int)Math.Round(completedScenes * 100m / totalScenes), 0, 99);
        }

        return 0;
    }

    private static int BookProgress(IReadOnlyList<RunSnapshot> snapshots, int chapterTotal, bool isTerminal)
    {
        if (isTerminal)
        {
            return 100;
        }

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

        var chapterProgress = snapshots.Sum(ChapterProgress);
        return Math.Clamp((int)Math.Floor(chapterProgress * 100m / chapterTotal), 0, 99);
    }

    private static decimal ChapterProgress(RunSnapshot snapshot)
    {
        if (IsSuccessfulTerminal(snapshot.Run.Status))
        {
            return 1m;
        }

        if (snapshot.Run.Status == StoryIntelligenceRunStatuses.Pending)
        {
            return 0m;
        }

        if ((snapshot.Run.TotalDetectedScenes ?? 0) > 0)
        {
            var sceneProgress = Math.Clamp((snapshot.Run.CompletedScenes ?? snapshot.Completed.Count) / Math.Max(1m, snapshot.Run.TotalDetectedScenes ?? 1), 0m, 1m);
            return Math.Clamp(0.25m + (sceneProgress * 0.7m), 0m, 0.95m);
        }

        return 0.15m;
    }

    private static string EstimateRemaining(StoryIntelligenceSavedRun run, int completedScenes, int totalScenes)
    {
        if (IsTerminal(run.Status))
        {
            return run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings ? "Ready to review" : run.Status;
        }

        if (completedScenes <= 0 || totalScenes <= completedScenes)
        {
            return "Calculating";
        }

        var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds));
        var averageMs = elapsedMs / Math.Max(1, completedScenes);
        return FormatDuration(averageMs * (totalScenes - completedScenes));
    }

    private static string EstimateElapsed(StoryIntelligenceSavedRun run)
    {
        var elapsedMs = run.TotalDurationMs ?? Math.Max(0, Convert.ToInt64((DateTime.UtcNow - run.StartedUtc).TotalMilliseconds));
        return FormatDuration(elapsedMs);
    }

    private static long SessionElapsedMilliseconds(IReadOnlyList<RunSnapshot> snapshots, bool isTerminal)
    {
        if (snapshots.Count == 0)
        {
            return 0;
        }

        var started = snapshots.Min(snapshot => snapshot.Run.StartedUtc);
        var ended = isTerminal
            ? snapshots
                .Select(snapshot => snapshot.Run.CompletedUtc ?? snapshot.Run.UpdatedUtc)
                .DefaultIfEmpty(DateTime.UtcNow)
                .Max()
            : DateTime.UtcNow;
        return Math.Max(0, Convert.ToInt64((ended - started).TotalMilliseconds));
    }

    private static string FormatDuration(long milliseconds)
    {
        var duration = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds));
        return duration.TotalMinutes >= 1 ? $"{Math.Max(1, (int)Math.Round(duration.TotalMinutes)):N0} min" : $"{Math.Max(0, (int)Math.Round(duration.TotalSeconds)):N0} sec";
    }

    private static int ChapterCount(IEnumerable<ParsedSceneResult> completed)
        => Math.Max(1, completed.Select(item => item.Result.ChapterID ?? 0).Where(id => id > 0).Distinct().Count());

    private static string StageDisplay(StoryIntelligenceSavedRun run)
        => FirstConfigured(run.CurrentStage, run.Status);

    private static StoryIntelligenceExperienceLocationState PendingLocation()
        => new()
        {
            Id = "location-pending",
            LibraryCode = string.Empty,
            Name = "Location not yet identified",
            Label = "Awaiting scene location",
            ImagePath = $"{FallbackRoot}/location-flat.svg"
        };

    private static string CharacterId(string? name) => $"character-result-{StableKey(CanonicalCharacterIdentity(name))}";

    private static string CanonicalCharacterIdentity(string? name)
    {
        var clean = Clean(name).ToLowerInvariant();
        if (string.IsNullOrWhiteSpace(clean))
        {
            return "unknown";
        }

        var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        if (words.Length == 2
            && !IsTitleWord(words[0])
            && name is not null
            && name.Trim().Length > 0
            && char.IsUpper(name.Trim()[0])
            && name.Trim().All(ch => char.IsLetter(ch) || char.IsWhiteSpace(ch) || ch == '\'' || ch == '\u2019'))
        {
            return words[0];
        }

        if (clean is "mother" or "mum" or "mummy" or "her mother" or "the mother" or "my mother" or "my mum"
            || clean.Contains("beth's mum", StringComparison.Ordinal)
            || clean.Contains("beth's mother", StringComparison.Ordinal))
        {
            return "beth mother";
        }

        if (clean is "aunt" or "her aunt" or "my aunt" or "aunt elen" || clean.Contains("beth's aunt", StringComparison.Ordinal))
        {
            return "beth aunt";
        }

        if (IsGenericNarratorName(clean))
        {
            return "narrator";
        }

        return clean;
    }

    private static bool IsTitleWord(string value)
        => value is "mr" or "mrs" or "miss" or "ms" or "dr" or "doctor" or "sir" or "lady";

    private static int CharacterWeight(SceneIntelligenceCharacter character)
        => character.MentionedOnly == true ? 46 : ConfidenceWeight(character.Confidence, 72);

    private static int AssetWeight(SceneIntelligenceAsset asset)
        => ConfidenceWeight(asset.Confidence, string.IsNullOrWhiteSpace(asset.Status) ? 64 : 82);

    private static int ConfidenceWeight(decimal? confidence, int fallback)
        => Math.Clamp(confidence.HasValue ? (int)Math.Round(confidence.Value * 100m) : fallback, 30, 100);

    private static int Specificity(string? value)
        => string.IsNullOrWhiteSpace(value) ? 0 : value.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;

    private static bool IsLowValueAsset(SceneIntelligenceAsset asset)
    {
        var value = $"{asset.Name} {asset.AssetType} {asset.Notes}".ToLowerInvariant();
        return value.Contains("background", StringComparison.OrdinalIgnoreCase)
            || value.Contains("ordinary", StringComparison.OrdinalIgnoreCase)
            || value is "door" or "table" or "chair";
    }

    private static string CharacterLibraryCode(
        string name,
        SceneIntelligenceCharacter? character,
        SceneIntelligenceScene? scene = null,
        IReadOnlySet<string>? reservedCodes = null)
    {
        var profile = CharacterProfile.From(name, character, scene);
        var candidates = CharacterCodeProfiles
            .OrderByDescending(candidate => candidate.Score(profile))
            .ThenBy(candidate => reservedCodes?.Contains(candidate.Code) == true ? 1 : 0)
            .ThenBy(candidate => StableHash($"{name}|{candidate.Code}"))
            .ToList();

        return candidates.FirstOrDefault(candidate => reservedCodes?.Contains(candidate.Code) != true)?.Code
            ?? candidates.First().Code;
    }

    private static string LocationLibraryCode(string name, SceneIntelligenceLocation? location, SceneIntelligenceSetting? setting)
    {
        var value = $"{name} {location?.LocationType} {location?.GenericRoomType} {setting?.LocationType}".ToLowerInvariant();
        var type = StoryIntelligenceIllustrationCompatibility.LocationType(value);
        if (type == "Bathroom") return "loc-generic-bathroom";
        if (type == "Kitchen") return "loc-generic-kitchen";
        if (type == "WaitingRoom") return "loc-generic-waiting-room";
        if (type == "DrivingTestCentre") return "loc-driving-test-centre";
        if (type == "VehicleInterior") return "loc-vehicle-interior";
        if (type == "Road") return "loc-generic-road";
        if (type == "Garage") return "loc-garage-workshop";
        if (type is "Hospital" or "PoliceStation" or "School" or "Church" or "Restaurant" or "Pub") return $"loc-generic-{StableKey(type)}";
        if (type is "Kitchen" or "Bedroom" or "LivingRoom" or "FlatInterior" or "Hallway") return "loc-small-flat-interior";
        if (type == "Office") return "loc-domestic-desk";
        if (type == "Stairwell") return "loc-narrow-stairwell";
        if (type is "CarPark") return "loc-wet-car-park";
        if (type == "HouseExterior") return "loc-estate-house-exterior";
        return string.Empty;
    }

    private static string AssetLibraryCode(string name, SceneIntelligenceAsset asset)
    {
        var value = $"{name} {asset.AssetType} {asset.Status}".ToLowerInvariant();
        var type = StoryIntelligenceIllustrationCompatibility.AssetType(value);
        if (type is "Letter" or "Note" or "Document" or "Certificate" or "Passport" or "DrivingLicence") return "asset-document";
        if (type is "Envelope") return "asset-envelope";
        if (type is "Notebook") return "asset-worn-notebook";
        if (type is "Book" or "DrivingManual" or "Newspaper" or "Magazine") return "asset-book";
        if (type == "Car") return "asset-red-classic-car";
        if (type == "Keys") return "asset-old-keys";
        if (type == "Telephone") return "asset-phone-recorder";
        if (type == "Photograph") return "asset-photo-print";
        if (type is "Painting") return "asset-photo-print";
        if (type == "Bottle") return "asset-bottle";
        if (type == "Clock") return "asset-clock";
        if (type == "TrafficSign") return "asset-traffic-sign";
        if (type is "Wallet" or "Handbag" or "Suitcase") return "asset-bag-wallet";
        if (type is "Parcel" or "Gift") return "asset-parcel";
        if (type is "Bench") return "asset-bench";
        if (type is "Tree" or "Lamp" or "Window" or "Door" or "Fence") return "asset-environment-object";
        if (type == "Rucksack") return "asset-rucksack";
        return string.Empty;
    }

    private static string CharacterFallback(string name)
        => CharacterLibraryCode(name, null) switch
        {
            "char-young-adult-red-haired-witness" => $"{FallbackRoot}/character-rosie.svg",
            "char-middle-aged-weathered-man" => $"{FallbackRoot}/character-graham.svg",
            "char-older-soft-presence" => $"{FallbackRoot}/character-maggie.svg",
            "char-young-helper" => $"{FallbackRoot}/character-beth.svg",
            "char-archival-contact" => $"{FallbackRoot}/character-graham.svg",
            "char-professional-investigator" => $"{FallbackRoot}/character-graham.svg",
            "char-neighbourhood-friend" => $"{FallbackRoot}/character-beth.svg",
            "char-family-relative" => $"{FallbackRoot}/character-maggie.svg",
            "char-quiet-antagonist" => $"{FallbackRoot}/character-graham.svg",
            "char-authorial-voice" => $"{FallbackRoot}/character-maggie.svg",
            "char-unknown-figure" => $"{FallbackRoot}/character-beth.svg",
            _ => $"{FallbackRoot}/character-beth.svg"
        };

    private static string LocationFallback(string name)
        => LocationLibraryCode(name, null, null) switch
        {
            "loc-laundry-utility-room" => $"{FallbackRoot}/location-laundry.svg",
            "loc-narrow-stairwell" => $"{FallbackRoot}/location-stairwell.svg",
            "loc-wet-car-park" => $"{FallbackRoot}/location-car-park.svg",
            "loc-estate-house-exterior" => $"{FallbackRoot}/location-doweries.svg",
            "loc-generic-road" => $"{FallbackRoot}/location-road.svg",
            "loc-vehicle-interior" => $"{FallbackRoot}/location-vehicle-interior.svg",
            "loc-generic-bathroom" => $"{FallbackRoot}/location-bathroom.svg",
            "loc-generic-kitchen" => $"{FallbackRoot}/location-kitchen.svg",
            "loc-generic-waiting-room" => $"{FallbackRoot}/location-waiting-room.svg",
            "loc-driving-test-centre" => $"{FallbackRoot}/location-driving-test-centre.svg",
            "loc-garage-workshop" => $"{FallbackRoot}/location-garage-workshop.svg",
            "loc-generic-hospital" => $"{FallbackRoot}/location-building.svg",
            "loc-generic-policestation" => $"{FallbackRoot}/location-building.svg",
            "loc-generic-school" => $"{FallbackRoot}/location-building.svg",
            "loc-generic-church" => $"{FallbackRoot}/location-building.svg",
            "loc-generic-restaurant" => $"{FallbackRoot}/location-building.svg",
            "loc-generic-pub" => $"{FallbackRoot}/location-building.svg",
            _ => $"{FallbackRoot}/location-flat.svg"
        };

    private static string AssetFallback(string name)
        => AssetLibraryCode(name, new SceneIntelligenceAsset()) switch
        {
            "asset-folded-letter" => $"{FallbackRoot}/asset-letter.svg",
            "asset-document" => $"{FallbackRoot}/asset-document.svg",
            "asset-envelope" => $"{FallbackRoot}/asset-envelope.svg",
            "asset-worn-notebook" => $"{FallbackRoot}/asset-notebook.svg",
            "asset-book" => $"{FallbackRoot}/asset-book.svg",
            "asset-red-classic-car" => $"{FallbackRoot}/asset-red-tr6.svg",
            "asset-old-keys" => $"{FallbackRoot}/asset-keys.svg",
            "asset-phone-recorder" => $"{FallbackRoot}/asset-phone.svg",
            "asset-photo-print" => $"{FallbackRoot}/asset-photo.svg",
            "asset-bottle" => $"{FallbackRoot}/asset-bottle.svg",
            "asset-clock" => $"{FallbackRoot}/asset-clock.svg",
            "asset-traffic-sign" => $"{FallbackRoot}/asset-traffic-sign.svg",
            "asset-bag-wallet" => $"{FallbackRoot}/asset-bag-wallet.svg",
            "asset-parcel" => $"{FallbackRoot}/asset-parcel.svg",
            "asset-bench" => $"{FallbackRoot}/asset-bench.svg",
            "asset-environment-object" => $"{FallbackRoot}/asset-environment.svg",
            _ => $"{FallbackRoot}/asset-generic.svg"
        };

    private static string StableKey(string? value)
    {
        var clean = Clean(value).ToLowerInvariant();
        if (string.IsNullOrWhiteSpace(clean))
        {
            return "unknown";
        }

        var builder = new StringBuilder();
        foreach (var ch in clean)
        {
            if (char.IsLetterOrDigit(ch))
            {
                builder.Append(ch);
            }
            else if (builder.Length > 0 && builder[^1] != '-')
            {
                builder.Append('-');
            }
        }

        return builder.ToString().Trim('-');
    }

    private static int StableHash(string? value)
    {
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
        return BitConverter.ToInt32(bytes, 0) & int.MaxValue;
    }

    private static string StableContentHash(string? value)
        => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty)))[..16].ToLowerInvariant();

    private static string Clean(string? value)
        => string.Join(' ', (value ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Trim();

    private static string FirstConfigured(params string?[] values)
        => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;

    private static string DisplayTitle(string? value, string fallback)
        => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();

    private static string Trim(string? value, int max)
    {
        var clean = Clean(value);
        return clean.Length <= max ? clean : clean[..Math.Max(0, max - 3)].TrimEnd() + "...";
    }

    private sealed record RunSnapshot(
        StoryIntelligenceSavedRun Run,
        IReadOnlyList<StoryIntelligenceSavedSceneResult> Results,
        IReadOnlyList<ParsedSceneResult> Parsed,
        IReadOnlyList<ParsedSceneResult> Completed,
        ParsedSceneResult? Current);

    private sealed record ParsedSceneResult(StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene, string? ParseError);

    private sealed record AssignmentImage(
        int IllustrationLibraryItemId,
        string StableCode,
        string Status,
        string PublicUrl,
        decimal Confidence,
        int MatchScore,
        string? Reason,
        string? ResolvedAgeBand,
        string? ResolvedPresentation,
        string? CurrentEvidence,
        string EvidenceVersion,
        string AssignmentStatus,
        bool IsFallback);

    private sealed record CharacterCodeProfile(
        string Code,
        string AgeBand,
        string Presentation,
        string HairColour,
        string HairLength,
        IReadOnlyList<string> Signals)
    {
        public int Score(CharacterProfile profile)
        {
            var score = 0;
            if (profile.AgeBand == AgeBand) score += 80;
            else if (CompatibleAge(profile.AgeBand, AgeBand)) score += 30;
            else score -= 45;

            if (!string.IsNullOrWhiteSpace(profile.Presentation) && Presentation.Equals(profile.Presentation, StringComparison.OrdinalIgnoreCase)) score += 24;
            if (!string.IsNullOrWhiteSpace(profile.HairColour) && HairColour.Equals(profile.HairColour, StringComparison.OrdinalIgnoreCase)) score += 18;
            if (!string.IsNullOrWhiteSpace(profile.HairLength) && HairLength.Equals(profile.HairLength, StringComparison.OrdinalIgnoreCase)) score += 10;
            score += Signals.Count(signal => profile.Text.Contains(signal, StringComparison.OrdinalIgnoreCase)) * 14;
            return score;
        }

        private static bool CompatibleAge(string required, string candidate)
            => (required, candidate) switch
            {
                ("Teen", "YoungAdult") => true,
                ("YoungAdult", "Teen") => true,
                ("MiddleAged", "Older") => true,
                ("Older", "MiddleAged") => true,
                _ => false
            };
    }

    private sealed record CharacterProfile(string Text, string AgeBand, string Presentation, string HairColour, string HairLength)
    {
        public static CharacterProfile From(string name, SceneIntelligenceCharacter? character, SceneIntelligenceScene? scene)
        {
            var relationshipText = string.Join(' ', (scene?.Relationships ?? [])
                .Where(relationship => string.Equals(Clean(relationship.CharacterA), Clean(name), StringComparison.OrdinalIgnoreCase)
                    || string.Equals(Clean(relationship.CharacterB), Clean(name), StringComparison.OrdinalIgnoreCase))
                .Select(relationship => $"{relationship.RelationshipSignal} {relationship.Evidence}"));
            var text = $"{name} {character?.RoleInScene} {character?.Notes} {string.Join(' ', character?.Actions ?? [])} {relationshipText}".ToLowerInvariant();
            return new(
                text,
                DetectAgeBand(text),
                DetectPresentation(text),
                DetectHairColour(text),
                DetectHairLength(text));
        }

        private static string DetectAgeBand(string text)
        {
            if (text.Contains("grandmother") || text.Contains("grandfather") || text.Contains("elderly") || text.Contains("older") || text.Contains("retired")) return "Older";
            if (text.Contains("mother") || text.Contains("father") || text.Contains("parent") || text.Contains("teacher") || text.Contains("aunt") || text.Contains("uncle") || text.Contains("middle-aged") || text.Contains("middle aged")) return "MiddleAged";
            if (text.Contains("teen") || text.Contains("teenage") || text.Contains("15") || text.Contains("fifteen") || text.Contains("schoolgirl") || text.Contains("schoolboy") || text.Contains("child") || text.Contains("daughter") || text.Contains("son")) return "Teen";
            if (text.Contains("young") || text.Contains("student")) return "YoungAdult";
            return "YoungAdult";
        }

        private static string DetectPresentation(string text)
        {
            if (text.Contains("mother") || text.Contains("daughter") || text.Contains("woman") || text.Contains("girl") || text.Contains("female") || text.Contains("sister") || text.Contains("aunt")) return "Feminine";
            if (text.Contains("father") || text.Contains("son") || text.Contains("man") || text.Contains("male") || text.Contains("brother") || text.Contains("uncle")) return "Masculine";
            return string.Empty;
        }

        private static string DetectHairColour(string text)
        {
            if (text.Contains("red") || text.Contains("auburn") || text.Contains("ginger")) return "Red";
            if (text.Contains("grey") || text.Contains("gray") || text.Contains("white-haired") || text.Contains("white hair")) return "Grey";
            if (text.Contains("black hair") || text.Contains("black-haired")) return "Black";
            if (text.Contains("dark") || text.Contains("brown") || text.Contains("brunette")) return "Brown";
            return string.Empty;
        }

        private static string DetectHairLength(string text)
        {
            if (text.Contains("long hair") || text.Contains("long-haired")) return "Long";
            if (text.Contains("short hair") || text.Contains("short-haired")) return "Short";
            return string.Empty;
        }
    }

    private static readonly IReadOnlyList<CharacterCodeProfile> CharacterCodeProfiles =
    [
        new("char-young-helper", "Teen", "Androgynous", "Black", "Short", ["teen", "child", "daughter", "son", "helper"]),
        new("char-young-adult-red-haired-witness", "YoungAdult", "Feminine", "Red", "Long", ["red", "auburn", "witness", "protective"]),
        new("char-young-adult-brunette-observer", "YoungAdult", "Feminine", "Brown", "Medium", ["young", "observer", "student"]),
        new("char-neighbourhood-friend", "YoungAdult", "Androgynous", "Brown", "Medium", ["friend", "neighbour", "neighbor"]),
        new("char-family-relative", "MiddleAged", "Feminine", "Brown", "Medium", ["mother", "parent", "aunt", "family", "relative"]),
        new("char-professional-investigator", "MiddleAged", "Androgynous", "Brown", "Short", ["teacher", "professional", "investigator", "authority"]),
        new("char-middle-aged-weathered-man", "MiddleAged", "Masculine", "DarkBrown", "Short", ["father", "man", "suspect", "threat", "weathered"]),
        new("char-quiet-antagonist", "MiddleAged", "Androgynous", "DarkBrown", "Short", ["antagonist", "threat", "pressure", "controlled"]),
        new("char-older-soft-presence", "Older", "Feminine", "Grey", "Short", ["grandmother", "older", "remembered", "mother"]),
        new("char-archival-contact", "Older", "Masculine", "White", "Short", ["grandfather", "older", "archive", "records"]),
        new("char-authorial-voice", "Older", "Androgynous", "Grey", "Medium", ["voice", "memory", "recorded"]),
        new("char-unknown-figure", "YoungAdult", "Androgynous", "Brown", "Medium", ["unknown", "unidentified"])
    ];
}
