using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;

namespace PlotLine.Services;

public interface IStoryIntelligenceKnowledgeImportService
{
    Task<StoryIntelligenceKnowledgeReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
    Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form);
}

public sealed class StoryIntelligenceKnowledgeImportService(
    IStoryIntelligenceResultRepository runs,
    ICharacterRepository characters,
    ISceneRepository scenes,
    IAssetRepository assets,
    IStoryIntelligencePipelineStateService pipelineState,
    ILogger<StoryIntelligenceKnowledgeImportService> logger) : IStoryIntelligenceKnowledgeImportService
{
    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        PropertyNameCaseInsensitive = true
    };

    public async Task<StoryIntelligenceKnowledgeReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
    {
        var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
        var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates);
        var decidedKeys = batch.KnowledgeDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
        var selectedStateIds = batch.KnowledgeSelections.ToDictionary(selection => selection.Key, selection => selection.KnowledgeStateID, StringComparer.OrdinalIgnoreCase);
        var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();

        return new StoryIntelligenceKnowledgeReviewViewModel
        {
            HasCommittedScenes = data.HasCommittedScenes,
            CanImport = visibleCandidates.Count > 0,
            AlreadyLinkedCount = data.AlreadyLinkedCount,
            IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
            KnowledgeStateOptions = lookupData.KnowledgeStates
                .OrderBy(state => state.SortOrder)
                .ThenBy(state => state.StateName)
                .Select(state => new SelectListItem(state.StateName, state.KnowledgeStateID.ToString()))
                .ToList(),
            Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceKnowledgeReviewCandidateViewModel
            {
                Key = candidate.Key,
                CharacterName = candidate.CharacterName,
                KnowledgeStatement = candidate.KnowledgeStatement,
                KnowledgeStateID = ResolveKnowledgeStateID(lookupData.KnowledgeStates, selectedStateIds.GetValueOrDefault(candidate.Key), candidate.KnowledgeStateName),
                KnowledgeStateName = candidate.KnowledgeStateName,
                AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
                Confidence = ConfidenceBand(candidate.Confidence),
                FirstScene = candidate.FirstScene,
                ExampleScene = candidate.ExampleScene,
                Evidence = candidate.Evidence,
                ExistingKnowledgeID = candidate.ExistingKnowledgeID,
                ExistingKnowledgeLabel = candidate.ExistingKnowledgeLabel
            }).ToList()
        };
    }

    public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form)
    {
        var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
        var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates);
        if (!data.HasCommittedScenes)
        {
            return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing knowledge." };
        }

        var choices = form.Knowledge
            .Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
            .ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
        SaveKnowledgeSelections(batch, choices.Values);
        if (choices.Count == 0)
        {
            if (data.Candidates.Count == 0)
            {
                batch.KnowledgeStageComplete = true;
                batch.LastKnowledgeImportResult = new StoryIntelligenceKnowledgeImportBatchResult();
                await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID);
                return new StoryIntelligenceImportCommitResult
                {
                    Success = true,
                    Message = "Knowledge review completed. No knowledge decisions were waiting."
                };
            }

            return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one knowledge item to create, link, merge or ignore." };
        }

        var stateIds = lookupData.KnowledgeStates.Select(state => state.KnowledgeStateID).ToHashSet();
        var created = 0;
        var linkedExisting = 0;
        var merged = 0;
        var ignored = 0;
        var resolvedKnowledge = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
        var candidatesByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase);

        foreach (var candidate in data.Candidates)
        {
            if (!choices.TryGetValue(candidate.Key, out var choice))
            {
                continue;
            }

            if (string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
            {
                ignored++;
                AddDecision(batch, candidate.Key, StoryIntelligenceKnowledgeImportActions.Ignore, null, KnowledgeLabel(candidate), false);
                continue;
            }

            if (string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.Alias, StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            var knowledgeId = string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase)
                ? candidate.ExistingKnowledgeID
                : null;

            if (!knowledgeId.HasValue)
            {
                if (!choice.KnowledgeStateID.HasValue || !stateIds.Contains(choice.KnowledgeStateID.Value))
                {
                    return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose a valid knowledge state for each knowledge item you want to create." };
                }

                var firstAppearance = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).First();
                knowledgeId = await characters.SaveKnowledgeAsync(new CharacterKnowledgeItem
                {
                    CharacterID = candidate.CharacterID,
                    StoryAssetID = candidate.StoryAssetID,
                    PlotThreadID = null,
                    SceneID = firstAppearance.SceneID,
                    KnowledgeStateID = choice.KnowledgeStateID.Value,
                    SourceEventID = null,
                    Description = BuildKnowledgeDescription(candidate, firstAppearance)
                });
                created++;
            }
            else
            {
                linkedExisting++;
            }

            resolvedKnowledge[candidate.Key] = knowledgeId.Value;
            AddDecision(batch, candidate.Key, choice.Action, knowledgeId.Value, KnowledgeLabel(candidate), true);
        }

        foreach (var candidate in data.Candidates)
        {
            if (!choices.TryGetValue(candidate.Key, out var choice)
                || !string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.Alias, StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            var targetKey = Clean(choice.AliasTargetKey);
            if (string.IsNullOrWhiteSpace(targetKey)
                || string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
                || !choices.TryGetValue(targetKey, out var targetChoice)
                || string.Equals(targetChoice.Action, StoryIntelligenceKnowledgeImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
                || string.Equals(targetChoice.Action, StoryIntelligenceKnowledgeImportActions.Alias, StringComparison.OrdinalIgnoreCase)
                || !candidatesByKey.ContainsKey(targetKey)
                || !resolvedKnowledge.TryGetValue(targetKey, out var targetKnowledgeId))
            {
                logger.LogWarning(
                    "Story Intelligence knowledge merge candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
                    candidate.Key,
                    targetKey);
                continue;
            }

            resolvedKnowledge[candidate.Key] = targetKnowledgeId;
            merged++;
            AddDecision(batch, candidate.Key, StoryIntelligenceKnowledgeImportActions.Alias, targetKnowledgeId, KnowledgeLabel(candidate), true);
        }

        batch.KnowledgeStageComplete = true;
        batch.LastKnowledgeImportResult = new StoryIntelligenceKnowledgeImportBatchResult
        {
            KnowledgeCreated = created,
            KnowledgeLinked = linkedExisting,
            KnowledgeMerged = merged,
            KnowledgeIgnored = ignored
        };
        await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID);

        logger.LogInformation(
            "Imported Story Intelligence knowledge for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored}",
            batch.BatchID,
            created,
            linkedExisting,
            merged,
            ignored);

        return new StoryIntelligenceImportCommitResult
        {
            Success = true,
            Message = $"Knowledge imported. {created:N0} created, {linkedExisting:N0} linked, {merged:N0} merged, {ignored:N0} ignored."
        };
    }

    private async Task<KnowledgeCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch, IReadOnlyList<KnowledgeState> knowledgeStates)
    {
        var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID);
        var assetIndex = await BuildAssetIndexAsync(batch.ProjectID);
        var existingKnowledge = await ListExistingKnowledgeAsync(batch.ProjectID);
        var groups = new Dictionary<string, KnowledgeCandidate>(StringComparer.OrdinalIgnoreCase);
        var alreadyLinked = 0;
        var hasCommittedScenes = false;

        foreach (var item in batch.Items)
        {
            var commit = await runs.GetImportCommitAsync(item.RunID);
            if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            hasCommittedScenes = true;
            var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
                .Where(scene => scene.ImportRunID == item.RunID)
                .ToList();
            var importedByRange = importedScenes
                .Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
                .ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
            var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
            var sceneResults = await runs.ListSceneResultsAsync(item.RunID);

            foreach (var sceneResult in sceneResults)
            {
                var parsed = TryReadScene(sceneResult);
                if (parsed is null)
                {
                    continue;
                }

                var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
                if (importedScene is null)
                {
                    continue;
                }

                var existingSceneKnowledge = await characters.ListKnowledgeBySceneAsync(importedScene.SceneID);
                alreadyLinked += existingSceneKnowledge.Count;
                AddSceneKnowledge(groups, characterIndex, assetIndex, existingKnowledge, importedScene, parsed, knowledgeStates);
            }
        }

        var candidates = groups.Values
            .OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count())
            .ThenBy(candidate => candidate.CharacterName)
            .ThenBy(candidate => candidate.KnowledgeStatement)
            .ToList();

        return new KnowledgeCandidateData(hasCommittedScenes, alreadyLinked, candidates);
    }

    private static void AddSceneKnowledge(
        Dictionary<string, KnowledgeCandidate> groups,
        CharacterIndex characterIndex,
        AssetIndex assetIndex,
        IReadOnlyDictionary<string, CharacterKnowledgeItem> existingKnowledge,
        Scene importedScene,
        SceneIntelligenceScene parsed,
        IReadOnlyList<KnowledgeState> knowledgeStates)
    {
        foreach (var change in parsed.KnowledgeChanges ?? [])
        {
            AddKnowledge(groups, characterIndex, assetIndex, existingKnowledge, importedScene, parsed, change.RecipientCharacter, change.KnowledgeItem, change.ChangeType, change.Evidence, change.Confidence, knowledgeStates);
        }

        foreach (var observation in parsed.Observations ?? [])
        {
            if (!IsKnowledgeObservation(observation))
            {
                continue;
            }

            AddKnowledge(
                groups,
                characterIndex,
                assetIndex,
                existingKnowledge,
                importedScene,
                parsed,
                observation.SubjectName,
                FirstConfigured(observation.Description, JoinConfigured(observation.Predicate, observation.ObjectName)),
                FirstConfigured(observation.Predicate, observation.ObservationType),
                observation.Evidence,
                observation.Confidence,
                knowledgeStates);
        }
    }

    private static void AddKnowledge(
        Dictionary<string, KnowledgeCandidate> groups,
        CharacterIndex characterIndex,
        AssetIndex assetIndex,
        IReadOnlyDictionary<string, CharacterKnowledgeItem> existingKnowledge,
        Scene importedScene,
        SceneIntelligenceScene parsed,
        string? characterName,
        string? knowledgeItem,
        string? changeType,
        string? evidence,
        decimal? confidence,
        IReadOnlyList<KnowledgeState> knowledgeStates)
    {
        var character = characterIndex.Find(characterName);
        var statement = CleanKnowledgeStatement(knowledgeItem);
        if (character is null || string.IsNullOrWhiteSpace(statement))
        {
            return;
        }

        var key = KnowledgeKey(character.CharacterID, statement);
        if (!groups.TryGetValue(key, out var candidate))
        {
            var existingKey = ExistingKnowledgeKey(character.CharacterID, statement);
            existingKnowledge.TryGetValue(existingKey, out var existingMatch);
            var stateName = KnowledgeStateFromSignal(changeType, statement, knowledgeStates);
            var assetMatch = assetIndex.Find(statement);
            candidate = new KnowledgeCandidate(
                key,
                character.CharacterID,
                character.CharacterName,
                statement,
                stateName,
                assetMatch?.StoryAssetID,
                existingMatch?.CharacterKnowledgeID,
                existingMatch is null ? null : ExistingKnowledgeLabel(existingMatch));
            groups[key] = candidate;
        }

        if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m))
        {
            candidate.KnowledgeStateName = KnowledgeStateFromSignal(changeType, statement, knowledgeStates);
        }

        candidate.Confidence = Max(candidate.Confidence, confidence);
        candidate.FirstScene ??= BuildSceneReference(importedScene);
        candidate.ExampleScene ??= BuildFirstAppearance(importedScene, parsed);
        candidate.Evidence ??= Clean(evidence);
        candidate.Appearances.Add(new KnowledgeAppearanceImport(
            importedScene.SceneID,
            importedScene.SceneNumber,
            importedScene.SceneTitle,
            changeType,
            evidence,
            confidence));
    }

    private async Task<IReadOnlyDictionary<string, CharacterKnowledgeItem>> ListExistingKnowledgeAsync(int projectId)
    {
        var rows = new Dictionary<string, CharacterKnowledgeItem>(StringComparer.OrdinalIgnoreCase);
        foreach (var character in await characters.ListCharactersAsync(projectId))
        {
            foreach (var item in await characters.ListKnowledgeByCharacterAsync(character.CharacterID))
            {
                var key = ExistingKnowledgeKey(item.CharacterID, KnowledgeTitle(item));
                if (!rows.ContainsKey(key))
                {
                    rows[key] = item;
                }
            }
        }

        return rows;
    }

    private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
    {
        var index = new CharacterIndex();
        foreach (var character in await characters.ListCharactersAsync(projectId))
        {
            index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
            index.Add(character.ShortName, character.CharacterID, character.CharacterName);
            foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
            {
                index.Add(alias.Alias, character.CharacterID, character.CharacterName);
            }
        }

        return index;
    }

    private async Task<AssetIndex> BuildAssetIndexAsync(int projectId)
    {
        var index = new AssetIndex();
        foreach (var asset in await assets.ListAssetsAsync(projectId))
        {
            index.Add(asset.AssetName, asset.StoryAssetID, asset.AssetName);
            foreach (var alias in await assets.ListAliasesAsync(asset.StoryAssetID))
            {
                index.Add(alias.Alias, asset.StoryAssetID, asset.AssetName);
            }
        }

        return index;
    }

    private static Scene? ResolveImportedScene(
        StoryIntelligenceSavedSceneResult sceneResult,
        IReadOnlyDictionary<string, Scene> byRange,
        IReadOnlyDictionary<int, Scene> byNumber)
    {
        if (sceneResult.StartParagraph.HasValue
            && sceneResult.EndParagraph.HasValue
            && byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
        {
            return rangeMatch;
        }

        return byNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var numberMatch) ? numberMatch : 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 bool IsKnowledgeObservation(SceneIntelligenceObservation observation)
    {
        var type = Clean(observation.ObservationType);
        if (!type.Contains("knowledge", StringComparison.OrdinalIgnoreCase)
            && !type.Contains("discover", StringComparison.OrdinalIgnoreCase)
            && !type.Contains("belief", StringComparison.OrdinalIgnoreCase)
            && !type.Contains("suspect", StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }

        return IsCharacterEntity(observation.SubjectEntityType)
            && !string.IsNullOrWhiteSpace(observation.SubjectName)
            && !string.IsNullOrWhiteSpace(FirstConfigured(observation.Description, observation.Predicate, observation.ObjectName));
    }

    private static string KnowledgeStateFromSignal(string? changeType, string? statement, IReadOnlyList<KnowledgeState> states)
    {
        var value = NormaliseForMatching($"{changeType} {statement}");
        var preferred =
            ContainsAny(value, "unaware", "does not know", "doesn't know", "not know", "forgets", "forgot", "stops believing") ? "Unaware" :
            ContainsAny(value, "suspects", "suspect", "suspicion") ? "Suspects" :
            ContainsAny(value, "misunderstands", "misunderstand", "mistaken", "false belief", "wrongly believes") ? "Misunderstands" :
            ContainsAny(value, "believes", "belief", "partially", "may know") ? "Partially Knows" :
            ContainsAny(value, "learns", "discovers", "confirms", "realises", "realizes", "is told", "knows", "reveals", "revealed") ? "Knows" :
            "Knows";

        return states.FirstOrDefault(state => string.Equals(state.StateName, preferred, StringComparison.OrdinalIgnoreCase))?.StateName
            ?? states.FirstOrDefault(state => string.Equals(state.StateName, "Knows", StringComparison.OrdinalIgnoreCase))?.StateName
            ?? states.FirstOrDefault()?.StateName
            ?? "Knows";
    }

    private static int ResolveKnowledgeStateID(IReadOnlyList<KnowledgeState> states, int? selectedKnowledgeStateId, string? suggestedState)
        => selectedKnowledgeStateId.HasValue && states.Any(state => state.KnowledgeStateID == selectedKnowledgeStateId.Value)
            ? selectedKnowledgeStateId.Value
            : states.FirstOrDefault(state => string.Equals(state.StateName, suggestedState, StringComparison.OrdinalIgnoreCase))?.KnowledgeStateID
                ?? states.FirstOrDefault(state => string.Equals(state.StateName, "Knows", StringComparison.OrdinalIgnoreCase))?.KnowledgeStateID
                ?? states.First().KnowledgeStateID;

    private static void SaveKnowledgeSelections(OnboardingStoryIntelligenceBatch batch, IEnumerable<StoryIntelligenceKnowledgeImportChoiceForm> choices)
    {
        foreach (var choice in choices)
        {
            if (string.IsNullOrWhiteSpace(choice.Key) || choice.KnowledgeStateID is not { } knowledgeStateId)
            {
                continue;
            }

            batch.KnowledgeSelections.RemoveAll(selection => string.Equals(selection.Key, choice.Key, StringComparison.OrdinalIgnoreCase));
            batch.KnowledgeSelections.Add(new OnboardingStoryIntelligenceKnowledgeSelection
            {
                Key = choice.Key,
                KnowledgeStateID = knowledgeStateId
            });
        }
    }

    private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, int? knowledgeId, string? label, bool createdOrLinked)
    {
        batch.KnowledgeDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase));
        batch.KnowledgeDecisions.Add(new OnboardingStoryIntelligenceKnowledgeDecision
        {
            Key = key,
            Action = action,
            CharacterKnowledgeID = knowledgeId,
            KnowledgeLabel = label,
            CreatedOrLinked = createdOrLinked
        });
    }

    private static string BuildKnowledgeDescription(KnowledgeCandidate candidate, KnowledgeAppearanceImport appearance)
    {
        var lines = new List<string> { candidate.KnowledgeStatement };
        if (!string.IsNullOrWhiteSpace(appearance.ChangeType))
        {
            lines.Add($"Change: {Clean(appearance.ChangeType)}.");
        }

        if (!string.IsNullOrWhiteSpace(appearance.Evidence))
        {
            lines.Add($"Evidence: {Clean(appearance.Evidence)}");
        }

        lines.Add("Imported from Story Intelligence knowledge review.");
        return string.Join(Environment.NewLine, lines);
    }

    private static string KnowledgeLabel(KnowledgeCandidate candidate)
        => $"{candidate.CharacterName}: {candidate.KnowledgeStatement}";

    private static string ExistingKnowledgeLabel(CharacterKnowledgeItem item)
        => $"{item.CharacterName}: {item.KnowledgeStateName} - {KnowledgeTitle(item)}";

    private static string KnowledgeTitle(CharacterKnowledgeItem item)
        => CleanKnowledgeStatement(item.Description);

    private static string CleanKnowledgeStatement(string? value)
    {
        var cleaned = Clean(value);
        if (string.IsNullOrWhiteSpace(cleaned))
        {
            return string.Empty;
        }

        foreach (var prefix in new[] { "Knowledge:", "Evidence:", "Change:" })
        {
            if (cleaned.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
            {
                cleaned = cleaned[prefix.Length..].Trim();
            }
        }

        var firstLine = cleaned.Split('\n', '\r').Select(line => line.Trim()).FirstOrDefault(line => !string.IsNullOrWhiteSpace(line));
        return firstLine ?? cleaned;
    }

    private static string KnowledgeKey(int characterId, string statement)
        => $"{characterId}:{CanonicalKnowledgeKey(statement)}";

    private static string ExistingKnowledgeKey(int characterId, string statement)
        => KnowledgeKey(characterId, statement);

    private static string CanonicalKnowledgeKey(string? value)
    {
        var normalised = NormaliseForMatching(value);
        var possessiveFamily = Regex.Match(
            normalised,
            @"\b(?<subject>\w+)\s+(?:is|was)\s+(?<holder>\w+)\s+s\s+(?:biological\s+)?(?<relation>father|mother|parent|sister|brother|sibling|aunt|uncle|cousin|child|son|daughter)\b",
            RegexOptions.IgnoreCase);
        if (possessiveFamily.Success)
        {
            return $"{possessiveFamily.Groups["relation"].Value} {possessiveFamily.Groups["subject"].Value} {possessiveFamily.Groups["holder"].Value}";
        }

        var pronounFamily = Regex.Match(
            normalised,
            @"\b(?<holder>\w+)\s+.*?\b(?<subject>\w+)\s+(?:is|was)\s+(?:her|his|their)\s+(?:biological\s+)?(?<relation>father|mother|parent|sister|brother|sibling|aunt|uncle|cousin|child|son|daughter)\b",
            RegexOptions.IgnoreCase);
        if (pronounFamily.Success)
        {
            return $"{pronounFamily.Groups["relation"].Value} {pronounFamily.Groups["subject"].Value} {pronounFamily.Groups["holder"].Value}";
        }

        normalised = Regex.Replace(normalised, @"\b(discovers?|learns?|realises?|realizes?|knows?|believes?|suspects?|confirms?|is told|that|the|a|an|his|her|their|its|about)\b", " ", RegexOptions.IgnoreCase);
        return Whitespace.Replace(normalised, " ").Trim();
    }

    private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
    {
        var summary = Clean(parsed.Summary?.Short);
        return string.IsNullOrWhiteSpace(summary)
            ? BuildSceneReference(scene)
            : $"{BuildSceneReference(scene)}: {summary}";
    }

    private static string BuildSceneReference(Scene scene)
        => $"Scene {scene.SceneNumber:g}";

    private static bool IsCharacterEntity(string? entityType)
        => string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);

    private static string ConfidenceBand(decimal? confidence)
        => confidence switch
        {
            >= 0.75m => "High",
            >= 0.45m => "Medium",
            null => "Unknown",
            _ => "Low"
        };

    private static decimal? Max(decimal? current, decimal? next)
        => current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;

    private static string? JoinConfigured(params string?[] values)
    {
        var configured = values.Select(Clean).Where(value => !string.IsNullOrWhiteSpace(value)).ToList();
        return configured.Count == 0 ? null : string.Join(" ", configured);
    }

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

    private static string Normalise(string? value)
        => Clean(value).ToLowerInvariant();

    private static string NormaliseForMatching(string? value)
        => $" {NonWord.Replace(Normalise(value), " ")} ";

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

    private static string Clean(string? value)
        => string.IsNullOrWhiteSpace(value) ? string.Empty : Whitespace.Replace(value, " ").Trim();

    private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled);
    private static readonly Regex NonWord = new(@"[^\p{L}\p{N}]+", RegexOptions.Compiled);

    private sealed record KnowledgeCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList<KnowledgeCandidate> Candidates);

    private sealed class KnowledgeCandidate(
        string key,
        int characterId,
        string characterName,
        string knowledgeStatement,
        string knowledgeStateName,
        int? storyAssetId,
        int? existingKnowledgeId,
        string? existingKnowledgeLabel)
    {
        public string Key { get; } = key;
        public int CharacterID { get; } = characterId;
        public string CharacterName { get; } = characterName;
        public string KnowledgeStatement { get; } = knowledgeStatement;
        public string KnowledgeStateName { get; set; } = knowledgeStateName;
        public int? StoryAssetID { get; } = storyAssetId;
        public int? ExistingKnowledgeID { get; } = existingKnowledgeId;
        public string? ExistingKnowledgeLabel { get; } = existingKnowledgeLabel;
        public decimal? Confidence { get; set; }
        public string? FirstScene { get; set; }
        public string? ExampleScene { get; set; }
        public string? Evidence { get; set; }
        public List<KnowledgeAppearanceImport> Appearances { get; } = [];
    }

    private sealed record KnowledgeAppearanceImport(
        int SceneID,
        decimal SceneNumber,
        string SceneTitle,
        string? ChangeType,
        string? Evidence,
        decimal? Confidence);

    private sealed class CharacterIndex
    {
        private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);

        public void Add(string? name, int characterId, string characterName)
        {
            var key = Clean(name);
            if (string.IsNullOrWhiteSpace(key) || byName.ContainsKey(key))
            {
                return;
            }

            byName[key] = new CharacterMatch(characterId, characterName);
        }

        public CharacterMatch? Find(string? name)
            => byName.TryGetValue(Clean(name), out var match) ? match : null;
    }

    private sealed class AssetIndex
    {
        private readonly Dictionary<string, AssetMatch> byName = new(StringComparer.OrdinalIgnoreCase);

        public void Add(string? name, int storyAssetId, string assetName)
        {
            var key = Clean(name);
            if (string.IsNullOrWhiteSpace(key) || byName.ContainsKey(key))
            {
                return;
            }

            byName[key] = new AssetMatch(storyAssetId, assetName);
        }

        public AssetMatch? Find(string? statement)
        {
            var value = NormaliseForMatching(statement);
            return byName.Values.FirstOrDefault(asset => value.Contains(NormaliseForMatching(asset.AssetName), StringComparison.Ordinal));
        }
    }

    private sealed record CharacterMatch(int CharacterID, string CharacterName);
    private sealed record AssetMatch(int StoryAssetID, string AssetName);
}
