using System.Text.RegularExpressions;
using PlotLine.Data;
using PlotLine.Models;

namespace PlotLine.Services;

public interface IWordCompanionService
{
    Task<WordCompanionProjectsResponse> ListProjectsAsync();
    Task<WordCompanionBooksResponse?> ListBooksAsync(int projectId);
    Task<WordCompanionCreateBookResponse?> CreateBookAsync(int projectId, WordCompanionCreateBookRequest request);
    Task<WordCompanionBookStructureResponse?> GetBookStructureAsync(int bookId);
    Task<WordCompanionChaptersResponse?> ListChaptersAsync(int bookId);
    Task<WordCompanionResolveSceneResponse?> ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request);
    Task<WordCompanionSceneCompanionResponse?> GetSceneCompanionAsync(int sceneId);
    Task<WordCompanionCreateChapterResponse?> CreateChapterAsync(int bookId, WordCompanionCreateChapterRequest request);
    Task<WordCompanionCreateSceneResponse?> CreateSceneAsync(int bookId, int chapterId, WordCompanionCreateSceneRequest request);
    Task<WordCompanionSyncSceneResponse?> SyncSceneAsync(int bookId, WordCompanionSyncSceneRequest request);
    Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, WordCompanionSyncManuscriptRequest request);
    Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request);
    Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request);
    Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(WordCompanionRuntimeSceneWordCountRequest request);
    Task<WordCompanionRuntimeCurrentSceneResponse?> ValidateRuntimeCurrentSceneAsync(WordCompanionRuntimeCurrentSceneRequest request);
    Task<WordCompanionRuntimeCharacterAliasResponse?> ListRuntimeCharacterAliasesAsync(int projectId, Guid documentGuid);
    Task<WordCompanionRuntimeCharacterSuggestionsResponse?> CreateRuntimeCharacterSuggestionsAsync(WordCompanionRuntimeCharacterSuggestionsRequest request);
    Task<WordCompanionRuntimeAssetAliasResponse?> ListRuntimeAssetAliasesAsync(int projectId, Guid documentGuid);
    Task<WordCompanionRuntimeAssetSuggestionsResponse?> CreateRuntimeAssetSuggestionsAsync(WordCompanionRuntimeAssetSuggestionsRequest request);
    Task<WordCompanionRuntimeLocationAliasResponse?> ListRuntimeLocationAliasesAsync(int projectId, Guid documentGuid);
    Task<WordCompanionRuntimeLocationSuggestionsResponse?> CreateRuntimeLocationSuggestionsAsync(WordCompanionRuntimeLocationSuggestionsRequest request);
    Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid);
    Task<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId);
    Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request);
    Task<WordCompanionManuscriptUnlinkResponse?> UnlinkManuscriptAsync(WordCompanionManuscriptUnlinkRequest request);
    Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request);
    Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(WordCompanionManuscriptImportRequest request);
    Task<WordCompanionDiscoverCharactersResponse?> DiscoverCharacterCandidatesAsync(WordCompanionDiscoverCharactersRequest request);
    Task<WordCompanionCreateDiscoveredCharactersResponse?> CreateDiscoveredCharactersAsync(WordCompanionCreateDiscoveredCharactersRequest request);
}

