@model StoryIntelligenceSavedRunDetailViewModel
@{
    ViewData["Title"] = "Story Intelligence Run";
    var run = Model.Run;
    var canCancel = run is not null && (run.Status == StoryIntelligenceRunStatuses.Pending || run.Status == StoryIntelligenceRunStatuses.Running);
    var shouldRefresh = run is not null && (canCancel || run.Status == "Running");
}

@if (shouldRefresh)
{
    <meta http-equiv="refresh" content="10" />
}

<div class="page-heading compact">
    <div>
        <p class="eyebrow">Admin utility</p>
        <h1>Story Intelligence Run @(run is null ? string.Empty : run.StoryIntelligenceRunID.ToString("N0"))</h1>
        <p class="lead-text">Saved audit output from the admin chapter-to-scene dry-run pipeline.</p>
    </div>
</div>

<nav class="mb-3" aria-label="Breadcrumb">
    <a asp-controller="Projects" asp-action="Index">Projects</a>
    <span class="muted"> / </span>
    <a asp-action="Index">Admin</a>
    <span class="muted"> / </span>
    <a asp-action="StoryIntelligenceRuns">Saved Story Intelligence Runs</a>
    <span class="muted"> / Run @(run is null ? string.Empty : run.StoryIntelligenceRunID.ToString("N0"))</span>
</nav>

@if (TempData["AdminMessage"] is string message)
{
    <div class="alert alert-success">@message</div>
}
@if (TempData["AdminError"] is string error)
{
    <div class="alert alert-danger">@error</div>
}

