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 IStoryIntelligenceRelationshipImportService
{
    Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
    Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form);
}

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

    public async Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
    {
        var data = await BuildCandidateDataAsync(batch);
        var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
        var typeOptions = ToRelationshipTypeOptions(lookupData.RelationshipTypes);
        var selectedTypeIds = batch.RelationshipSelections.ToDictionary(selection => selection.Key, selection => selection.RelationshipTypeID, StringComparer.OrdinalIgnoreCase);
        var decidedKeys = batch.RelationshipDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
        var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
        return new StoryIntelligenceRelationshipReviewViewModel
        {
            HasCommittedScenes = data.HasCommittedScenes,
            CanImport = visibleCandidates.Count > 0,
            AlreadyLinkedCount = data.AlreadyLinkedCount,
            IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
            RelationshipTypeOptions = typeOptions,
            Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel
            {
                Key = candidate.Key,
                CharacterAName = candidate.CharacterAName,
                CharacterBName = candidate.CharacterBName,
                RelationshipType = candidate.RelationshipType,
                RelationshipTypeID = ResolveRelationshipTypeID(lookupData.RelationshipTypes, selectedTypeIds.GetValueOrDefault(candidate.Key), candidate.RelationshipType),
                AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
                Confidence = ConfidenceBand(candidate.Confidence),
                FirstAppearance = candidate.FirstAppearanceNote,
                LastAppearance = candidate.LastAppearanceNote,
                ExampleScene = candidate.ExampleScene,
                ExampleContext = candidate.ExampleContext,
                ExistingRelationshipID = candidate.ExistingRelationshipID,
                ExistingRelationshipLabel = candidate.ExistingRelationshipLabel
            }).ToList()
        };
    }

    public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceRelationshipImportForm form)
    {
        var data = await BuildCandidateDataAsync(batch);
        if (!data.HasCommittedScenes)
        {
            return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing relationships." };
        }

        var choices = form.Relationships
            .Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
            .ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
        SaveRelationshipSelections(batch, choices.Values);
        if (choices.Count == 0)
        {
            if (data.Candidates.Count == 0)
            {
                batch.RelationshipStageComplete = true;
                batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult();
                await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID);
                return new StoryIntelligenceImportCommitResult
                {
                    Success = true,
                    Message = "Relationship review completed. No relationship decisions were waiting."
                };
            }

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

        var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
        var relationshipTypesById = lookupData.RelationshipTypes.ToDictionary(type => type.RelationshipTypeID);
        var created = 0;
        var linkedExisting = 0;
        var merged = 0;
        var ignored = 0;
        var eventsCreated = 0;
        var resolvedRelationships = 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, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
            {
                ignored++;
                AddDecision(batch, candidate.Key, StoryIntelligenceRelationshipImportActions.Ignore, null, RelationshipLabel(candidate), false);
                continue;
            }

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

            var relationshipId = string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase)
                ? candidate.ExistingRelationshipID
                : null;

            if (!relationshipId.HasValue)
            {
                if (!choice.RelationshipTypeID.HasValue || !relationshipTypesById.ContainsKey(choice.RelationshipTypeID.Value))
                {
                    return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose a valid relationship type for each relationship you want to create." };
                }

                relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship
                {
                    ProjectID = batch.ProjectID,
                    CharacterAID = candidate.CharacterAID,
                    CharacterBID = candidate.CharacterBID,
                    RelationshipTypeID = choice.RelationshipTypeID.Value,
                    IsPermanent = false,
                    StartSceneID = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).FirstOrDefault()?.SceneID,
                    EndSceneID = null,
                    IsKnownToReader = true,
                    Notes = BuildRelationshipNotes(candidate),
                    IsInitialRelationship = false,
                    InitialBookID = batch.BookID,
                    ReaderInitiallyKnows = true,
                    InitialRelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, candidate.RelationshipType),
                    InitialIntensity = ConfidenceIntensity(candidate.Confidence),
                    IsReciprocal = true
                });
                created++;
            }
            else
            {
                linkedExisting++;
            }

            resolvedRelationships[candidate.Key] = relationshipId.Value;
            AddDecision(batch, candidate.Key, choice.Action, relationshipId.Value, RelationshipLabel(candidate), true);
        }

        foreach (var candidate in data.Candidates)
        {
            if (!choices.TryGetValue(candidate.Key, out var choice)
                || !string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.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, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
                || string.Equals(targetChoice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase)
                || !candidatesByKey.ContainsKey(targetKey)
                || !resolvedRelationships.TryGetValue(targetKey, out var targetRelationshipId))
            {
                logger.LogWarning(
                    "Story Intelligence relationship merge candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
                    candidate.Key,
                    targetKey);
                continue;
            }

            resolvedRelationships[candidate.Key] = targetRelationshipId;
            merged++;
            AddDecision(batch, candidate.Key, StoryIntelligenceRelationshipImportActions.Alias, targetRelationshipId, RelationshipLabel(candidate), true);
        }

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

            foreach (var appearance in candidate.Appearances
                         .Where(appearance => !appearance.AlreadyLinked)
                         .GroupBy(appearance => appearance.SceneID)
                         .Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First()))
            {
                if (!HasMeaningfulRelationshipEvent(appearance))
                {
                    continue;
                }

                await characters.SaveRelationshipEventAsync(new RelationshipEvent
                {
                    CharacterRelationshipID = relationshipId,
                    SceneID = appearance.SceneID,
                    RelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, appearance.Signal ?? candidate.RelationshipType),
                    Intensity = ConfidenceIntensity(appearance.Confidence ?? candidate.Confidence),
                    IsKnownToOtherCharacter = true,
                    Description = BuildRelationshipEventDescription(candidate, appearance)
                });
                eventsCreated++;
            }
        }

        batch.RelationshipStageComplete = true;
        batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult
        {
            RelationshipsCreated = created,
            RelationshipsLinked = linkedExisting,
            RelationshipsMerged = merged,
            RelationshipsIgnored = ignored,
            RelationshipEventsCreated = eventsCreated
        };
        await pipelineState.RecordRelationshipImportAsync(batch.ProjectID, batch.BookID);

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

        return new StoryIntelligenceImportCommitResult
        {
            Success = true,
            ScenesCreated = eventsCreated,
            Message = $"Relationships imported. {created:N0} created, {linkedExisting:N0} linked, {merged:N0} merged, {ignored:N0} ignored, {eventsCreated:N0} relationship event(s) added."
        };
    }

    private async Task<RelationshipCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
    {
        var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID);
        var existingRelationships = await characters.ListRelationshipsByProjectAsync(batch.ProjectID);
        var existingByPair = existingRelationships
            .GroupBy(relationship => PairKey(relationship.CharacterAID, relationship.CharacterBID))
            .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
        var groups = new Dictionary<string, RelationshipCandidate>(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 existingEvents = await characters.ListRelationshipEventsBySceneAsync(importedScene.SceneID);
                alreadyLinked += existingEvents.Select(item => item.CharacterRelationshipID).Distinct().Count();
                AddSceneRelationships(groups, characterIndex, existingByPair, importedScene, parsed, existingEvents);
            }
        }

        var candidates = groups.Values
            .Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
            .OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count())
            .ThenBy(candidate => candidate.CharacterAName)
            .ThenBy(candidate => candidate.CharacterBName)
            .ToList();

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

    private static void AddSceneRelationships(
        Dictionary<string, RelationshipCandidate> groups,
        CharacterIndex characterIndex,
        IReadOnlyDictionary<string, CharacterRelationship> existingByPair,
        Scene importedScene,
        SceneIntelligenceScene parsed,
        IReadOnlyList<RelationshipEvent> existingEvents)
    {
        foreach (var relationship in parsed.Relationships ?? [])
        {
            AddRelationship(groups, characterIndex, existingByPair, importedScene, parsed, relationship.CharacterA, relationship.CharacterB, relationship.RelationshipSignal, relationship.Evidence, relationship.Confidence, existingEvents);
        }

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

            AddRelationship(
                groups,
                characterIndex,
                existingByPair,
                importedScene,
                parsed,
                observation.SubjectName,
                observation.ObjectName,
                FirstConfigured(observation.Predicate, observation.ObservationType),
                FirstConfigured(observation.Description, observation.Evidence),
                observation.Confidence,
                existingEvents);
        }
    }

    private static void AddRelationship(
        Dictionary<string, RelationshipCandidate> groups,
        CharacterIndex characterIndex,
        IReadOnlyDictionary<string, CharacterRelationship> existingByPair,
        Scene importedScene,
        SceneIntelligenceScene parsed,
        string? characterAName,
        string? characterBName,
        string? relationshipSignal,
        string? evidence,
        decimal? confidence,
        IReadOnlyList<RelationshipEvent> existingEvents)
    {
        var characterA = characterIndex.Find(characterAName);
        var characterB = characterIndex.Find(characterBName);
        if (characterA is null || characterB is null || characterA.CharacterID == characterB.CharacterID)
        {
            return;
        }

        var key = PairKey(characterA.CharacterID, characterB.CharacterID);
        if (!groups.TryGetValue(key, out var candidate))
        {
            existingByPair.TryGetValue(key, out var existingMatch);
            candidate = new RelationshipCandidate(
                key,
                characterA.CharacterID,
                characterA.CharacterName,
                characterB.CharacterID,
                characterB.CharacterName,
                RelationshipTypeFromSignal(relationshipSignal, evidence),
                existingMatch?.CharacterRelationshipID,
                existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}");
            groups[key] = candidate;
        }

        if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m))
        {
            candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal, evidence);
        }

        candidate.Confidence = Max(candidate.Confidence, confidence);
        candidate.FirstAppearanceNote ??= BuildSceneReference(importedScene);
        candidate.LastAppearanceNote = BuildSceneReference(importedScene);
        candidate.ExampleScene ??= BuildFirstAppearance(importedScene, parsed);
        candidate.ExampleContext ??= Clean(evidence);
        candidate.Appearances.Add(new RelationshipAppearanceImport(
            importedScene.SceneID,
            importedScene.SceneNumber,
            importedScene.SceneTitle,
            relationshipSignal,
            evidence,
            confidence,
            candidate.ExistingRelationshipID.HasValue && existingEvents.Any(existing => existing.CharacterRelationshipID == candidate.ExistingRelationshipID.Value)));
    }

    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 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 IsRelationshipObservation(SceneIntelligenceObservation observation)
    {
        var type = Clean(observation.ObservationType);
        if (!type.Contains("relationship", StringComparison.OrdinalIgnoreCase)
            && !type.Contains("character", StringComparison.OrdinalIgnoreCase)
            && !RelationshipSignalWords.Any(word => type.Contains(word, StringComparison.OrdinalIgnoreCase)))
        {
            return false;
        }

        return IsCharacterEntity(observation.SubjectEntityType)
            && IsCharacterEntity(observation.ObjectEntityType)
            && !string.IsNullOrWhiteSpace(observation.SubjectName)
            && !string.IsNullOrWhiteSpace(observation.ObjectName);
    }

    private static int ResolveRelationshipTypeID(IReadOnlyList<RelationshipType> types, int? selectedRelationshipTypeId, string? suggestedType)
        => selectedRelationshipTypeId.HasValue && types.Any(type => type.RelationshipTypeID == selectedRelationshipTypeId.Value)
            ? selectedRelationshipTypeId.Value
            : types.FirstOrDefault(type => string.Equals(type.TypeName, suggestedType, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
                ?? types.FirstOrDefault(type => string.Equals(type.TypeName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
                ?? types.First().RelationshipTypeID;

    private static int MatchRelationshipState(IReadOnlyList<RelationshipState> states, string? signal)
    {
        var value = Normalise(signal);
        var stateName =
            value.Contains("reconcil") ? "Reconciled" :
            value.Contains("romantic") || value.Contains("lover") || value.Contains("love") ? "Romantic" :
            value.Contains("friend") || value.Contains("ally") || value.Contains("support") ? "Friendly" :
            value.Contains("argument") || value.Contains("argue") || value.Contains("conflict") || value.Contains("hostile") || value.Contains("enemy") ? "Hostile" :
            value.Contains("distrust") || value.Contains("suspect") || value.Contains("suspicious") ? "Distrustful" :
            value.Contains("estranged") || value.Contains("separat") || value.Contains("deteriorat") ? "Estranged" :
            value.Contains("tense") || value.Contains("tension") ? "Tense" :
            "Unknown";
        return states.FirstOrDefault(state => string.Equals(state.StateName, stateName, StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
            ?? states.FirstOrDefault(state => string.Equals(state.StateName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
            ?? states.First().RelationshipStateID;
    }

    private static string RelationshipTypeFromSignal(string? signal, string? evidence = null)
    {
        var value = NormaliseForMatching($"{signal} {evidence}");
        if (ContainsAny(value, "mother", "father", "sister", "brother", "parent", "daughter", "son", " child ", "aunt", "uncle", "cousin", "family", "sibling", "parent and child"))
        {
            return "Family";
        }

        if (ContainsAny(value, "romance", "romantic", "lover", "spouse", "husband", "wife", "girlfriend", "boyfriend", "love interest", "former partner", "partner like", "partner-like"))
        {
            return "Romantic";
        }

        if (ContainsAny(value, "teacher", "student", "instructor", "learner", "mentor", "pupil", "tutor", "driving instructor"))
        {
            return "Educational";
        }

        if (ContainsAny(value, "police", "detective", "suspect", "witness", "authority", "guard", "prisoner", "investigator", "person of interest"))
        {
            return "Authority";
        }

        if (ContainsAny(value, "criminal", "accomplice", "offender", "victim", "gang", "conspirator"))
        {
            return "Criminal";
        }

        if (ContainsAny(value, "doctor", "patient", "nurse", "therapist", "medical"))
        {
            return "Medical";
        }

        if (ContainsAny(value, "neighbour", "neighbor"))
        {
            return "Neighbour";
        }

        if (ContainsAny(value, "colleague", "coworker", "co worker", "boss", "employee", "manager", "client", "professional", "work relationship"))
        {
            return "Professional";
        }

        if (ContainsAny(value, "friend", "friendship", "best friend", "close friend", "childhood friend", "trust between friends", "affection between friends"))
        {
            return "Friend";
        }

        if (ContainsAny(value, "rival", "enemy", "adversarial", "conflict", "mistrust", "antagonist", "competitor"))
        {
            return "Rival";
        }

        if (ContainsAny(value, "ally", "protector", "supporter", "confidant", "dependence", "cooperation"))
        {
            return "Ally";
        }

        return "Unknown";
    }

    private static bool HasMeaningfulRelationshipEvent(RelationshipAppearanceImport appearance)
    {
        var value = $"{appearance.Signal} {appearance.Evidence}";
        return RelationshipEventWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase));
    }

    private static string BuildRelationshipNotes(RelationshipCandidate candidate)
    {
        var lines = new List<string> { "Imported from Story Intelligence relationship review." };
        if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
        {
            lines.Add(candidate.ExampleContext);
        }

        if (!string.IsNullOrWhiteSpace(candidate.FirstAppearanceNote))
        {
            lines.Add($"First detected: {candidate.FirstAppearanceNote}");
        }

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

    private static string BuildRelationshipEventDescription(RelationshipCandidate candidate, RelationshipAppearanceImport appearance)
        => FirstConfigured(
            Clean(appearance.Evidence),
            Clean(appearance.Signal),
            $"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}") ?? RelationshipLabel(candidate);

    private static string RelationshipLabel(RelationshipCandidate candidate)
        => $"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}";

    private static IReadOnlyList<SelectListItem> ToRelationshipTypeOptions(IEnumerable<RelationshipType> relationshipTypes)
        => relationshipTypes
            .OrderBy(type => type.CategorySortOrder)
            .ThenBy(type => type.SortOrder)
            .ThenBy(type => type.TypeName)
            .Select(type => new SelectListItem(type.TypeName, type.RelationshipTypeID.ToString()))
            .ToList();

    private static void SaveRelationshipSelections(OnboardingStoryIntelligenceBatch batch, IEnumerable<StoryIntelligenceRelationshipImportChoiceForm> choices)
    {
        foreach (var choice in choices)
        {
            if (string.IsNullOrWhiteSpace(choice.Key) || choice.RelationshipTypeID is not { } relationshipTypeId)
            {
                continue;
            }

            batch.RelationshipSelections.RemoveAll(selection => string.Equals(selection.Key, choice.Key, StringComparison.OrdinalIgnoreCase));
            batch.RelationshipSelections.Add(new OnboardingStoryIntelligenceRelationshipSelection
            {
                Key = choice.Key,
                RelationshipTypeID = relationshipTypeId
            });
        }
    }

    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 void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, int? relationshipId, string? label, bool createdOrLinked)
    {
        batch.RelationshipDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase));
        batch.RelationshipDecisions.Add(new OnboardingStoryIntelligenceRelationshipDecision
        {
            Key = key,
            Action = action,
            CharacterRelationshipID = relationshipId,
            RelationshipLabel = label,
            CreatedOrLinked = createdOrLinked
        });
    }

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

    private static int? ConfidenceIntensity(decimal? confidence)
        => confidence.HasValue ? Math.Clamp((int)Math.Round(confidence.Value * 10m, MidpointRounding.AwayFromZero), 1, 10) : null;

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

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

    private static string PairKey(int characterAId, int characterBId)
        => characterAId <= characterBId ? $"{characterAId}:{characterBId}" : $"{characterBId}:{characterAId}";

    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 static readonly string[] RelationshipSignalWords =
    [
        "friend", "family", "romantic", "lover", "spouse", "parent", "child", "sibling", "enemy",
        "rival", "ally", "argument", "conflict", "reconciled", "separated", "deteriorates", "meeting"
    ];

    private static readonly string[] RelationshipEventWords =
    [
        "begins", "begin", "starts", "first meeting", "meet", "met", "deteriorates", "deteriorate",
        "reconciles", "reconciliation", "reconciled", "argument", "argues", "separation", "separate",
        "estranged", "confront", "conflict", "tension", "hostile", "distrust", "supports", "protects"
    ];

    private sealed record RelationshipCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList<RelationshipCandidate> Candidates);

    private sealed class RelationshipCandidate(
        string key,
        int characterAId,
        string characterAName,
        int characterBId,
        string characterBName,
        string relationshipType,
        int? existingRelationshipId,
        string? existingRelationshipLabel)
    {
        public string Key { get; } = key;
        public int CharacterAID { get; } = characterAId;
        public string CharacterAName { get; } = characterAName;
        public int CharacterBID { get; } = characterBId;
        public string CharacterBName { get; } = characterBName;
        public string RelationshipType { get; set; } = relationshipType;
        public int? ExistingRelationshipID { get; } = existingRelationshipId;
        public string? ExistingRelationshipLabel { get; } = existingRelationshipLabel;
        public decimal? Confidence { get; set; }
        public string? FirstAppearanceNote { get; set; }
        public string? LastAppearanceNote { get; set; }
        public string? ExampleScene { get; set; }
        public string? ExampleContext { get; set; }
        public List<RelationshipAppearanceImport> Appearances { get; } = [];
    }

    private sealed record RelationshipAppearanceImport(
        int SceneID,
        decimal SceneNumber,
        string SceneTitle,
        string? Signal,
        string? Evidence,
        decimal? Confidence,
        bool AlreadyLinked);

    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 record CharacterMatch(int CharacterID, string CharacterName);
}
