using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;

namespace PlotLine.Services;

public interface IStoryIntelligenceImportCommitService
{
    Task<StoryIntelligenceImportConfirmationViewModel?> BuildConfirmationAsync(int runId);
    Task<StoryIntelligenceImportCommitResult> CommitAsync(int runId, int userId);
}

public sealed class StoryIntelligenceImportCommitService(
    IStoryIntelligenceResultRepository storyRuns,
    IProjectRepository projects,
    IBookRepository books,
    IChapterRepository chapters,
    ILookupRepository lookups,
    ISceneMetricTypeRepository metricTypes,
    IWriterWorkspaceRepository writerWorkspace,
    ICharacterRepository characters,
    IStoryIntelligencePipelineStateService pipelineState,
    ILogger<StoryIntelligenceImportCommitService> logger) : IStoryIntelligenceImportCommitService
{
    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        PropertyNameCaseInsensitive = true
    };

    private static readonly IReadOnlyDictionary<string, string[]> MetricAliases = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
    {
        ["action"] = ["Action"],
        ["emotion"] = ["Emotion", "Emotional Weight", "Emotional Resonance"],
        ["tension"] = ["Tension"],
        ["conflict"] = ["Conflict", "Relationship Conflict"],
        ["mystery"] = ["Mystery"],
        ["romance"] = ["Romance", "Emotional Intimacy"],
        ["humour"] = ["Humour", "Humor", "Comedy", "Comic Relief"],
        ["humor"] = ["Humour", "Humor", "Comedy", "Comic Relief"],
        ["revelation"] = ["Revelation", "Reveal"],
        ["pacing"] = ["Pacing", "PacingIntensity", "Pacing Intensity", "Overall Intensity"],
        ["pacingIntensity"] = ["Pacing", "PacingIntensity", "Pacing Intensity", "Overall Intensity"]
    };

    public async Task<StoryIntelligenceImportConfirmationViewModel?> BuildConfirmationAsync(int runId)
    {
        var prepared = await PrepareAsync(runId);
        if (prepared is null)
        {
            return null;
        }

        return prepared.Confirmation;
    }

    public async Task<StoryIntelligenceImportCommitResult> CommitAsync(int runId, int userId)
    {
        var prepared = await PrepareAsync(runId);
        if (prepared is null)
        {
            return new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence run could not be found." };
        }

        if (!prepared.Confirmation.CanCommit)
        {
            return new StoryIntelligenceImportCommitResult
            {
                Success = false,
                Message = prepared.Confirmation.HasCompletedCommit
                    ? "This Story Intelligence run has already been committed."
                    : string.Join(" ", prepared.Confirmation.Blockers)
            };
        }

        var result = await storyRuns.CommitImportAsync(new StoryIntelligenceImportCommitRequest
        {
            StoryIntelligenceRunID = prepared.Run.StoryIntelligenceRunID,
            UserID = userId,
            ProjectID = prepared.Run.ProjectID!.Value,
            BookID = prepared.Run.BookID!.Value,
            ChapterID = prepared.Run.ChapterID!.Value,
            TimeModeID = prepared.TimeModeID,
            TimeConfidenceID = prepared.TimeConfidenceID,
            RevisionStatusID = prepared.RevisionStatusID,
            TimelineNoteTypeID = prepared.TimelineNoteTypeID,
            ChapterSummary = prepared.ChapterSummary,
            ChapterPurposeID = prepared.ChapterPurposeID,
            Scenes = prepared.Scenes,
            Warnings = prepared.Confirmation.Warnings
        });

        logger.LogInformation(
            "Committed Story Intelligence run {StoryIntelligenceRunID}. CommitID={CommitID} ScenesCreated={ScenesCreated} MetricsCreated={MetricsCreated}",
            runId,
            result.CommitID,
            result.ScenesCreated,
            result.MetricsCreated);

        if (result.Success && prepared.Run.ProjectID.HasValue && prepared.Run.BookID.HasValue)
        {
            await pipelineState.RecordSceneImportAsync(prepared.Run.ProjectID.Value, prepared.Run.BookID.Value, prepared.Run.StoryIntelligenceRunID);
        }

        return result;
    }

    private async Task<PreparedImport?> PrepareAsync(int runId)
    {
        var run = await storyRuns.GetRunAsync(runId);
        if (run is null)
        {
            return null;
        }

        var existingCommit = await storyRuns.GetImportCommitAsync(runId);
        var blockers = new List<string>();
        var warnings = new List<string>();

        if (run.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings))
        {
            blockers.Add("Only completed Story Intelligence runs can be committed.");
        }

        if (run.ProjectID is null || run.BookID is null || run.ChapterID is null)
        {
            blockers.Add("The run must have a project, book and chapter before it can create scenes.");
        }

        var sceneResults = await storyRuns.ListSceneResultsAsync(runId);
        if (sceneResults.Count == 0)
        {
            blockers.Add("No saved scene results are available to import.");
        }

        var readiness = run.ChapterID.HasValue
            ? await storyRuns.GetChapterImportReadinessAsync(run.ChapterID.Value)
            : null;
        if (readiness is not null && !readiness.CanCommit)
        {
            blockers.Add(readiness.BlockReason ?? "This chapter already contains scenes.");
        }

        if (existingCommit is not null && string.Equals(existingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
        {
            blockers.Add("This Story Intelligence run has already been committed.");
        }

        var lookupData = await lookups.GetAllAsync();
        var activeMetricTypes = run.ProjectID.HasValue
            ? (await metricTypes.ListForManagementAsync(run.ProjectID.Value)).Where(metric => metric.IsActive).ToList()
            : [];
        var noteTypes = await writerWorkspace.ListNoteTypesAsync();
        var characterMap = run.ProjectID.HasValue
            ? await BuildCharacterMapAsync(run.ProjectID.Value)
            : new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);

        var timeModeId = FindId(lookupData.TimeModes, item => item.TimeModeName, item => item.TimeModeID, "Relative", "Unknown");
        var timeConfidenceId = FindId(lookupData.TimeConfidences, item => item.TimeConfidenceName, item => item.TimeConfidenceID, "Implied", "Unknown");
        var revisionStatusId = FindId(lookupData.RevisionStatuses, item => item.StatusName, item => item.RevisionStatusID, "Planned");
        var timelineNoteTypeId = noteTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Timeline Note", StringComparison.OrdinalIgnoreCase))?.SceneNoteTypeID;
        if (timelineNoteTypeId is null)
        {
            warnings.Add("Timeline clues and Story Intelligence import notes will not be imported because the Timeline Note type is not available.");
        }

        var purposeMap = lookupData.ScenePurposeTypes.ToDictionary(type => Clean(type.PurposeName), type => type.ScenePurposeTypeID, StringComparer.OrdinalIgnoreCase);
        var chapterPurposeMap = lookupData.ChapterPurposeTypes.ToDictionary(type => Clean(type.PurposeName), type => type.ChapterPurposeID, StringComparer.OrdinalIgnoreCase);
        var metricMap = BuildMetricMap(activeMetricTypes);
        var chapterResult = await storyRuns.GetChapterResultAsync(runId);
        var parsedChapter = TryReadChapter(chapterResult);
        var chapterSummary = Clean(parsedChapter?.ChapterSummary);
        var chapterPurposeId = MatchChapterPurpose(parsedChapter, chapterPurposeMap);
        var importScenes = new List<StoryIntelligenceSceneImportItem>();
        foreach (var sceneResult in sceneResults.OrderBy(scene => scene.TemporarySceneNumber))
        {
            var parsed = TryReadScene(sceneResult);
            if (parsed is null)
            {
                var fallback = BuildFallbackImportScene(sceneResult, metricMap);
                if (fallback is null)
                {
                    warnings.Add($"Scene {sceneResult.TemporarySceneNumber:N0} could not be prepared automatically and needs review.");
                    continue;
                }

                warnings.Add($"Scene {sceneResult.TemporarySceneNumber:N0} will be created with limited detail because some analysis could not be read.");
                importScenes.Add(fallback);
                continue;
            }

            importScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap));
        }

        if (importScenes.Count == 0)
        {
            blockers.Add("No scenes are ready to create for this chapter.");
        }

        var projectName = run.ProjectID.HasValue ? (await projects.GetAsync(run.ProjectID.Value))?.ProjectName ?? $"Project {run.ProjectID.Value:N0}" : "None";
        var bookTitle = run.BookID.HasValue ? (await books.GetAsync(run.BookID.Value))?.BookDisplayTitle ?? $"Book {run.BookID.Value:N0}" : "None";
        var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;
        var chapterLabel = chapter is null
            ? "None"
            : string.IsNullOrWhiteSpace(chapter.ChapterTitle)
                ? $"Chapter {chapter.ChapterNumber:0.##}"
                : $"Chapter {chapter.ChapterNumber:0.##}: {chapter.ChapterTitle}";

        return new PreparedImport(
            run,
            new StoryIntelligenceImportConfirmationViewModel
            {
                Run = run,
                ExistingCommit = existingCommit,
                ProjectName = projectName,
                BookTitle = bookTitle,
                ChapterLabel = chapterLabel,
                ExistingSceneCount = readiness?.ActiveSceneCount ?? 0,
                CommitMode = readiness?.CommitMode ?? string.Empty,
                ScenesToCreate = importScenes.Count,
                MetricsToImport = importScenes.Sum(scene => scene.Metrics.Count),
                MetricNames = importScenes.SelectMany(scene => scene.Metrics.Select(metric => metric.MetricName)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(name => name).ToList(),
                Warnings = warnings,
                Blockers = blockers,
                Scenes = importScenes
            },
            importScenes,
            timeModeId,
            timeConfidenceId,
            revisionStatusId,
            timelineNoteTypeId,
            string.IsNullOrWhiteSpace(chapterSummary) ? null : chapterSummary,
            chapterPurposeId);
    }

    private static StoryIntelligenceSceneImportItem BuildImportScene(
        StoryIntelligenceSavedSceneResult sceneResult,
        SceneIntelligenceScene parsed,
        IReadOnlyDictionary<string, int> characterMap,
        IReadOnlyDictionary<string, int> purposeMap,
        IReadOnlyDictionary<string, SceneMetricType> metricMap)
    {
        var povName = Clean(parsed.PointOfView?.CharacterName);
        var povCharacterId = !string.IsNullOrWhiteSpace(povName)
            && !string.Equals(povName, "Narrator", StringComparison.OrdinalIgnoreCase)
            && characterMap.TryGetValue(povName, out var characterId)
                ? characterId
                : (int?)null;
        var summary = Clean(parsed.Summary?.Short);
        var sceneTitle = GenerateTitle(sceneResult.TemporarySceneNumber, summary);

        return new StoryIntelligenceSceneImportItem
        {
            SceneResultID = sceneResult.SceneResultID,
            TemporarySceneNumber = sceneResult.TemporarySceneNumber,
            StartParagraph = sceneResult.StartParagraph,
            EndParagraph = sceneResult.EndParagraph,
            SourceLabel = sceneResult.SourceLabel,
            ParsedScene = parsed,
            PovCharacterID = povCharacterId,
            PurposeTypeIDs = MatchPurposes(parsed, purposeMap),
            Metrics = BuildMetrics(parsed, metricMap),
            SceneTitle = sceneTitle,
            Summary = string.IsNullOrWhiteSpace(summary) ? null : summary,
            PurposeNotes = BuildPurposeNotes(parsed, sceneResult, povName, povCharacterId),
            OutcomeNotes = string.IsNullOrWhiteSpace(parsed.Summary?.Detailed) ? null : Clean(parsed.Summary.Detailed),
            ImportNoteText = BuildImportNote(parsed, povName, povCharacterId)
        };
    }

    private static StoryIntelligenceSceneImportItem? BuildFallbackImportScene(
        StoryIntelligenceSavedSceneResult sceneResult,
        IReadOnlyDictionary<string, SceneMetricType> metricMap)
    {
        if (!sceneResult.StartParagraph.HasValue || !sceneResult.EndParagraph.HasValue || sceneResult.StartParagraph > sceneResult.EndParagraph)
        {
            return null;
        }

        var summary = ExtractSummaryFromJsonText(sceneResult.OutputTextJson)
            ?? ExtractSummaryFromJsonText(sceneResult.ParsedJson)
            ?? $"Scene {sceneResult.TemporarySceneNumber:N0} from paragraphs {DisplayRange(sceneResult.StartParagraph, sceneResult.EndParagraph)}.";
        var parsed = new SceneIntelligenceScene
        {
            SchemaVersion = "fallback",
            Summary = new SceneIntelligenceSummary
            {
                Short = summary,
                Detailed = summary,
                Confidence = null
            },
            PointOfView = new SceneIntelligencePointOfView
            {
                CharacterName = null,
                NarrativeMode = "Unknown",
                Confidence = null
            },
            Setting = new SceneIntelligenceSetting
            {
                LocationName = null,
                LocationType = "Unknown",
                Confidence = null
            },
            Metrics = new Dictionary<string, SceneIntelligenceMetric>()
        };

        return new StoryIntelligenceSceneImportItem
        {
            SceneResultID = sceneResult.SceneResultID,
            TemporarySceneNumber = sceneResult.TemporarySceneNumber,
            StartParagraph = sceneResult.StartParagraph,
            EndParagraph = sceneResult.EndParagraph,
            SourceLabel = sceneResult.SourceLabel,
            ParsedScene = parsed,
            PovCharacterID = null,
            PurposeTypeIDs = [],
            Metrics = [],
            SceneTitle = GenerateTitle(sceneResult.TemporarySceneNumber, summary),
            Summary = summary,
            PurposeNotes = $"Story Intelligence import source: run {sceneResult.StoryIntelligenceRunID:N0}, paragraphs {DisplayRange(sceneResult.StartParagraph, sceneResult.EndParagraph)}.{Environment.NewLine}Some optional analysis could not be read, so this scene was created with limited detail.",
            OutcomeNotes = null,
            ImportNoteText = "Scene created with limited Story Intelligence detail. Review the source manuscript for POV, setting, metrics and timeline notes."
        };
    }

    private async Task<Dictionary<string, int>> BuildCharacterMapAsync(int projectId)
    {
        var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
        foreach (var character in await characters.ListCharactersAsync(projectId))
        {
            AddCharacterMap(map, character.CharacterName, character.CharacterID);
            AddCharacterMap(map, character.ShortName, character.CharacterID);
            foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
            {
                AddCharacterMap(map, alias.Alias, character.CharacterID);
            }
        }

        return map;
    }

    private static void AddCharacterMap(Dictionary<string, int> map, string? name, int characterId)
    {
        var clean = Clean(name);
        if (!string.IsNullOrWhiteSpace(clean))
        {
            map.TryAdd(clean, characterId);
        }
    }

    private static Dictionary<string, SceneMetricType> BuildMetricMap(IReadOnlyList<SceneMetricType> metricTypes)
    {
        var byName = metricTypes.ToDictionary(metric => Clean(metric.MetricName), metric => metric, StringComparer.OrdinalIgnoreCase);
        var map = new Dictionary<string, SceneMetricType>(StringComparer.OrdinalIgnoreCase);
        foreach (var (schemaName, aliases) in MetricAliases)
        {
            var metric = aliases.Select(alias => byName.TryGetValue(Clean(alias), out var match) ? match : null).FirstOrDefault(match => match is not null);
            if (metric is not null)
            {
                map[schemaName] = metric;
            }
        }

        return map;
    }

    private static IReadOnlyList<SceneMetricValue> BuildMetrics(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, SceneMetricType> metricMap)
    {
        if (parsed.Metrics is null || parsed.Metrics.Count == 0)
        {
            return [];
        }

        var values = new List<SceneMetricValue>();
        var importedMetricTypeIds = new HashSet<int>();
        foreach (var (name, metric) in parsed.Metrics)
        {
            if (!metric.Score.HasValue || !metricMap.TryGetValue(name, out var metricType))
            {
                continue;
            }

            if (!importedMetricTypeIds.Add(metricType.MetricTypeID))
            {
                continue;
            }

            values.Add(new SceneMetricValue
            {
                MetricTypeID = metricType.MetricTypeID,
                MetricName = metricType.MetricName,
                Value = Math.Clamp(metric.Score.Value, metricType.MinValue, metricType.MaxValue),
                Notes = metric.Confidence.HasValue ? $"Story Intelligence confidence {metric.Confidence.Value:0.##}." : "Imported from Story Intelligence."
            });
        }

        return values;
    }

    private static IReadOnlyList<int> MatchPurposes(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, int> purposeMap)
    {
        var values = ReadPurposeTexts(parsed).Select(Clean).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
        if (values.Count == 0)
        {
            return [];
        }

        var matches = purposeMap
            .Where(pair => values.Any(value => value.Contains(pair.Key, StringComparison.OrdinalIgnoreCase) || pair.Key.Contains(value, StringComparison.OrdinalIgnoreCase)))
            .Select(pair => pair.Value)
            .Distinct()
            .ToList();
        return matches;
    }

    private static string? BuildPurposeNotes(SceneIntelligenceScene parsed, StoryIntelligenceSavedSceneResult result, string povName, int? povCharacterId)
    {
        var lines = new List<string>
        {
            $"Story Intelligence import source: run {result.StoryIntelligenceRunID:N0}, paragraphs {DisplayRange(result.StartParagraph, result.EndParagraph)}."
        };

        if (!string.IsNullOrWhiteSpace(parsed.ScenePurpose?.ObservedFunction))
        {
            lines.Add($"Observed function: {parsed.ScenePurpose.ObservedFunction}");
        }

        var purposeLabels = ReadPurposeLabels(parsed.ScenePurpose).ToList();
        if (purposeLabels.Count > 0)
        {
            lines.Add($"Purpose labels observed: {string.Join(", ", purposeLabels)}");
        }

        if (parsed.Setting is not null)
        {
            var setting = string.Join("; ", new[]
            {
                Label("Location", parsed.Setting.LocationName),
                Label("Location type", parsed.Setting.LocationType),
                Label("Room", parsed.Setting.GenericRoomType),
                Label("Parent hint", parsed.Setting.ParentLocationHint),
                Label("Time", parsed.Setting.DateOrTimeReference ?? parsed.Setting.TimeOfDay)
            }.Where(value => !string.IsNullOrWhiteSpace(value)));
            if (!string.IsNullOrWhiteSpace(setting))
            {
                lines.Add($"Setting notes: {setting}");
            }
        }

        if (!string.IsNullOrWhiteSpace(povName) && !povCharacterId.HasValue)
        {
            lines.Add($"POV not linked: {povName}");
        }

        return string.Join(Environment.NewLine, lines);
    }

    private static string? BuildImportNote(SceneIntelligenceScene parsed, string povName, int? povCharacterId)
    {
        var lines = new List<string>();
        var timelineClues = ReadFactualTimelineClues(parsed).ToList();
        if (timelineClues.Count > 0)
        {
            lines.Add("Timeline clues:");
            lines.AddRange(timelineClues.Select(clue => $"- {clue}"));
        }

        var sceneNotes = ReadUsefulSceneNotes(parsed).ToList();
        if (sceneNotes.Count > 0)
        {
            if (lines.Count > 0)
            {
                lines.Add(string.Empty);
            }

            lines.Add("Scene notes:");
            lines.AddRange(sceneNotes.Select(note => $"- {note}"));
        }

        if (!string.IsNullOrWhiteSpace(povName) && !povCharacterId.HasValue)
        {
            lines.Add($"POV left unlinked because '{povName}' did not resolve to an existing character.");
        }

        return lines.Count == 0 ? null : string.Join(Environment.NewLine, lines);
    }

    private static IEnumerable<string> ReadPurposeTexts(SceneIntelligenceScene parsed)
    {
        if (!string.IsNullOrWhiteSpace(parsed.ScenePurpose?.ObservedFunction))
        {
            yield return parsed.ScenePurpose.ObservedFunction;
        }

        foreach (var label in ReadPurposeLabels(parsed.ScenePurpose))
        {
            yield return label;
        }
    }

    private static IEnumerable<string> ReadPurposeLabels(SceneIntelligenceScenePurpose? purpose)
    {
        if (purpose?.ExtensionData is null)
        {
            yield break;
        }

        foreach (var propertyName in new[] { "purposeLabels", "labels", "label" })
        {
            if (!purpose.ExtensionData.TryGetValue(propertyName, out var element))
            {
                continue;
            }

            if (element.ValueKind == JsonValueKind.String)
            {
                var label = Clean(element.GetString());
                if (!string.IsNullOrWhiteSpace(label))
                {
                    yield return label;
                }
            }
            else if (element.ValueKind == JsonValueKind.Array)
            {
                foreach (var item in element.EnumerateArray())
                {
                    if (item.ValueKind == JsonValueKind.String)
                    {
                        var label = Clean(item.GetString());
                        if (!string.IsNullOrWhiteSpace(label))
                        {
                            yield return label;
                        }
                    }
                }
            }
        }
    }

    private static IEnumerable<string> ReadFactualTimelineClues(SceneIntelligenceScene parsed)
    {
        if (parsed.TimelineClues is null)
        {
            yield break;
        }

        foreach (var clue in parsed.TimelineClues)
        {
            var value = Clean(clue.AbsoluteDate ?? clue.RelativeOrder ?? clue.Clue);
            if (string.IsNullOrWhiteSpace(value))
            {
                continue;
            }

            var source = $"{clue.Clue} {clue.RelativeOrder} {clue.AbsoluteDate} {clue.Evidence}";
            if (source.Contains("speculat", StringComparison.OrdinalIgnoreCase)
                || source.Contains("uncertain", StringComparison.OrdinalIgnoreCase)
                || source.Contains("unknown", StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            yield return value;
        }
    }

    private static IEnumerable<string> ReadUsefulSceneNotes(SceneIntelligenceScene parsed)
    {
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        foreach (var note in ReadSettingNotes(parsed.Setting))
        {
            if (seen.Add(note))
            {
                yield return note;
            }
        }

        if (parsed.SourceLimits?.AmbiguityNotes is not null)
        {
            foreach (var ambiguity in parsed.SourceLimits.AmbiguityNotes.Select(Clean).Where(value => !string.IsNullOrWhiteSpace(value)))
            {
                var note = $"Ambiguity: {ambiguity}";
                if (seen.Add(note))
                {
                    yield return note;
                }
            }
        }

        if (parsed.Observations is null)
        {
            yield break;
        }

        foreach (var observation in parsed.Observations)
        {
            var note = Clean(observation.Description);
            if (string.IsNullOrWhiteSpace(note) || !IsUsefulSceneObservation(observation))
            {
                continue;
            }

            if (seen.Add(note))
            {
                yield return note;
            }
        }
    }

    private static IEnumerable<string> ReadSettingNotes(SceneIntelligenceSetting? setting)
    {
        if (setting is null)
        {
            yield break;
        }

        var atmosphere = ReadStringExtension(setting.ExtensionData, "atmosphere", "mood", "tone");
        if (!string.IsNullOrWhiteSpace(atmosphere))
        {
            yield return $"Atmosphere: {atmosphere}";
        }
    }

    private static bool IsUsefulSceneObservation(SceneIntelligenceObservation observation)
    {
        var type = Clean(observation.ObservationType);
        var predicate = Clean(observation.Predicate);
        return type.Contains("setting", StringComparison.OrdinalIgnoreCase)
            || type.Contains("atmosphere", StringComparison.OrdinalIgnoreCase)
            || type.Contains("scene", StringComparison.OrdinalIgnoreCase)
            || predicate.Contains("observes", StringComparison.OrdinalIgnoreCase)
            || predicate.Contains("reveals", StringComparison.OrdinalIgnoreCase)
            || predicate.Contains("confirms", StringComparison.OrdinalIgnoreCase)
            || predicate.Contains("discovers", StringComparison.OrdinalIgnoreCase);
    }

    private static string? ReadStringExtension(Dictionary<string, JsonElement>? data, params string[] names)
    {
        if (data is null)
        {
            return null;
        }

        foreach (var name in names)
        {
            if (data.TryGetValue(name, out var element) && element.ValueKind == JsonValueKind.String)
            {
                var value = Clean(element.GetString());
                if (!string.IsNullOrWhiteSpace(value))
                {
                    return value;
                }
            }
        }

        return null;
    }

    private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
    {
        if (string.IsNullOrWhiteSpace(result.ParsedJson))
        {
            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)
        {
        }

        return null;
    }

    private static ChapterStructureModel? TryReadChapter(StoryIntelligenceSavedChapterResult? result)
    {
        if (string.IsNullOrWhiteSpace(result?.ParsedJson))
        {
            return null;
        }

        try
        {
            return JsonSerializer.Deserialize<ChapterStructureModel>(result.ParsedJson, JsonOptions);
        }
        catch (JsonException)
        {
            return null;
        }
    }

    private static int? MatchChapterPurpose(ChapterStructureModel? chapter, IReadOnlyDictionary<string, int> purposeMap)
    {
        if (chapter is null || purposeMap.Count == 0)
        {
            return null;
        }

        var texts = new List<string>();
        if (!string.IsNullOrWhiteSpace(chapter.ChapterSummary))
        {
            texts.Add(chapter.ChapterSummary);
        }

        if (chapter.ExtensionData is not null)
        {
            foreach (var name in new[] { "chapterPurpose", "primaryPurpose", "purpose" })
            {
                if (chapter.ExtensionData.TryGetValue(name, out var element) && element.ValueKind == JsonValueKind.String)
                {
                    texts.Add(element.GetString() ?? string.Empty);
                }
            }
        }

        texts.AddRange((chapter.SceneBoundaries ?? []).Select(boundary => boundary.Reason ?? string.Empty));
        var combined = Clean(string.Join(" ", texts));
        if (string.IsNullOrWhiteSpace(combined))
        {
            return null;
        }

        var direct = purposeMap.FirstOrDefault(pair => combined.Contains(pair.Key, StringComparison.OrdinalIgnoreCase));
        if (direct.Value > 0)
        {
            return direct.Value;
        }

        var lowered = combined.ToLowerInvariant();
        foreach (var (key, value) in purposeMap)
        {
            var purpose = key.ToLowerInvariant();
            if ((purpose.Contains("investigation") && ContainsAny(lowered, "investigat", "question", "search", "clue"))
                || (purpose.Contains("revelation") && ContainsAny(lowered, "reveal", "discover", "learns", "truth"))
                || (purpose.Contains("relationship") && ContainsAny(lowered, "relationship", "bond", "trust", "conflict between"))
                || (purpose.Contains("character") && ContainsAny(lowered, "character", "grief", "fear", "choice", "realises", "realizes"))
                || (purpose.Contains("escalation") && ContainsAny(lowered, "escalat", "danger", "worsens", "pressure"))
                || (purpose.Contains("turning") && ContainsAny(lowered, "turning point", "changes direction", "decision"))
                || (purpose.Contains("transition") && ContainsAny(lowered, "transition", "moves from", "sets up"))
                || (purpose.Contains("resolution") && ContainsAny(lowered, "resolve", "settles", "concludes"))
                || (purpose.Contains("world") && ContainsAny(lowered, "world", "setting", "institution", "rules")))
            {
                return value;
            }
        }

        return null;
    }

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

    private static string? ExtractSummaryFromJsonText(string? value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return null;
        }

        try
        {
            using var document = JsonDocument.Parse(value);
            if (TryReadSummary(document.RootElement, out var summary))
            {
                return TrimTo(summary!, 500);
            }
        }
        catch (JsonException)
        {
        }

        var shortIndex = value.IndexOf("\"short\"", StringComparison.OrdinalIgnoreCase);
        if (shortIndex >= 0)
        {
            var colonIndex = value.IndexOf(':', shortIndex);
            if (colonIndex >= 0)
            {
                var afterColon = value[(colonIndex + 1)..].TrimStart();
                if (afterColon.Length > 1 && afterColon[0] == '"')
                {
                    var endIndex = afterColon.IndexOf('"', 1);
                    if (endIndex > 1)
                    {
                        return TrimTo(afterColon[1..endIndex], 500);
                    }
                }
            }
        }

        return null;
    }

    private static bool TryReadSummary(JsonElement element, out string? summary)
    {
        summary = null;
        if (element.ValueKind != JsonValueKind.Object)
        {
            return false;
        }

        if (element.TryGetProperty("parsedScene", out var parsedScene))
        {
            return TryReadSummary(parsedScene, out summary);
        }

        if (!element.TryGetProperty("summary", out var summaryElement) || summaryElement.ValueKind != JsonValueKind.Object)
        {
            return false;
        }

        foreach (var propertyName in new[] { "short", "detailed" })
        {
            if (summaryElement.TryGetProperty(propertyName, out var valueElement)
                && valueElement.ValueKind == JsonValueKind.String
                && !string.IsNullOrWhiteSpace(valueElement.GetString()))
            {
                summary = valueElement.GetString();
                return true;
            }
        }

        return false;
    }

    private static int FindId<T>(IReadOnlyList<T> items, Func<T, string> name, Func<T, int> id, params string[] preferredNames)
    {
        foreach (var preferred in preferredNames)
        {
            var match = items.FirstOrDefault(item => string.Equals(name(item), preferred, StringComparison.OrdinalIgnoreCase));
            if (match is not null)
            {
                return id(match);
            }
        }

        return items.Count > 0 ? id(items[0]) : throw new InvalidOperationException("Required lookup data is missing.");
    }

    private static string GenerateTitle(int sceneNumber, string summary)
        => string.IsNullOrWhiteSpace(summary)
            ? $"Scene {sceneNumber:N0}"
            : $"Scene {sceneNumber:N0} - {TrimTo(summary, 60)}";

    private static string TrimTo(string value, int maxLength)
        => value.Length <= maxLength ? value : value[..maxLength].TrimEnd() + "...";

    private static string DisplayRange(int? start, int? end)
        => start.HasValue && end.HasValue ? $"{start.Value:N0}-{end.Value:N0}" : "unknown";

    private static string? Label(string label, string? value)
        => string.IsNullOrWhiteSpace(value) ? null : $"{label}: {value}";

    private static string Clean(string? value)
        => string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));

    private sealed record PreparedImport(
        StoryIntelligenceSavedRun Run,
        StoryIntelligenceImportConfirmationViewModel Confirmation,
        IReadOnlyList<StoryIntelligenceSceneImportItem> Scenes,
        int TimeModeID,
        int TimeConfidenceID,
        int RevisionStatusID,
        int? TimelineNoteTypeID,
        string? ChapterSummary,
        int? ChapterPurposeID);
}