@if (run is null)
{
    <section class="edit-panel">
        <p class="mb-0">This Story Intelligence run could not be found.</p>
    </section>
}
else
{
    <section class="edit-panel">
        <h2>Run metadata</h2>
        <div class="row g-3">
            <div class="col-md-3">
                <div class="border rounded p-3 h-100">
                    <p class="eyebrow">Status</p>
                    <h3 class="mb-0">@run.Status</h3>
                </div>
            </div>
            <div class="col-md-3">
                <div class="border rounded p-3 h-100">
                    <p class="eyebrow">Scenes</p>
                    <h3 class="mb-0">@Model.SceneResults.Count.ToString("N0")</h3>
                </div>
            </div>
            <div class="col-md-3">
                <div class="border rounded p-3 h-100">
                    <p class="eyebrow">Tokens</p>
                    <h3 class="mb-0">@Display(run.TotalTokens)</h3>
                </div>
            </div>
            <div class="col-md-3">
                <div class="border rounded p-3 h-100">
                    <p class="eyebrow">Duration</p>
                    <h3 class="mb-0">@DisplayDuration(run.TotalDurationMs)</h3>
                </div>
            </div>
        </div>

        <dl class="mt-3 mb-0">
            <dt>Current stage</dt>
            <dd>@Display(run.CurrentStage)</dd>
            <dt>Current message</dt>
            <dd>@Display(run.CurrentMessage)</dd>
            <dt>Chapter structure status</dt>
            <dd>@ChapterStructureStatus(Model.ChapterResult, run)</dd>
            <dt>Scene progress</dt>
            <dd>
                Detected: @Display(run.TotalDetectedScenes),
                completed: @Display(run.CompletedScenes),
                failed: @Display(run.FailedScenes)
            </dd>
            <dt>Source type</dt>
            <dd>@run.SourceType</dd>
            <dt>Source file</dt>
            <dd>@Display(run.SourceFileName)</dd>
            <dt>Source file size</dt>
            <dd>@DisplayBytes(run.SourceFileSizeBytes)</dd>
            <dt>Source word count</dt>
            <dd>@Display(run.SourceWordCount)</dd>
            <dt>Source character count</dt>
            <dd>@Display(run.SourceCharacterCount)</dd>
            <dt>Submitted paragraph count</dt>
            <dd>@Display(run.SourceParagraphCount)</dd>
            <dt>First five numbered paragraphs</dt>
            <dd><pre class="mb-0">@FirstNumberedParagraphs(run)</pre></dd>
            <dt>Last paragraph number</dt>
            <dd>@Display(LastParagraphNumber(run))</dd>
            <dt>Source chapter count</dt>
            <dd>@Display(run.SourceChapterCount)</dd>
            <dt>Detected document features</dt>
            <dd>
                Images: @Display(run.SourceDetectedImagesCount),
                tables: @Display(run.SourceDetectedTablesCount),
                footnotes: @Display(run.SourceDetectedFootnotesCount),
                comments: @Display(run.SourceDetectedCommentsCount)
            </dd>
            <dt>Project</dt>
            <dd>@DisplayName(run.ProjectTitle, run.ProjectID, "Project")</dd>
            <dt>Book</dt>
            <dd>@DisplayName(run.BookTitle, run.BookID, "Book")</dd>
            <dt>Chapter</dt>
            <dd>@DisplayChapter(run.ChapterID, run.ChapterNumber)</dd>
            <dt>Prompt version</dt>
            <dd>@run.PromptVersion</dd>
            <dt>Prompt versions summary</dt>
            <dd>@Display(run.PromptVersionsSummary)</dd>
            <dt>Model</dt>
            <dd>@run.Model</dd>
            <dt>Chapter Structure model used</dt>
            <dd>@ChapterStructureModelUsed(Model)</dd>
            <dt>Scene Intelligence model used</dt>
            <dd>@SceneIntelligenceModelUsed(Model)</dd>
            <dt>Started</dt>
            <dd>@run.StartedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</dd>
            <dt>Completed</dt>
            <dd>@(run.CompletedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC</dd>
            <dt>Cancellation requested</dt>
            <dd>@(run.CancellationRequestedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC</dd>
            <dt>Cancelled</dt>
            <dd>@(run.CancelledUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC</dd>
            <dt>Failure stage</dt>
            <dd>@Display(run.FailureStage)</dd>
            <dt>Input tokens</dt>
            <dd>@Display(run.TotalInputTokens)</dd>
            <dt>Output tokens</dt>
            <dd>@Display(run.TotalOutputTokens)</dd>
            <dt>Estimated cost</dt>
            <dd>@DisplayCost(run.EstimatedCostGBP, "GBP") / @DisplayCost(run.EstimatedCostUSD, "USD")</dd>
            <dt>Error message</dt>
            <dd>@Display(run.ErrorMessage)</dd>
            <dt>Error detail</dt>
            <dd><pre class="mb-0">@Display(run.ErrorDetail)</pre></dd>
        </dl>

        @if (canCancel)
        {
            <form asp-action="CancelStoryIntelligenceRun" asp-route-id="@run.StoryIntelligenceRunID" method="post" class="mt-3">
                <button class="btn btn-outline-danger" type="submit">Cancel run</button>
            </form>
        }
        else if (CanRerun(run))
        {
            <div class="mt-3">
                <a class="btn btn-outline-primary" asp-action="StoryIntelligenceFullChapterTest" asp-route-sourceRunId="@run.StoryIntelligenceRunID">Run same source again with different models</a>
            </div>
        }

        <div class="mt-3">
            @if (Model.ImportCommit is not null && string.Equals(Model.ImportCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
            {
                <div class="alert alert-success mb-0">
                    This run was committed on @Model.ImportCommit.CommittedAt.ToString("yyyy-MM-dd HH:mm:ss") UTC.
                    Created @Model.ImportCommit.ScenesCreated.ToString("N0") scene@(Model.ImportCommit.ScenesCreated == 1 ? string.Empty : "s")
                    and @Model.ImportCommit.MetricsCreated.ToString("N0") metric value@(Model.ImportCommit.MetricsCreated == 1 ? string.Empty : "s").
                </div>
            }
            else if (CanCommitImport(run, Model.SceneResults))
            {
                <a class="btn btn-primary" asp-action="ConfirmStoryIntelligenceImportCommit" asp-route-id="@run.StoryIntelligenceRunID">Commit Import</a>
            }
        </div>
    </section>

    <section class="edit-panel">
        <h2>Chapter result</h2>
        @if (Model.ChapterResult is null)
        {
            <p class="mb-0">No chapter result was saved for this run.</p>
        }
        else
        {
            var chapter = Model.ChapterResult;
            <dl>
                <dt>Source label</dt>
                <dd>@chapter.SourceLabel</dd>
                <dt>Chapter</dt>
                <dd>@Display(chapter.ChapterNumber)</dd>
                <dt>Prompt version</dt>
                <dd>@chapter.PromptVersion</dd>
                <dt>Model</dt>
                <dd>@chapter.Model</dd>
                <dt>Validation</dt>
                <dd>@chapter.ValidationErrorsCount.ToString("N0") error(s), @chapter.ValidationWarningsCount.ToString("N0") warning(s)</dd>
                <dt>Duration</dt>
                <dd>@DisplayDuration(chapter.DurationMs)</dd>
                <dt>Tokens</dt>
                <dd>@Display(chapter.TotalTokens) total (@Display(chapter.InputTokens) input, @Display(chapter.OutputTokens) output)</dd>
            </dl>

            <dl>
                <dt>Highest returned endParagraph</dt>
                <dd>@Display(HighestRawReturnedEndParagraph(chapter))</dd>
                <dt>Returned scene boundary ranges</dt>
                <dd>@DisplayRawBoundaryRanges(chapter)</dd>
                <dt>Normalised scene boundary ranges used</dt>
                <dd>@DisplayNormalisedBoundaryRanges(chapter)</dd>
                <dt>Chapter structure normalisation</dt>
                <dd>@ChapterStructureNormalisation(chapter)</dd>
            </dl>
            @if (ReturnedEndParagraphExceedsSubmitted(chapter, run))
            {
                <div class="alert alert-warning">
                    The model returned paragraph numbers beyond the submitted chapter. This usually means the prompt was not using explicit numbered paragraphs.
                </div>
            }
            <label class="form-label" for="chapterParsedJson">Parsed / normalised JSON used for validation and splitting</label>
            <textarea class="form-control font-monospace" id="chapterParsedJson" rows="10" readonly>@chapter.ParsedJson</textarea>
            <label class="form-label mt-3" for="chapterOutputJson">Extracted output JSON</label>
            <textarea class="form-control font-monospace" id="chapterOutputJson" rows="10" readonly>@chapter.OutputTextJson</textarea>
            <label class="form-label mt-3" for="chapterRawJson">Raw OpenAI response JSON</label>
            <textarea class="form-control font-monospace" id="chapterRawJson" rows="10" readonly>@chapter.RawResponseJson</textarea>
        }
    </section>

    <section class="edit-panel">
        <h2>Scene results</h2>
        @if (Model.SceneResults.Count == 0)
        {
            <p class="mb-0">No scene results were saved for this run.</p>
        }
    </section>

    <section class="edit-panel">
        <h2>Import Preview</h2>
        @if (Model.ImportPreview is null)
        {
            <p class="mb-0">No import preview is available.</p>
        }
        else
        {
            var preview = Model.ImportPreview;
            <div class="alert alert-info">Dry run only. No PlotDirector entities are created or updated from this preview.</div>

            <h3>Characters</h3>
            @RenderEntityList(preview.Characters)

            <h3>Locations</h3>
            @RenderEntityList(preview.Locations)

            <h3>Assets</h3>
            @RenderEntityList(preview.Assets)

            <h3>Relationships</h3>
            @if (preview.Relationships.Count == 0)
            {
                <p>No relationship changes detected.</p>
            }
            else
            {
                <ul>
                    @foreach (var relationship in preview.Relationships)
                    {
                        <li>@relationship.Action: @relationship.CharacterA - @relationship.Signal - @relationship.CharacterB (@DisplayConfidence(relationship.ConfidencePercent))</li>
                    }
                </ul>
            }

            <h3>Knowledge</h3>
            @if (preview.Knowledge.Count == 0)
            {
                <p>No knowledge changes detected.</p>
            }
            else
            {
                <ul>
                    @foreach (var item in preview.Knowledge)
                    {
                        <li>@item.Action: @item.Character - @item.KnowledgeItem (@item.OldState -> @item.NewState, @DisplayConfidence(item.ConfidencePercent))</li>
                    }
                </ul>
            }

            @if (preview.UnresolvedKnowledge.Count > 0)
            {
                <h4>Knowledge involving unresolved references</h4>
                <ul>
                    @foreach (var item in preview.UnresolvedKnowledge)
                    {
                        <li>@item.Action: @item.Character - @item.KnowledgeItem (@item.OldState -> @item.NewState, @DisplayConfidence(item.ConfidencePercent))</li>
                    }
                </ul>
            }

            <h3>Timeline Clues</h3>
            <p>@preview.Timeline.Count.ToString("N0") timeline clue@(preview.Timeline.Count == 1 ? string.Empty : "s") detected.</p>
            @if (preview.Timeline.Count > 0)
            {
                <ul>
                    @foreach (var item in preview.Timeline.Take(20))
                    {
                        <li>Scene @Display(item.SceneNumber): @item.SceneDate (@DisplayConfidence(item.ConfidencePercent))</li>
                    }
                </ul>
            }

            <h3>Warnings</h3>
            <ul class="mb-0">
                @foreach (var warning in preview.Warnings)
                {
                    <li>@warning</li>
                }
            </ul>
        }
    </section>

    @foreach (var scene in Model.SceneResults)
    {
        <section class="edit-panel">
            <h2>Suggested Scene @scene.TemporarySceneNumber.ToString("N0")</h2>
            <dl>
                <dt>Paragraph range</dt>
                <dd>@Display(scene.StartParagraph)-@Display(scene.EndParagraph)</dd>
                <dt>Source label</dt>
                <dd>@scene.SourceLabel</dd>
                <dt>Prompt version</dt>
                <dd>@scene.PromptVersion</dd>
                <dt>Model</dt>
                <dd>@scene.Model</dd>
                <dt>Validation</dt>
                <dd>@scene.ValidationErrorsCount.ToString("N0") error(s), @scene.ValidationWarningsCount.ToString("N0") warning(s)</dd>
                @if (IsFailedScene(scene))
                {
                    <dt>Failure stage</dt>
                    <dd>@SceneFailureStage(scene)</dd>
                    <dt>Error message</dt>
                    <dd>@SceneErrorMessage(scene)</dd>
                    <dt>Retry attempted</dt>
                    <dd>@(SceneRetryAttempted(scene) ? "Yes" : "No")</dd>
                    <dt>Retry error</dt>
                    <dd>@Display(SceneRetryError(scene))</dd>
                }
                <dt>Duration</dt>
                <dd>@DisplayDuration(scene.DurationMs)</dd>
                <dt>Tokens</dt>
                <dd>@Display(scene.TotalTokens) total (@Display(scene.InputTokens) input, @Display(scene.OutputTokens) output)</dd>
            </dl>

            <label class="form-label" for="sceneParsedJson-@scene.SceneResultID">Parsed JSON</label>
            <textarea class="form-control font-monospace" id="sceneParsedJson-@scene.SceneResultID" rows="10" readonly>@scene.ParsedJson</textarea>
            <label class="form-label mt-3" for="sceneOutputJson-@scene.SceneResultID">Extracted raw assistant output</label>
            <textarea class="form-control font-monospace" id="sceneOutputJson-@scene.SceneResultID" rows="10" readonly>@scene.OutputTextJson</textarea>
            <label class="form-label mt-3" for="sceneRawJson-@scene.SceneResultID">Raw OpenAI response JSON</label>
            <textarea class="form-control font-monospace" id="sceneRawJson-@scene.SceneResultID" rows="10" readonly>@scene.RawResponseJson</textarea>
        </section>
    }
}

@functions {
    private static string Display(object? value) => value switch
    {
        null => "None",
        string text when string.IsNullOrWhiteSpace(text) => "None",
        decimal number => number.ToString("0.##"),
        int number => number.ToString("N0"),
        _ => value.ToString() ?? "None"
    };

    private static string DisplayDuration(long? milliseconds)
        => milliseconds.HasValue
            ? TimeSpan.FromMilliseconds(milliseconds.Value).TotalSeconds < 1
                ? $"{milliseconds.Value:N0} ms"
                : $"{TimeSpan.FromMilliseconds(milliseconds.Value).TotalSeconds:0.00} sec"
            : "None";

    private static string DisplayBytes(long? bytes)
        => bytes.HasValue ? $"{bytes.Value:N0} bytes" : "None";

    private static string DisplayCost(decimal? value, string currency)
        => value.HasValue ? $"{value.Value:0.000000} {currency}" : "None";

    private static string DisplayConfidence(int? percent)
        => percent.HasValue
            ? percent.Value >= 90
                ? $"{percent.Value}% High"
                : percent.Value >= 70
                    ? $"{percent.Value}% Medium"
                    : $"{percent.Value}% Low"
            : "Unknown";

    private static Microsoft.AspNetCore.Html.IHtmlContent RenderEntityList(IReadOnlyList<StoryImportEntityResolution> items)
    {
        if (items.Count == 0)
        {
            return new Microsoft.AspNetCore.Html.HtmlString("<p>No items detected.</p>");
        }

        var builder = new System.Text.StringBuilder();
        foreach (var group in items
            .GroupBy(item => GroupLabel(item.Status))
            .OrderBy(group => GroupSort(group.Key)))
        {
            builder.Append("<h4>").Append(Encode(group.Key)).Append("</h4><ul>");
            foreach (var item in group.OrderBy(item => item.MatchedName ?? item.InputName, StringComparer.OrdinalIgnoreCase))
            {
                builder.Append("<li>");
                builder.Append(IconForStatus(item.Status)).Append(' ');
                builder.Append(Encode(item.MatchedName ?? item.InputName));
                if (!string.IsNullOrWhiteSpace(item.MatchedName) && !string.Equals(item.MatchedName, item.InputName, StringComparison.OrdinalIgnoreCase))
                {
                    builder.Append(" <span class=\"muted\">from ").Append(Encode(item.InputName)).Append("</span>");
                }

                builder.Append(" <span class=\"muted\">(").Append(Encode(DisplayConfidence(item.ConfidencePercent))).Append(")</span>");
                if (item.Candidates.Count > 1)
                {
                    builder.Append("<br /><span class=\"muted\">Candidates: ")
                        .Append(Encode(string.Join(", ", item.Candidates.Select(candidate => candidate.Name))))
                        .Append("</span>");
                }

                if (!string.IsNullOrWhiteSpace(item.Notes))
                {
                    builder.Append("<br /><span class=\"muted\">").Append(Encode(item.Notes)).Append("</span>");
                }

                builder.Append("</li>");
            }

            builder.Append("</ul>");
        }

        return new Microsoft.AspNetCore.Html.HtmlString(builder.ToString());
    }

    private static string IconForStatus(string status)
        => status switch
        {
            StoryImportResolutionStatuses.Matched => "✓",
            StoryImportResolutionStatuses.Ambiguous => "⚠",
            StoryImportResolutionStatuses.PossibleDuplicate => "⚠",
            StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate => "⚠",
            StoryImportResolutionStatuses.PossibleMatch => "?",
            StoryImportResolutionStatuses.NewCharacter or StoryImportResolutionStatuses.NewLocation or StoryImportResolutionStatuses.NewAsset => "+",
            _ => "•"
        };

    private static string GroupLabel(string status)
        => status switch
        {
            StoryImportResolutionStatuses.NewCharacter or StoryImportResolutionStatuses.NewLocation or StoryImportResolutionStatuses.NewAsset => "New",
            StoryImportResolutionStatuses.PossibleDuplicate or StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate => "Possible Duplicate / Alias Candidate",
            StoryImportResolutionStatuses.GenericReference or StoryImportResolutionStatuses.Ignored => "Generic / Ignored",
            StoryImportResolutionStatuses.UnresolvedPersonReference => "Unresolved",
            _ => status
        };

    private static int GroupSort(string group)
        => group switch
        {
            "Matched" => 0,
            "New" => 1,
            "Possible Match" => 2,
            "Possible Duplicate / Alias Candidate" => 3,
            "Ambiguous" => 4,
            "Generic / Ignored" => 5,
            "Unresolved" => 6,
            _ => 99
        };

    private static string Encode(string? value)
        => System.Net.WebUtility.HtmlEncode(value ?? string.Empty);

    private static string ChapterStructureModelUsed(StoryIntelligenceSavedRunDetailViewModel model)
        => !string.IsNullOrWhiteSpace(model.ChapterResult?.Model)
            ? model.ChapterResult.Model
            : ParseModelPart(model.Run?.Model, "Chapter");

    private static string SceneIntelligenceModelUsed(StoryIntelligenceSavedRunDetailViewModel model)
    {
        var sceneModels = model.SceneResults
            .Select(scene => scene.Model)
            .Where(value => !string.IsNullOrWhiteSpace(value))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToList();
        if (sceneModels.Count > 0)
        {
            return string.Join(", ", sceneModels);
        }

        return ParseModelPart(model.Run?.Model, "Scene");
    }

    private static string ParseModelPart(string? summary, string key)
    {
        if (string.IsNullOrWhiteSpace(summary))
        {
            return "None";
        }

        foreach (var segment in summary.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
        {
            var parts = segment.Split('=', 2, StringSplitOptions.TrimEntries);
            if (parts.Length == 2 && string.Equals(parts[0], key, StringComparison.OrdinalIgnoreCase))
            {
                return parts[1];
            }
        }

        return summary;
    }

    private static string DisplayName(string? title, int? id, string label)
        => !string.IsNullOrWhiteSpace(title)
            ? title
            : id.HasValue
                ? $"{label} {id.Value:N0}"
                : "None";

    private static string DisplayChapter(int? chapterId, decimal? chapterNumber)
        => chapterId.HasValue
            ? chapterNumber.HasValue
                ? $"Chapter {chapterNumber.Value:0.##} (ID {chapterId.Value:N0})"
                : $"Chapter ID {chapterId.Value:N0}"
            : "None";

    private static string FirstNumberedParagraphs(StoryIntelligenceSavedRun run)
    {
        var paragraphs = StoryIntelligenceParagraphs.Split(run.SourceText);
        if (paragraphs.Count == 0)
        {
            return "None";
        }

        return string.Join(
            Environment.NewLine + Environment.NewLine,
            paragraphs.Take(5).Select((paragraph, index) => $"[{index + 1}] {PreviewParagraph(paragraph)}"));
    }

    private static int? LastParagraphNumber(StoryIntelligenceSavedRun run)
    {
        var paragraphs = StoryIntelligenceParagraphs.Split(run.SourceText);
        return paragraphs.Count == 0 ? null : paragraphs.Count;
    }

    private static string ChapterStructureStatus(StoryIntelligenceSavedChapterResult? chapter, StoryIntelligenceSavedRun run)
    {
        if (chapter is not null)
        {
            return chapter.ValidationErrorsCount > 0
                ? $"Saved with {chapter.ValidationErrorsCount:N0} validation error(s)."
                : "Saved.";
        }

        return string.Equals(run.FailureStage, StoryIntelligenceFailureStages.ChapterStructure, StringComparison.Ordinal)
            ? "Failed before a valid chapter structure result was saved."
            : "Not saved yet.";
    }

    private static bool IsFailedScene(StoryIntelligenceSavedSceneResult scene)
        => scene.ValidationErrorsCount > 0 && SceneJsonString(scene, "error") is not null;

    private static bool CanRerun(StoryIntelligenceSavedRun run)
        => !string.IsNullOrWhiteSpace(run.SourceText)
            && (run.Status is StoryIntelligenceRunStatuses.Completed
                or StoryIntelligenceRunStatuses.CompletedWithWarnings
                or StoryIntelligenceRunStatuses.Failed
                or StoryIntelligenceRunStatuses.Cancelled);

    private static bool CanCommitImport(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> scenes)
        => run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings
            && run.ProjectID.HasValue
            && run.BookID.HasValue
            && run.ChapterID.HasValue
            && scenes.Count > 0;

    private static string SceneFailureStage(StoryIntelligenceSavedSceneResult scene)
        => SceneErrorMessage(scene).Contains("boundary", StringComparison.OrdinalIgnoreCase)
            ? StoryIntelligenceFailureStages.SceneSplit
            : StoryIntelligenceFailureStages.SceneIntelligence;

    private static string SceneErrorMessage(StoryIntelligenceSavedSceneResult scene)
        => SceneJsonString(scene, "error") ?? "Scene failed. See parsed JSON for details.";

    private static bool SceneRetryAttempted(StoryIntelligenceSavedSceneResult scene)
    {
        try
        {
            using var document = System.Text.Json.JsonDocument.Parse(scene.ParsedJson);
            if (document.RootElement.TryGetProperty("retryAttempted", out var retry) && retry.ValueKind is System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False)
            {
                return retry.GetBoolean();
            }
        }
        catch (System.Text.Json.JsonException)
        {
        }

        return false;
    }

    private static string? SceneRetryError(StoryIntelligenceSavedSceneResult scene)
        => SceneJsonString(scene, "retryError");

    private static string? SceneJsonString(StoryIntelligenceSavedSceneResult scene, string propertyName)
    {
        if (string.IsNullOrWhiteSpace(scene.ParsedJson))
        {
            return null;
        }

        try
        {
            using var document = System.Text.Json.JsonDocument.Parse(scene.ParsedJson);
            if (document.RootElement.TryGetProperty(propertyName, out var value) && value.ValueKind == System.Text.Json.JsonValueKind.String)
            {
                return value.GetString();
            }
        }
        catch (System.Text.Json.JsonException)
        {
        }

        return null;
    }

    private static int? HighestRawReturnedEndParagraph(StoryIntelligenceSavedChapterResult chapter)
        => ReadBoundaryRanges(chapter.OutputTextJson).Select(range => range.End).DefaultIfEmpty().Max() is var max && max > 0 ? max : null;

    private static string DisplayRawBoundaryRanges(StoryIntelligenceSavedChapterResult chapter)
        => DisplayBoundaryRanges(chapter.OutputTextJson);

    private static string DisplayNormalisedBoundaryRanges(StoryIntelligenceSavedChapterResult chapter)
        => DisplayBoundaryRanges(chapter.ParsedJson);

    private static string DisplayBoundaryRanges(string json)
    {
        var ranges = ReadBoundaryRanges(json).ToList();
        return ranges.Count == 0
            ? "None"
            : string.Join(", ", ranges.Select(range => $"{range.Start}-{range.End}"));
    }

    private static bool ReturnedEndParagraphExceedsSubmitted(StoryIntelligenceSavedChapterResult chapter, StoryIntelligenceSavedRun run)
    {
        var highest = HighestRawReturnedEndParagraph(chapter);
        var submitted = run.SourceParagraphCount;
        return highest.HasValue && submitted.HasValue && highest.Value > submitted.Value;
    }

    private static string ChapterStructureNormalisation(StoryIntelligenceSavedChapterResult chapter)
    {
        if (string.IsNullOrWhiteSpace(chapter.ParsedJson))
        {
            return "None";
        }

        try
        {
            using var document = System.Text.Json.JsonDocument.Parse(chapter.ParsedJson);
            if (!document.RootElement.TryGetProperty("normalisation", out var normalisation)
                || normalisation.ValueKind != System.Text.Json.JsonValueKind.Array)
            {
                return "None";
            }

            var messages = normalisation
                .EnumerateArray()
                .Select(issue => issue.TryGetProperty("message", out var message) && message.ValueKind == System.Text.Json.JsonValueKind.String
                    ? message.GetString()
                    : null)
                .Where(message => !string.IsNullOrWhiteSpace(message))
                .ToList();

            return messages.Count == 0 ? "None" : string.Join(Environment.NewLine, messages);
        }
        catch (System.Text.Json.JsonException)
        {
            return "None";
        }
    }

    private static IReadOnlyList<(int Start, int End)> ReadBoundaryRanges(string json)
    {
        if (string.IsNullOrWhiteSpace(json))
        {
            return [];
        }

        try
        {
            using var document = System.Text.Json.JsonDocument.Parse(json);
            if (!document.RootElement.TryGetProperty("sceneBoundaries", out var boundaries)
                || boundaries.ValueKind != System.Text.Json.JsonValueKind.Array)
            {
                return [];
            }

            var ranges = new List<(int Start, int End)>();
            foreach (var boundary in boundaries.EnumerateArray())
            {
                var start = boundary.TryGetProperty("startParagraph", out var startElement) && startElement.TryGetInt32(out var startValue)
                    ? startValue
                    : 0;
                var end = boundary.TryGetProperty("endParagraph", out var endElement) && endElement.TryGetInt32(out var endValue)
                    ? endValue
                    : 0;
                ranges.Add((start, end));
            }

            return ranges;
        }
        catch (System.Text.Json.JsonException)
        {
            return [];
        }
    }

    private static string PreviewParagraph(string value)
    {
        var cleaned = value.ReplaceLineEndings(" ").Trim();
        return cleaned.Length <= 220 ? cleaned : $"{cleaned[..220]}...";
    }
}