public sealed class WordCompanionService(
    IWordCompanionRepository repository,
    IManuscriptDocumentRepository manuscriptDocuments,
    ICurrentUserService currentUser) : IWordCompanionService
{
    private const int CharacterDiscoveryMinimumMentions = 2;
    private const int CharacterDiscoverySuggestionLimit = 50;
    private const int CharacterDiscoveryExcludedLimit = 30;
    private static readonly Regex CharacterCandidateRegex = new(
        @"(?<!['’\p{L}])(?:(?:Mr|Mrs|Ms|Miss|Dr|Rev|Fr|Sir|Lady|Lord|DS|DI|DC|PC|Professor|Aunt|Uncle)\.?\s+)?[A-Z][a-z]+(?:[-'][A-Z][a-z]+)?(?:\s+[A-Z][a-z]+(?:[-'][A-Z][a-z]+)?){0,2}(?=(?:['’]s)?(?!\p{L})|(?!(?:['’]))[^\p{L}]|$)",
        RegexOptions.Compiled);
    private static readonly HashSet<string> CharacterDiscoveryStopWords = new(StringComparer.OrdinalIgnoreCase)
    {
        "A", "An", "As", "At", "By", "He", "Her", "Hers", "Him", "His", "I", "If", "In", "It", "Its", "Me",
        "Mine", "My", "Of", "On", "Our", "Ours", "She", "The", "Their", "Theirs", "They", "Them", "To", "Us",
        "We", "You", "Your", "Yours",
        "What", "When", "Where", "Why", "Who", "How", "Which", "Whose", "Whom",
        "Yes", "No", "Yeah", "Yep", "Nope", "Okay", "OK", "Ok", "Well", "Right",
        "There", "Here", "This", "That", "These", "Those", "Then", "Now",
        "Not", "Just", "Only", "Very", "Really", "Maybe", "Perhaps", "Probably",
        "Are", "Is", "Was", "Were", "Be", "Been", "Being", "Am",
        "Can", "Could", "Should", "Would", "Will", "Won", "Shall", "May", "Might", "Must",
        "For", "From", "With", "Without", "Into", "Onto", "Over", "Under", "Between", "Before", "After",
        "During", "Through", "Around", "About",
        "And", "But", "Or", "So", "Because", "Though", "Although",
        "One", "Two", "Three", "First", "Second", "Third",
        "Come", "Go", "Get", "Got", "Let", "Look", "See", "Tell", "Ask", "Asked", "Said", "Say", "Think",
        "Thought", "Know", "Knew", "Want", "Wanted", "Need", "Needed",
        "Please", "Thank", "Thanks", "Sorry", "Hello", "Hi", "Hey", "Goodbye", "Bye",
        "Chapter", "Scene", "Book", "Part", "Page", "Prologue", "Epilogue", "Volume", "Act",
        "Christmas", "Easter", "Morning", "Afternoon", "Evening", "Night", "Today", "Tomorrow", "Yesterday",
        "Tonight", "Tomorrow", "Yesterday", "God", "Something", "Anything", "Nothing", "Everything", "Everyone",
        "Somebody", "Someone", "Anybody", "Nobody", "People", "Person", "Man", "Woman", "Boy", "Girl",
        "Did", "Do", "Does", "Done", "Had", "Has", "Have", "Having", "Even", "All", "Fine", "Great", "Sure",
        "Course", "Thing", "Things", "Still", "Err", "Er", "Erm", "Alright", "Allright", "Alrite", "New",
        "Somewhere", "Coke", "Like", "Don", "Isn"
    };
    private static readonly HashSet<string> CharacterDiscoveryRelationshipTitles = new(StringComparer.OrdinalIgnoreCase)
    {
        "Mum", "Dad", "Mother", "Father", "Gran", "Grandma", "Grandad", "Nan", "Nana", "Granny", "Brother",
        "Sister", "Aunt", "Auntie", "Uncle", "Cousin"
    };
    private static readonly HashSet<string> CharacterDiscoveryTemporalWords = new(StringComparer.OrdinalIgnoreCase)
    {
        "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday",
        "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December",
        "Spring", "Summer", "Autumn", "Fall", "Winter"
    };
    private static readonly HashSet<string> CharacterDiscoveryNameLikeWords = new(StringComparer.OrdinalIgnoreCase)
    {
        "Grace", "Jack", "Rose", "May", "Will", "Mark", "Hope", "Faith", "Pat", "Rob", "Bill", "Harry",
        "November", "Autumn", "Rain"
    };
    private static readonly HashSet<string> CharacterDiscoveryHonorifics = new(StringComparer.OrdinalIgnoreCase)
    {
        "Mr", "Mrs", "Ms", "Miss", "Dr", "Rev", "Fr", "Sir", "Lady", "Lord", "DS", "DI", "DC", "PC", "Professor", "Aunt", "Uncle"
    };
    private static readonly HashSet<string> CharacterDiscoveryDialogueTags = new(StringComparer.OrdinalIgnoreCase)
    {
        "said", "asked", "replied", "answered", "whispered", "shouted", "called", "muttered", "cried", "snapped",
        "sighed", "laughed", "continued", "began", "told", "yelled"
    };
    private static readonly HashSet<string> CharacterDiscoveryHumanActionVerbs = new(StringComparer.OrdinalIgnoreCase)
    {
        "walked", "turned", "smiled", "looked", "stared", "nodded", "sat", "stood", "stepped", "ran", "came",
        "went", "opened", "closed", "held", "took", "put", "gave", "grabbed", "shrugged", "frowned", "laughed",
        "sighed", "watched", "waited", "leaned", "knelt", "waved", "pointed", "followed", "paused", "breathed"
    };
    private static readonly HashSet<string> CharacterDiscoveryLocationIndicators = new(StringComparer.OrdinalIgnoreCase)
    {
        "Road", "Lane", "Street", "Close", "Avenue", "Drive", "Way", "Hill", "Hospital", "Inn", "Church", "School",
        "House", "Farm", "Hall", "Court", "Station", "Hotel", "Shop", "Cafe", "Café", "Pub", "Park", "Gardens"
    };
    public async Task<WordCompanionProjectsResponse> ListProjectsAsync()
    {
        var userId = RequireUserId();
        return new WordCompanionProjectsResponse { Projects = await repository.ListProjectsAsync(userId) };
    }

    public async Task<WordCompanionBooksResponse?> ListBooksAsync(int projectId)
    {
        if (projectId <= 0)
        {
            return null;
        }

        var books = await repository.ListBooksAsync(projectId, RequireUserId());
        return new WordCompanionBooksResponse { Books = books };
    }

    public Task<WordCompanionCreateBookResponse?> CreateBookAsync(int projectId, WordCompanionCreateBookRequest request)
    {
        if (projectId <= 0 || string.IsNullOrWhiteSpace(request.Title))
        {
            return Task.FromResult<WordCompanionCreateBookResponse?>(null);
        }

        request.Title = request.Title.Trim();
        return repository.CreateBookAsync(projectId, RequireUserId(), request);
    }

    public Task<WordCompanionBookStructureResponse?> GetBookStructureAsync(int bookId)
    {
        return bookId <= 0 ? Task.FromResult<WordCompanionBookStructureResponse?>(null) : repository.GetBookStructureAsync(bookId, RequireUserId());
    }

    public async Task<WordCompanionChaptersResponse?> ListChaptersAsync(int bookId)
    {
        if (bookId <= 0)
        {
            return null;
        }

        var chapters = await repository.ListChaptersAsync(bookId, RequireUserId());
        return new WordCompanionChaptersResponse { Chapters = chapters };
    }

    public Task<WordCompanionResolveSceneResponse?> ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request)
    {
        if (bookId <= 0 || string.IsNullOrWhiteSpace(request.ChapterTitle) || string.IsNullOrWhiteSpace(request.SceneTitle))
        {
            return Task.FromResult<WordCompanionResolveSceneResponse?>(null);
        }

        return ResolveSceneCoreAsync(bookId, request);
    }

    public Task<WordCompanionSceneCompanionResponse?> GetSceneCompanionAsync(int sceneId)
    {
        return sceneId <= 0 ? Task.FromResult<WordCompanionSceneCompanionResponse?>(null) : repository.GetSceneCompanionAsync(sceneId, RequireUserId());
    }

    public Task<WordCompanionCreateChapterResponse?> CreateChapterAsync(int bookId, WordCompanionCreateChapterRequest request)
    {
        if (bookId <= 0 || string.IsNullOrWhiteSpace(request.Title) || request.SortOrder is <= 0)
        {
            return Task.FromResult<WordCompanionCreateChapterResponse?>(null);
        }

        request.Title = request.Title.Trim();
        return repository.CreateChapterAsync(bookId, RequireUserId(), request);
    }

    public Task<WordCompanionCreateSceneResponse?> CreateSceneAsync(int bookId, int chapterId, WordCompanionCreateSceneRequest request)
    {
        if (bookId <= 0 || chapterId <= 0 || string.IsNullOrWhiteSpace(request.SceneTitle))
        {
            return Task.FromResult<WordCompanionCreateSceneResponse?>(null);
        }

        request.SceneTitle = request.SceneTitle.Trim();
        return repository.CreateSceneAsync(bookId, chapterId, RequireUserId(), request);
    }

    public Task<WordCompanionSyncSceneResponse?> SyncSceneAsync(int bookId, WordCompanionSyncSceneRequest request)
    {
        if (bookId <= 0
            || string.IsNullOrWhiteSpace(request.ChapterTitle)
            || string.IsNullOrWhiteSpace(request.SceneTitle)
            || request.ActualWords < 0
            || request.ChapterSortOrder is <= 0
            || request.SceneSortOrder is <= 0)
        {
            return Task.FromResult<WordCompanionSyncSceneResponse?>(null);
        }

        request.ChapterTitle = request.ChapterTitle.Trim();
        request.SceneTitle = request.SceneTitle.Trim();
        return repository.SyncSceneAsync(bookId, RequireUserId(), request);
    }

    public Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, WordCompanionSyncManuscriptRequest request)
    {
        if (bookId <= 0 || request.Chapters.Any(chapter =>
                string.IsNullOrWhiteSpace(chapter.Title)
                || chapter.WordCount < 0
                || chapter.SortOrder is <= 0
                || chapter.Scenes.Any(scene => string.IsNullOrWhiteSpace(scene.Title) || scene.WordCount < 0 || scene.SortOrder is <= 0)))
        {
            return Task.FromResult<WordCompanionSyncManuscriptResponse?>(null);
        }

        foreach (var chapter in request.Chapters)
        {
            chapter.Title = chapter.Title?.Trim();
            foreach (var scene in chapter.Scenes)
            {
                scene.Title = scene.Title?.Trim();
            }
        }

        return repository.SyncManuscriptAsync(bookId, RequireUserId(), request);
    }

    public Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request)
    {
        if (sceneId <= 0
            || request.PrimaryLocationId is <= 0
            || HasInvalidIds(request.CharacterIds)
            || HasInvalidIds(request.AssetIds)
            || HasInvalidIds(request.LocationIds))
        {
            return Task.FromResult<WordCompanionUpdateSceneLinksResponse?>(null);
        }

        return repository.UpdateSceneLinksAsync(sceneId, RequireUserId(), request);
    }

    public Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request)
    {
        var actualWords = request.ActualWordCount ?? request.ActualWords;
        return sceneId <= 0 || actualWords is null or < 0
            ? Task.FromResult<WordCompanionUpdateWordCountResponse?>(null)
            : repository.UpdateWordCountAsync(sceneId, RequireUserId(), actualWords.Value, request.LastWorkedOn);
    }

    public Task<WordCompanionUpdateWordCountResponse?> UpdateRuntimeSceneWordCountAsync(WordCompanionRuntimeSceneWordCountRequest request)
    {
        return request.DocumentGuid == Guid.Empty
            || request.BookId <= 0
            || request.ChapterId <= 0
            || request.SceneId <= 0
            || request.WordCount < 0
            ? Task.FromResult<WordCompanionUpdateWordCountResponse?>(null)
            : repository.UpdateRuntimeSceneWordCountAsync(RequireUserId(), request);
    }

    public Task<WordCompanionRuntimeCurrentSceneResponse?> ValidateRuntimeCurrentSceneAsync(WordCompanionRuntimeCurrentSceneRequest request)
    {
        return request.DocumentGuid == Guid.Empty
            || request.ProjectId <= 0
            || request.BookId <= 0
            || request.ChapterId <= 0
            || request.SceneId <= 0
            ? Task.FromResult<WordCompanionRuntimeCurrentSceneResponse?>(null)
            : repository.ValidateRuntimeCurrentSceneAsync(RequireUserId(), request);
    }

    public async Task<WordCompanionRuntimeCharacterAliasResponse?> ListRuntimeCharacterAliasesAsync(int projectId, Guid documentGuid)
    {
        if (projectId <= 0 || documentGuid == Guid.Empty)
        {
            return null;
        }

        var aliases = await repository.ListRuntimeCharacterAliasesAsync(RequireUserId(), projectId, documentGuid);
        return new WordCompanionRuntimeCharacterAliasResponse
        {
            ProjectId = projectId,
            Characters = aliases
        };
    }

    public Task<WordCompanionRuntimeCharacterSuggestionsResponse?> CreateRuntimeCharacterSuggestionsAsync(WordCompanionRuntimeCharacterSuggestionsRequest request)
    {
        request.CharacterIds = request.CharacterIds
            .Where(id => id > 0)
            .Distinct()
            .Take(CharacterDiscoverySuggestionLimit)
            .ToList();

        return request.DocumentGuid == Guid.Empty
            || request.SceneId <= 0
            || request.CharacterIds.Count == 0
            ? Task.FromResult<WordCompanionRuntimeCharacterSuggestionsResponse?>(null)
            : repository.CreateRuntimeCharacterSuggestionsAsync(RequireUserId(), request);
    }

    public async Task<WordCompanionRuntimeAssetAliasResponse?> ListRuntimeAssetAliasesAsync(int projectId, Guid documentGuid)
    {
        if (projectId <= 0 || documentGuid == Guid.Empty)
        {
            return null;
        }

        var aliases = await repository.ListRuntimeAssetAliasesAsync(RequireUserId(), projectId, documentGuid);
        return new WordCompanionRuntimeAssetAliasResponse
        {
            ProjectId = projectId,
            Assets = aliases
        };
    }

    public Task<WordCompanionRuntimeAssetSuggestionsResponse?> CreateRuntimeAssetSuggestionsAsync(WordCompanionRuntimeAssetSuggestionsRequest request)
    {
        request.AssetIds = request.AssetIds
            .Where(id => id > 0)
            .Distinct()
            .Take(CharacterDiscoverySuggestionLimit)
            .ToList();

        return request.DocumentGuid == Guid.Empty
            || request.SceneId <= 0
            || request.AssetIds.Count == 0
            ? Task.FromResult<WordCompanionRuntimeAssetSuggestionsResponse?>(null)
            : repository.CreateRuntimeAssetSuggestionsAsync(RequireUserId(), request);
    }

    public async Task<WordCompanionRuntimeLocationAliasResponse?> ListRuntimeLocationAliasesAsync(int projectId, Guid documentGuid)
    {
        if (projectId <= 0 || documentGuid == Guid.Empty)
        {
            return null;
        }

        var aliases = await repository.ListRuntimeLocationAliasesAsync(RequireUserId(), projectId, documentGuid);
        return new WordCompanionRuntimeLocationAliasResponse
        {
            ProjectId = projectId,
            Locations = aliases
        };
    }

    public Task<WordCompanionRuntimeLocationSuggestionsResponse?> CreateRuntimeLocationSuggestionsAsync(WordCompanionRuntimeLocationSuggestionsRequest request)
    {
        request.LocationIds = request.LocationIds
            .Where(id => id > 0)
            .Distinct()
            .Take(CharacterDiscoverySuggestionLimit)
            .ToList();

        return request.DocumentGuid == Guid.Empty
            || request.SceneId <= 0
            || request.LocationIds.Count == 0
            ? Task.FromResult<WordCompanionRuntimeLocationSuggestionsResponse?>(null)
            : repository.CreateRuntimeLocationSuggestionsAsync(RequireUserId(), request);
    }

    public async Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid)
    {
        if (bookId <= 0 || documentGuid == Guid.Empty)
        {
            return null;
        }

        var userId = RequireUserId();
        var document = await manuscriptDocuments.GetByGuidAsync(documentGuid, userId);
        if (document is null || document.BookID != bookId || !document.IsActive)
        {
            return null;
        }

        await manuscriptDocuments.UpdateLastOpenedAsync(document.ManuscriptDocumentID, userId);
        document = await manuscriptDocuments.GetByGuidAsync(documentGuid, userId) ?? document;

        var projects = await repository.ListProjectsAsync(userId);
        var project = projects.FirstOrDefault(item => item.ProjectId == document.ProjectID);
        if (project is null)
        {
            return null;
        }

        var books = await repository.ListBooksAsync(document.ProjectID, userId);
        var book = books.FirstOrDefault(item => item.BookId == document.BookID);
        if (book is null)
        {
            return null;
        }

        return new WordCompanionRuntimeBookResponse
        {
            ProjectId = project.ProjectId,
            ProjectTitle = project.Title,
            BookId = book.BookId,
            BookTitle = book.Title,
            BookSubtitle = book.Subtitle,
            UsesExplicitScenes = book.UsesExplicitScenes,
            LastSyncUtc = document.LastSyncUtc,
            ManuscriptDocument = ToDto(document)
        };
    }

    public async Task<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId)
    {
        if (bookId <= 0)
        {
            return null;
        }

        var document = await manuscriptDocuments.GetByBookAsync(bookId, RequireUserId());
        return new WordCompanionManuscriptBookResponse
        {
            BookId = bookId,
            ManuscriptDocument = document is null ? null : ToDto(document)
        };
    }

    public async Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request)
    {
        if (request.BookId <= 0)
        {
            return null;
        }

        var documentGuid = request.DocumentGuid.GetValueOrDefault();
        if (documentGuid == Guid.Empty)
        {
            documentGuid = Guid.NewGuid();
        }

        ManuscriptDocumentModel? document;
        try
        {
            document = await manuscriptDocuments.SaveAsync(request.BookId, documentGuid, RequireUserId());
        }
        catch (InvalidOperationException)
        {
            return null;
        }

        return document is null
            ? null
            : new WordCompanionManuscriptLinkResponse
            {
                BookId = document.BookID,
                ManuscriptDocument = ToDto(document),
                Message = "Manuscript linked."
            };
    }

    public async Task<WordCompanionManuscriptUnlinkResponse?> UnlinkManuscriptAsync(WordCompanionManuscriptUnlinkRequest request)
    {
        if (request.BookId <= 0)
        {
            return null;
        }

        var result = await manuscriptDocuments.UnlinkBookAsync(request.BookId, request.DocumentGuid, RequireUserId());
        return result is null
            ? null
            : new WordCompanionManuscriptUnlinkResponse
            {
                BookId = result.BookID,
                ManuscriptDocumentId = result.ManuscriptDocumentID,
                ChaptersUnlinked = result.ChaptersUnlinked,
                ScenesUnlinked = result.ScenesUnlinked,
                Message = result.Message
            };
    }

    public Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request)
    {
        if (request.ProjectId <= 0
            || request.BookId <= 0
            || request.ChapterCount < 0
            || request.SceneCount < 0
            || request.WordCount < 0)
        {
            return Task.FromResult<WordCompanionManuscriptAnalyseResponse?>(null);
        }

        return repository.AnalyseManuscriptAsync(RequireUserId(), request);
    }

    public Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(WordCompanionManuscriptImportRequest request)
    {
        if (request.ProjectId <= 0
            || request.BookId <= 0
            || request.BindingVersion <= 0
            || request.Chapters.Count == 0
            || request.Chapters.Any(chapter =>
                string.IsNullOrWhiteSpace(chapter.Title)
                || chapter.SortOrder is <= 0
                || chapter.WordCount < 0
                || chapter.Scenes.Count == 0
                || chapter.Scenes.Any(scene => string.IsNullOrWhiteSpace(scene.Title) || scene.SortOrder is <= 0 || scene.WordCount < 0)))
        {
            return Task.FromResult<WordCompanionManuscriptImportResponse?>(null);
        }

        request.DocumentGuid = request.DocumentGuid.GetValueOrDefault() == Guid.Empty ? Guid.NewGuid() : request.DocumentGuid;

        foreach (var chapter in request.Chapters)
        {
            chapter.Title = chapter.Title?.Trim();
            foreach (var scene in chapter.Scenes)
            {
                scene.Title = scene.Title?.Trim();
            }
        }

        return repository.ImportManuscriptAsync(RequireUserId(), request);
    }

    public async Task<WordCompanionDiscoverCharactersResponse?> DiscoverCharacterCandidatesAsync(WordCompanionDiscoverCharactersRequest request)
    {
        if (request.ProjectId <= 0)
        {
            return null;
        }

        var userId = RequireUserId();
        if (!await repository.HasProjectAccessAsync(request.ProjectId, userId))
        {
            return null;
        }

        var candidates = DiscoverCharacterCandidates(request.DocumentText ?? string.Empty, request.IncludeExcluded);
        return new WordCompanionDiscoverCharactersResponse
        {
            ProjectId = request.ProjectId,
            Candidates = candidates
        };
    }

    public Task<WordCompanionCreateDiscoveredCharactersResponse?> CreateDiscoveredCharactersAsync(WordCompanionCreateDiscoveredCharactersRequest request)
    {
        if (request.ProjectId <= 0 || request.CandidateNames.Count == 0)
        {
            return Task.FromResult<WordCompanionCreateDiscoveredCharactersResponse?>(null);
        }

        request.CandidateNames = request.CandidateNames
            .Select(NormalizeCandidateName)
            .Where(name => !string.IsNullOrWhiteSpace(name) && name.Length <= 200)
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .Take(CharacterDiscoverySuggestionLimit)
            .ToList();

        return request.CandidateNames.Count == 0
            ? Task.FromResult<WordCompanionCreateDiscoveredCharactersResponse?>(null)
            : repository.CreateDiscoveredCharactersAsync(RequireUserId(), request);
    }

    private async Task<WordCompanionResolveSceneResponse?> ResolveSceneCoreAsync(int bookId, WordCompanionResolveSceneRequest request)
    {
        return await repository.ResolveSceneAsync(bookId, RequireUserId(), request.ChapterTitle!.Trim(), request.SceneTitle!.Trim());
    }

    private int RequireUserId()
    {
        return currentUser.UserId ?? throw new InvalidOperationException("An authenticated user is required.");
    }

    private static bool HasInvalidIds(IEnumerable<int> ids) => ids.Any(id => id <= 0);

    private static IReadOnlyList<WordCompanionCharacterCandidateDto> DiscoverCharacterCandidates(string manuscriptText, bool includeExcluded = false)
    {
        if (string.IsNullOrWhiteSpace(manuscriptText))
        {
            return [];
        }

        manuscriptText = NormalizeApostrophes(manuscriptText);
        var candidates = new Dictionary<string, CharacterCandidateEvidence>(StringComparer.Ordinal);
        foreach (Match match in CharacterCandidateRegex.Matches(manuscriptText))
        {
            var candidate = NormalizeCandidateName(match.Value);
            if (string.IsNullOrWhiteSpace(candidate))
            {
                continue;
            }

            if (!candidates.TryGetValue(candidate, out var evidence))
            {
                evidence = new CharacterCandidateEvidence(candidate);
                candidates[candidate] = evidence;
            }

            evidence.MentionCount += 1;
            if (AppearsAtSentenceStart(manuscriptText, match.Index))
            {
                evidence.SentenceStartMentions += 1;
            }

            if (HasDialogueTagEvidence(manuscriptText, match.Index, match.Length))
            {
                evidence.DialogueEvidenceCount += 1;
            }

            if (HasPossessiveEvidence(manuscriptText, match.Index, match.Length))
            {
                evidence.PossessiveEvidenceCount += 1;
            }

            if (HasHumanActionEvidence(manuscriptText, match.Index, match.Length))
            {
                evidence.HumanActionEvidenceCount += 1;
            }
        }

        var builtCandidates = candidates.Values
            .Select(BuildCandidateDto)
            .Where(item => item is not null)
            .Select(item => item!)
            .ToList();

        var visible = builtCandidates
            .Where(item => !string.Equals(item.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
            .OrderByDescending(item => CategorySortValue(item.Category))
            .ThenByDescending(item => item.QualityScore)
            .ThenByDescending(item => ConfidenceSortValue(item.Confidence))
            .ThenByDescending(item => item.MentionCount)
            .ThenBy(item => item.Text, StringComparer.OrdinalIgnoreCase)
            .Take(CharacterDiscoverySuggestionLimit)
            .ToList();

        if (includeExcluded)
        {
            visible.AddRange(builtCandidates
                .Where(item => string.Equals(item.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
                .OrderByDescending(item => item.MentionCount)
                .ThenBy(item => item.Text, StringComparer.OrdinalIgnoreCase)
                .Take(CharacterDiscoveryExcludedLimit));
        }

        return visible;
    }

    private static WordCompanionCharacterCandidateDto? BuildCandidateDto(CharacterCandidateEvidence evidence)
    {
        if (string.IsNullOrWhiteSpace(evidence.Text) || evidence.Text.Length > 200)
        {
            return null;
        }

        var words = evidence.Text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        var first = words.Length > 0 ? TrimHonorificPunctuation(words[0]) : string.Empty;
        if (words.Length == 0)
        {
            return null;
        }

        var hasHonorific = words.Length > 1 && CharacterDiscoveryHonorifics.Contains(first);
        var hasMultiWordName = words.Length > 1;
        var hasNameLikeWord = words.Any(word => CharacterDiscoveryNameLikeWords.Contains(TrimHonorificPunctuation(word)));
        var hasTemporalWord = words.Any(word => CharacterDiscoveryTemporalWords.Contains(TrimHonorificPunctuation(word)));
        var hasLocationIndicator = words.Any(word => CharacterDiscoveryLocationIndicators.Contains(TrimHonorificPunctuation(word)));
        var hasStopWord = words.Any(word => CharacterDiscoveryStopWords.Contains(TrimHonorificPunctuation(word)));
        var isRelationshipTitle = words.Length == 1 && CharacterDiscoveryRelationshipTitles.Contains(first);
        var nonSentenceStartMentions = evidence.MentionCount - evidence.SentenceStartMentions;
        var mostlySentenceStart = evidence.MentionCount > 0 && evidence.SentenceStartMentions >= Math.Ceiling(evidence.MentionCount * 0.8m);

        var score = 0;
        if (evidence.MentionCount >= CharacterDiscoveryMinimumMentions)
        {
            score += 1;
        }
        if (evidence.MentionCount >= 10)
        {
            score += 1;
        }
        if (evidence.MentionCount >= 25)
        {
            score += 1;
        }
        if (nonSentenceStartMentions > 0)
        {
            score += 2;
        }
        if (evidence.DialogueEvidenceCount > 0)
        {
            score += 5;
        }
        if (hasMultiWordName)
        {
            score += 5;
        }
        if (hasHonorific)
        {
            score += 4;
        }
        if (evidence.PossessiveEvidenceCount > 0)
        {
            score += 2;
        }
        if (evidence.HumanActionEvidenceCount > 0)
        {
            score += 2;
        }
        if (hasNameLikeWord)
        {
            score += 3;
        }
        if (hasTemporalWord)
        {
            score -= 1;
        }
        if (hasLocationIndicator && evidence.DialogueEvidenceCount == 0 && !hasHonorific)
        {
            score -= 2;
        }
        if (mostlySentenceStart)
        {
            score -= 2;
        }
        if (!hasMultiWordName
            && evidence.DialogueEvidenceCount == 0
            && evidence.PossessiveEvidenceCount == 0
            && evidence.HumanActionEvidenceCount == 0
            && nonSentenceStartMentions == 0)
        {
            score -= 1;
        }
        if (evidence.MentionCount < CharacterDiscoveryMinimumMentions)
        {
            score -= 2;
        }
        if (first.Length <= 2 && !hasHonorific)
        {
            score -= 3;
        }

        var category = CandidateCategory(score, hasStopWord, isRelationshipTitle, hasTemporalWord, hasLocationIndicator, evidence);
        var confidence = score >= 4 ? "High" : score >= 2 ? "Medium" : "Low";
        if (hasLocationIndicator && evidence.DialogueEvidenceCount == 0 && !hasHonorific)
        {
            confidence = score >= 4 ? "Medium" : "Low";
        }
        if (string.Equals(category, "Excluded", StringComparison.Ordinal))
        {
            confidence = "Low";
        }

        return new WordCompanionCharacterCandidateDto
        {
            Text = evidence.Text,
            MentionCount = evidence.MentionCount,
            Confidence = confidence,
            QualityScore = score,
            Category = category,
            Reason = BuildCandidateReason(evidence, hasStopWord, isRelationshipTitle, hasHonorific, hasMultiWordName, hasTemporalWord, hasLocationIndicator, mostlySentenceStart)
        };
    }

    private static string CandidateCategory(
        int score,
        bool hasStopWord,
        bool isRelationshipTitle,
        bool hasTemporalWord,
        bool hasLocationIndicator,
        CharacterCandidateEvidence evidence)
    {
        if (hasStopWord)
        {
            return "Excluded";
        }
        if (isRelationshipTitle)
        {
            return "RelationshipTitle";
        }
        if (hasTemporalWord && evidence.DialogueEvidenceCount == 0 && evidence.PossessiveEvidenceCount == 0)
        {
            return "Excluded";
        }
        if (hasLocationIndicator && score < 4)
        {
            return "Excluded";
        }
        if (evidence.MentionCount < CharacterDiscoveryMinimumMentions && evidence.DialogueEvidenceCount == 0)
        {
            return "Excluded";
        }

        return score >= 4 ? "ProbableCharacter" : "PossibleCharacter";
    }

    private static string BuildCandidateReason(
        CharacterCandidateEvidence evidence,
        bool hasStopWord,
        bool isRelationshipTitle,
        bool hasHonorific,
        bool hasMultiWordName,
        bool hasTemporalWord,
        bool hasLocationIndicator,
        bool mostlySentenceStart)
    {
        if (hasStopWord)
        {
            return "Excluded word";
        }
        if (isRelationshipTitle)
        {
            return "Relationship title";
        }
        if (evidence.DialogueEvidenceCount > 0)
        {
            return "Dialogue evidence";
        }
        if (evidence.PossessiveEvidenceCount > 0)
        {
            return "Possessive use";
        }
        if (evidence.HumanActionEvidenceCount > 0)
        {
            return "Human action nearby";
        }
        if (hasHonorific)
        {
            return "Title or honorific";
        }
        if (hasMultiWordName)
        {
            if (hasLocationIndicator)
            {
                return "Possible location";
            }

            return "Proper-name phrase";
        }
        if (hasTemporalWord)
        {
            return "Possible name or temporal word";
        }
        if (mostlySentenceStart)
        {
            return "Mostly sentence starts";
        }
        return evidence.MentionCount >= 10 ? "Frequent possible name" : "Possible name";
    }

    private static int ConfidenceSortValue(string confidence)
    {
        return string.Equals(confidence, "High", StringComparison.OrdinalIgnoreCase) ? 3
            : string.Equals(confidence, "Medium", StringComparison.OrdinalIgnoreCase) ? 2
            : 1;
    }

    private static int CategorySortValue(string category)
    {
        return string.Equals(category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase) ? 4
            : string.Equals(category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase) ? 3
            : string.Equals(category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase) ? 2
            : 1;
    }

    private static bool AppearsAtSentenceStart(string manuscriptText, int matchIndex)
    {
        for (var i = matchIndex - 1; i >= 0; i--)
        {
            var character = manuscriptText[i];
            if (char.IsWhiteSpace(character))
            {
                if (character is '\n' or '\r')
                {
                    return true;
                }
                continue;
            }

            if (character is '"' or '\'' or '’' or '“' or '”' or '(' or '[')
            {
                continue;
            }

            return character is '.' or '!' or '?' or ':' or ';';
        }

        return true;
    }

    private static bool HasDialogueTagEvidence(string manuscriptText, int matchIndex, int matchLength)
    {
        var before = manuscriptText[Math.Max(0, matchIndex - 48)..matchIndex];
        var afterStart = matchIndex + matchLength;
        var after = manuscriptText[afterStart..Math.Min(manuscriptText.Length, afterStart + 48)];
        return HasTagAfterCandidate(after) || HasTagBeforeCandidate(before);
    }

    private static bool HasPossessiveEvidence(string manuscriptText, int matchIndex, int matchLength)
    {
        var afterStart = matchIndex + matchLength;
        return afterStart + 1 < manuscriptText.Length
            && manuscriptText[afterStart] == '\''
            && char.ToLowerInvariant(manuscriptText[afterStart + 1]) == 's';
    }

    private static bool HasHumanActionEvidence(string manuscriptText, int matchIndex, int matchLength)
    {
        var afterStart = matchIndex + matchLength;
        var after = manuscriptText[afterStart..Math.Min(manuscriptText.Length, afterStart + 36)];
        var match = Regex.Match(after, @"^\s+(?<verb>[a-z]+)\b", RegexOptions.IgnoreCase);
        return match.Success && CharacterDiscoveryHumanActionVerbs.Contains(match.Groups["verb"].Value);
    }

    private static bool HasTagAfterCandidate(string textAfterCandidate)
    {
        var match = Regex.Match(textAfterCandidate, @"^\s*,?\s*(?<tag>[a-z]+)\b", RegexOptions.IgnoreCase);
        return match.Success && CharacterDiscoveryDialogueTags.Contains(match.Groups["tag"].Value);
    }

    private static bool HasTagBeforeCandidate(string textBeforeCandidate)
    {
        var match = Regex.Match(textBeforeCandidate, @"\b(?<tag>[a-z]+)\s*,?\s*[""'\u2019\u201d]?\s*$", RegexOptions.IgnoreCase);
        return match.Success && CharacterDiscoveryDialogueTags.Contains(match.Groups["tag"].Value);
    }

    private static string NormalizeCandidateName(string candidate)
    {
        return Regex.Replace(NormalizeApostrophes(candidate), @"\s+", " ").Trim().Trim(',', '.', ';', ':', '!', '?', '"', '\'');
    }

    private static string NormalizeApostrophes(string? value)
    {
        return (value ?? string.Empty).Replace('’', '\'').Replace('‘', '\'');
    }

    private static string TrimHonorificPunctuation(string word)
    {
        return (word ?? string.Empty).Trim().TrimEnd('.');
    }

    private sealed class CharacterCandidateEvidence(string text)
    {
        public string Text { get; } = text;
        public int MentionCount { get; set; }
        public int SentenceStartMentions { get; set; }
        public int DialogueEvidenceCount { get; set; }
        public int PossessiveEvidenceCount { get; set; }
        public int HumanActionEvidenceCount { get; set; }
    }

    private static WordCompanionManuscriptDocumentDto ToDto(ManuscriptDocumentModel document) => new()
    {
        ManuscriptDocumentId = document.ManuscriptDocumentID,
        BookId = document.BookID,
        ProjectId = document.ProjectID,
        DocumentGuid = document.DocumentGuid,
        BindingVersion = document.BindingVersion,
        CreatedUtc = document.CreatedUtc,
        LastSyncUtc = document.LastSyncUtc,
        LastOpenedUtc = document.LastOpenedUtc,
        IsActive = document.IsActive
    };
}
