using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;

namespace PlotLine.Services;

public interface IOnboardingService
{
    Task<UserOnboardingState> GetOrCreateAsync();
    Task<OnboardingWizardViewModel> GetWizardAsync();
    Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null);
    Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport);
    Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? progress = null);
    Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId);
    Task<bool> ShouldShowOnboardingAsync();
    Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync();
    Task<bool> ShouldShowWordCompanionHeaderAsync();
    Task MoveToNextStepAsync();
    Task StartManualSetupAsync();
    Task StartManuscriptImportSetupAsync();
    Task BeginManuscriptImportForBookAsync(int projectId, int bookId);
    Task SetCurrentStepAsync(string currentStep);
    Task SaveWritingJourneyAsync(string writingJourney);
    Task SaveWritingSoftwareAsync(string writingSoftware);
    Task SaveProjectAsync(ProjectOnboardingForm form);
    Task SaveBookAsync(BookOnboardingForm form);
    Task MarkCompletedAsync();
}

public sealed class OnboardingService(
    IOnboardingRepository onboarding,
    IProjectRepository projects,
    IBookRepository books,
    IBookService bookService,
    IProjectActivityService activity,
    IWordCompanionPresenceService companionPresence,
    IManuscriptScanPreviewStore scanPreviews,
    ICharacterRepository characters,
    IOnboardingBuildRepository builds,
    ICurrentUserService currentUser) : IOnboardingService
{
    private static readonly HashSet<string> WritingJourneys = new(StringComparer.Ordinal)
    {
        WritingJourneyValues.PlanningNewStory,
        WritingJourneyValues.AlreadyStarted,
        WritingJourneyValues.ManuscriptMostlyComplete
    };

    private static readonly HashSet<string> WritingSoftware = new(StringComparer.Ordinal)
    {
        WritingSoftwareValues.MicrosoftWord,
        WritingSoftwareValues.GoogleDocs,
        WritingSoftwareValues.Scrivener,
        WritingSoftwareValues.LibreOfficeOpenOffice,
        WritingSoftwareValues.MarkdownObsidian,
        WritingSoftwareValues.Other
    };

    public async Task<UserOnboardingState> GetOrCreateAsync()
        => await onboarding.GetOrCreateAsync(RequireUserId());

    public async Task<OnboardingWizardViewModel> GetWizardAsync()
    {
        var state = await GetOrCreateAsync();
        var step = ResolveStep(state);
        var projectOptions = await BuildProjectOptionsAsync();
        var bookOptions = state.ProjectID.HasValue
            ? await BuildBookOptionsAsync(state.ProjectID.Value)
            : [];
        var selectedProject = state.ProjectID.HasValue
            ? await projects.GetForUserAsync(state.ProjectID.Value, RequireUserId())
            : null;
        var selectedBook = state.BookID.HasValue
            ? await books.GetAsync(state.BookID.Value)
            : null;
        var presence = await companionPresence.GetStatusAsync(RequireUserId());
        var scanState = await scanPreviews.GetStateAsync(RequireUserId(), state.UserOnboardingStateID);

        return new OnboardingWizardViewModel
        {
            CurrentStep = step,
            StepNumber = StepNumber(step),
            TotalSteps = 9,
            WritingJourney = state.WritingJourney,
            WritingSoftware = state.WritingSoftware,
            ProjectID = state.ProjectID,
            BookID = state.BookID,
            SelectedProjectName = selectedProject?.ProjectName,
            SelectedBookTitle = selectedBook is null ? null : BookOptionTitle(selectedBook),
            CompanionPresence = ToPresenceViewModel(presence),
            ScanState = ToScanStateViewModel(scanState),
            ProjectOptions = projectOptions,
            BookOptions = bookOptions,
            ProjectForm = new ProjectOnboardingForm
            {
                Mode = projectOptions.Count == 0 || !state.ProjectID.HasValue ? OnboardingSelectionModes.Create : OnboardingSelectionModes.Select,
                SelectedProjectID = state.ProjectID
            },
            BookForm = new BookOnboardingForm
            {
                Mode = bookOptions.Count == 0 || !state.BookID.HasValue ? OnboardingSelectionModes.Create : OnboardingSelectionModes.Select,
                SelectedBookID = state.BookID
            },
            Options = step switch
            {
                OnboardingSteps.WritingJourney => WritingJourneyOptions(),
                OnboardingSteps.WritingSoftware => WritingSoftwareOptions(),
                _ => []
            }
        };
    }

    public async Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null)
    {
        var context = await GetScanReviewContextAsync(previewId);
        return context is null ? null : BuildScanReviewViewModel(context.Value);
    }

    public async Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport)
    {
        var context = await GetScanReviewContextAsync(form.PreviewID);
        if (context is null)
        {
            return null;
        }

        var (userId, preview, _, project, book, existingCharacters) = context.Value;
        var chapterLookup = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
        var sceneLookup = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal);
        var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
        var existingCharacterIds = existingCharacters.Select(character => character.CharacterID).ToHashSet();

        var chapterDecisions = form.Chapters
            .Where(item => chapterLookup.ContainsKey(item.TemporaryChapterKey))
            .Select(item => new ManuscriptScanChapterReviewDecision
            {
                TemporaryChapterKey = item.TemporaryChapterKey,
                Include = item.Include,
                Title = Clean(item.Title),
                ChapterNumber = item.ChapterNumber
            })
            .OrderBy(item => item.ChapterNumber)
            .ToList();

        var includedChapterKeys = chapterDecisions.Where(item => item.Include).Select(item => item.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
        var sceneDecisions = form.Scenes
            .Where(item => sceneLookup.ContainsKey(item.TemporarySceneKey))
            .Select(item => new ManuscriptScanSceneReviewDecision
            {
                TemporarySceneKey = item.TemporarySceneKey,
                TemporaryChapterKey = item.TemporaryChapterKey,
                Include = item.Include && includedChapterKeys.Contains(item.TemporaryChapterKey),
                Title = CleanOptional(item.Title),
                SceneNumberWithinChapter = item.SceneNumberWithinChapter,
                AllowZeroWords = item.AllowZeroWords
            })
            .OrderBy(item => item.TemporaryChapterKey)
            .ThenBy(item => item.SceneNumberWithinChapter)
            .ToList();

        var characterDecisions = form.Characters
            .Where(item => candidateLookup.ContainsKey(item.TemporaryCharacterKey))
            .Select(item =>
            {
                var cleanName = Clean(item.Name);
                var matchedExistingId = item.ExistingCharacterID.HasValue && existingCharacterIds.Contains(item.ExistingCharacterID.Value)
                    ? item.ExistingCharacterID
                    : MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID;
                return new ManuscriptScanCharacterReviewDecision
                {
                    TemporaryCharacterKey = item.TemporaryCharacterKey,
                    Include = item.Include,
                    Name = cleanName,
                    Category = item.Category,
                    ExistingCharacterID = matchedExistingId
                };
            })
            .ToList();

        var review = new ManuscriptScanReviewDecision
        {
            PreviewID = preview.PreviewID,
            Status = readyToImport ? ManuscriptScanReviewStatuses.ReadyToImport : ManuscriptScanReviewStatuses.ReviewInProgress,
            Chapters = chapterDecisions,
            Scenes = sceneDecisions,
            Characters = characterDecisions
        };

        ValidateScanReview(review, preview);
        var saved = await scanPreviews.SaveReviewAsync(userId, review);
        return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters));
    }

    public async Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? progress = null)
    {
        var context = await GetScanReviewContextAsync(previewId);
        if (context is null)
        {
            return null;
        }

        var (userId, preview, review, _, _, _) = context.Value;
        if (!string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
        {
            throw new InvalidOperationException("Review the scan and choose Save and continue before preparing chapters.");
        }

        await PublishBuildProgress(userId, preview.PreviewID, "Creating chapters...", 10, progress);
        ValidateScanReview(review, preview);

        var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList();

        await PublishBuildProgress(userId, preview.PreviewID, "Preparing chapters for analysis...", 35, progress);
        var request = new OnboardingManuscriptBuildRequest
        {
            PreviewID = preview.PreviewID,
            ProjectID = preview.ProjectID,
            BookID = preview.BookID,
            DocumentGuid = Guid.TryParse(preview.CompanionDocumentIdentifier, out var documentGuid) ? documentGuid : Guid.NewGuid(),
            Chapters = chapterDecisions.Select((chapter, index) => new OnboardingBuildChapterRequest
            {
                TemporaryChapterKey = chapter.TemporaryChapterKey,
                Title = chapter.Title,
                SortOrder = (index + 1) * 10,
                WordCount = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapter.TemporaryChapterKey)?.WordCount ?? 0
            }).ToList(),
            Scenes = [],
            Characters = []
        };

        await PublishBuildProgress(userId, preview.PreviewID, "Saving chapters...", 60, progress);
        var result = await builds.BuildAsync(userId, request)
            ?? throw new InvalidOperationException($"The project chapters could not be prepared. Approved chapters: {request.Chapters.Count}. Return to review, save your selections, and try again.");
        result = new OnboardingManuscriptBuildResult
        {
            BuildID = result.BuildID,
            PreviewID = result.PreviewID,
            ProjectID = result.ProjectID,
            BookID = result.BookID,
            ManuscriptDocumentID = result.ManuscriptDocumentID,
            Status = result.Status,
            MarkerStatus = result.MarkerStatus,
            ChaptersCreated = result.ChaptersCreated,
            ScenesCreated = result.ScenesCreated,
            CharactersCreated = result.CharactersCreated,
            CharactersReused = result.CharactersReused,
            SceneAppearancesCreated = result.SceneAppearancesCreated,
            AlreadyBuilt = result.AlreadyBuilt,
            Message = "Approved chapters are ready for Story Intelligence.",
            ChapterMappings = result.ChapterMappings,
            SceneMappings = result.SceneMappings,
            CharacterMappings = result.CharacterMappings,
            MarkerWarning = result.MarkerWarning
        };
        await scanPreviews.SaveBuildResultAsync(userId, result);
        await PublishBuildProgress(userId, preview.PreviewID, "Finalising project...", 90, progress);
        await PublishBuildProgress(userId, preview.PreviewID, result.Message, 100, progress, result);
        return result;
    }

    public async Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId)
        => await scanPreviews.GetBuildResultAsync(RequireUserId(), previewId);

    private async Task PublishBuildProgress(int userId, Guid previewId, string message, int percent, Func<OnboardingBuildProgress, Task>? publish, OnboardingManuscriptBuildResult? result = null)
    {
        var state = await scanPreviews.BuildProgressAsync(userId, previewId, message, percent, result);
        if (publish is not null)
        {
            await publish(state);
        }
    }

    private async Task<(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters)?> GetScanReviewContextAsync(Guid? previewId)
    {
        var userId = RequireUserId();
        var state = await onboarding.GetAsync(userId);
        if (state is null || !IsMicrosoftWordState(state))
        {
            return null;
        }

        var preview = previewId.HasValue
            ? await scanPreviews.GetPreviewAsync(userId, previewId.Value)
            : await scanPreviews.GetLatestPreviewAsync(userId, state.UserOnboardingStateID);
        if (preview is null
            || preview.OnboardingID != state.UserOnboardingStateID
            || preview.ProjectID != state.ProjectID
            || preview.BookID != state.BookID)
        {
            return null;
        }

        var project = await projects.GetForUserAsync(preview.ProjectID, userId);
        var book = await books.GetAsync(preview.BookID);
        if (project is null || !project.IsOwner || project.IsArchived || book is null || book.ProjectID != project.ProjectID || book.IsArchived)
        {
            return null;
        }

        var review = await scanPreviews.GetReviewAsync(userId, preview.PreviewID);
        if (review is null)
        {
            return null;
        }

        var existingCharacters = await characters.ListCharactersAsync(project.ProjectID);
        return (userId, preview, review, project, book, existingCharacters.Where(character => !character.IsArchived).ToList());
    }

    private static ManuscriptScanReviewViewModel BuildScanReviewViewModel(
        (int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters) context)
    {
        var (userId, preview, review, project, book, existingCharacters) = context;
        _ = userId;
        var chapterReview = review.Chapters.ToDictionary(item => item.TemporaryChapterKey, StringComparer.Ordinal);
        var sceneReview = review.Scenes.ToDictionary(item => item.TemporarySceneKey, StringComparer.Ordinal);
        var characterReview = review.Characters.ToDictionary(item => item.TemporaryCharacterKey, StringComparer.Ordinal);
        var existingOptions = existingCharacters
            .OrderBy(character => character.CharacterName)
            .Select(character => new ManuscriptScanExistingCharacterOptionViewModel
            {
                CharacterID = character.CharacterID,
                CharacterName = character.CharacterName
            })
            .ToList();

        var chapters = preview.Chapters
            .OrderBy(chapter => chapter.ChapterNumber)
            .Select(chapter =>
            {
                chapterReview.TryGetValue(chapter.TemporaryChapterKey, out var decision);
                var scenes = preview.Scenes
                    .Where(scene => string.Equals(scene.TemporaryChapterKey, chapter.TemporaryChapterKey, StringComparison.Ordinal))
                    .OrderBy(scene => scene.SceneNumberWithinChapter)
                    .Select(scene =>
                    {
                        sceneReview.TryGetValue(scene.TemporarySceneKey, out var sceneDecision);
                        return new ManuscriptScanReviewSceneViewModel
                        {
                            TemporarySceneKey = scene.TemporarySceneKey,
                            TemporaryChapterKey = scene.TemporaryChapterKey,
                            SceneNumberWithinChapter = scene.SceneNumberWithinChapter,
                            Title = scene.Title,
                            ReviewTitle = sceneDecision?.Title ?? (string.IsNullOrWhiteSpace(scene.Title) ? $"Scene {scene.SceneNumberWithinChapter}" : scene.Title),
                            Include = sceneDecision?.Include ?? true,
                            WordCount = scene.WordCount,
                            OpeningTextPreview = scene.OpeningTextPreview
                        };
                    })
                    .ToList();

                return new ManuscriptScanReviewChapterViewModel
                {
                    TemporaryChapterKey = chapter.TemporaryChapterKey,
                    ChapterNumber = chapter.ChapterNumber,
                    Title = chapter.Title,
                    ReviewTitle = decision?.Title ?? chapter.Title,
                    Include = decision?.Include ?? (!string.IsNullOrWhiteSpace(chapter.Title) && chapter.WordCount > 0),
                    WordCount = chapter.WordCount,
                    Scenes = scenes
                };
            })
            .ToList();

        var characters = preview.CharacterCandidates
            .OrderByDescending(candidate => CandidateCategorySortValue(candidate.Category))
            .ThenByDescending(candidate => candidate.QualityScore)
            .ThenByDescending(candidate => candidate.MentionCount)
            .ThenBy(candidate => candidate.Name)
            .Select(candidate =>
            {
                characterReview.TryGetValue(candidate.TemporaryCharacterKey, out var decision);
                var reviewName = decision?.Name ?? candidate.Name;
                var existing = decision?.ExistingCharacterID.HasValue == true
                    ? existingCharacters.FirstOrDefault(character => character.CharacterID == decision.ExistingCharacterID.Value)
                    : MatchExistingCharacter(reviewName, existingCharacters);
                return new ManuscriptScanReviewCharacterViewModel
                {
                    TemporaryCharacterKey = candidate.TemporaryCharacterKey,
                    Name = candidate.Name,
                    ReviewName = reviewName,
                    Include = decision?.Include ?? string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
                    MentionCount = candidate.MentionCount,
                    QualityScore = candidate.QualityScore,
                    Category = candidate.Category,
                    Reason = candidate.Reason,
                    ExistingCharacterID = existing?.CharacterID,
                    ExistingCharacterName = existing?.CharacterName
                };
            })
            .ToList();

        var selectedChapterKeys = chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
        var selectedScenes = chapters.SelectMany(chapter => chapter.Scenes).Where(scene => scene.Include && selectedChapterKeys.Contains(scene.TemporaryChapterKey)).ToList();

        return new ManuscriptScanReviewViewModel
        {
            PreviewID = preview.PreviewID,
            ReviewStatus = review.Status,
            DocumentTitle = string.IsNullOrWhiteSpace(preview.DocumentTitle) ? "Word manuscript" : preview.DocumentTitle!,
            SelectedProjectName = project.ProjectName,
            SelectedBookTitle = BookOptionTitle(book),
            TotalWordCount = preview.TotalWordCount,
            ChapterCount = preview.ChapterCount,
            SceneCount = preview.SceneCount,
            CharacterCandidateCount = preview.CharacterCandidateCount,
            SelectedChapterCount = selectedChapterKeys.Count,
            SelectedSceneCount = selectedScenes.Count,
            SelectedCharacterCount = characters.Count(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)),
            SelectedWordCount = selectedScenes.Sum(scene => scene.WordCount),
            Chapters = chapters,
            CharacterCandidates = characters,
            ExistingCharacterOptions = existingOptions
        };
    }

    private static void ValidateScanReview(ManuscriptScanReviewDecision review, ManuscriptScanPreview preview)
    {
        var previewChapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
        var previewScenes = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal);
        var includedChapterKeys = review.Chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
        if (includedChapterKeys.Count == 0)
        {
            throw new InvalidOperationException("Choose at least one chapter before continuing.");
        }

        if (!review.Scenes.Any(scene => scene.Include && includedChapterKeys.Contains(scene.TemporaryChapterKey)))
        {
            throw new InvalidOperationException("Choose at least one scene before continuing.");
        }

        if (review.Chapters.Where(chapter => chapter.Include).Any(chapter => string.IsNullOrWhiteSpace(chapter.Title)))
        {
            throw new InvalidOperationException("Every included chapter needs a title.");
        }

        if (review.Chapters.Select(chapter => chapter.ChapterNumber).Distinct().Count() != review.Chapters.Count)
        {
            throw new InvalidOperationException("Chapter order is not valid. Restore the scan defaults and try again.");
        }

        foreach (var scene in review.Scenes.Where(scene => scene.Include))
        {
            if (!includedChapterKeys.Contains(scene.TemporaryChapterKey))
            {
                throw new InvalidOperationException("Included scenes must belong to included chapters.");
            }

            if (!previewScenes.TryGetValue(scene.TemporarySceneKey, out var previewScene))
            {
                throw new InvalidOperationException("One of the selected scenes no longer exists in the scan preview.");
            }

            if (previewScene.WordCount <= 0 && !scene.AllowZeroWords)
            {
                throw new InvalidOperationException("Included scenes with no words must be explicitly allowed.");
            }
        }

        foreach (var chapter in review.Chapters)
        {
            if (!previewChapters.ContainsKey(chapter.TemporaryChapterKey))
            {
                throw new InvalidOperationException("One of the selected chapters no longer exists in the scan preview.");
            }
        }

        var duplicateName = review.Characters
            .Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
            .Select(character => Clean(character.Name))
            .Where(name => !string.IsNullOrWhiteSpace(name))
            .GroupBy(name => name, StringComparer.OrdinalIgnoreCase)
            .FirstOrDefault(group => group.Count() > 1);
        if (duplicateName is not null)
        {
            throw new InvalidOperationException($"The character name \"{duplicateName.Key}\" appears more than once. Exclude or rename one before continuing.");
        }

        if (review.Characters.Any(character => character.Include && string.IsNullOrWhiteSpace(character.Name)))
        {
            throw new InvalidOperationException("Every included character needs a display name.");
        }
    }

    private static Character? MatchExistingCharacter(string? name, IReadOnlyList<Character> existingCharacters)
    {
        var clean = Clean(name);
        return string.IsNullOrWhiteSpace(clean)
            ? null
            : existingCharacters.FirstOrDefault(character => string.Equals(character.CharacterName, clean, StringComparison.OrdinalIgnoreCase));
    }

    public async Task<bool> ShouldShowOnboardingAsync()
    {
        if (!currentUser.UserId.HasValue)
        {
            return false;
        }

        var state = await onboarding.GetAsync(currentUser.UserId.Value);
        return state is null || state.OnboardingCompletedUtc is null;
    }

    public async Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync()
    {
        if (!currentUser.UserId.HasValue)
        {
            return new OnboardingNudgeViewModel();
        }

        var state = await onboarding.GetAsync(currentUser.UserId.Value);
        if (state is not null && IsMicrosoftWordState(state))
        {
            var status = await companionPresence.GetStatusAsync(currentUser.UserId.Value);
            return new OnboardingNudgeViewModel
            {
                ShouldShow = true,
                IsCompanionPresence = true,
                CompanionConnected = status.IsConnected,
                Title = status.IsConnected ? "Word Companion Connected" : "Connect your Word Companion",
                Description = status.IsConnected
                    ? "Your Word Companion is ready for this story setup."
                    : "Open Word, start the Companion, and sign in with your PlotDirector account.",
                ButtonText = status.IsConnected ? "View setup" : "Connect Companion"
            };
        }

        if (state?.OnboardingCompletedUtc is not null)
        {
            return new OnboardingNudgeViewModel { ShouldShow = false };
        }

        var hasStarted = state is not null
            && (!string.IsNullOrWhiteSpace(state.WritingJourney)
                || !string.IsNullOrWhiteSpace(state.WritingSoftware)
                || state.ProjectID.HasValue
                || state.BookID.HasValue
                || state.CurrentStep != OnboardingSteps.Welcome);

        return hasStarted
            ? new OnboardingNudgeViewModel
            {
                ShouldShow = true,
                Title = "Continue your PlotDirector setup",
                Description = "Pick the project and book PlotDirector should use for your first guided setup.",
                ButtonText = "Continue setup"
            }
            : new OnboardingNudgeViewModel { ShouldShow = true };
    }

    public async Task<bool> ShouldShowWordCompanionHeaderAsync()
    {
        if (!currentUser.UserId.HasValue)
        {
            return false;
        }

        var state = await onboarding.GetAsync(currentUser.UserId.Value);
        return state is not null && IsMicrosoftWordState(state);
    }

    public async Task MoveToNextStepAsync()
    {
        var state = await GetOrCreateAsync();
        var nextStep = ResolveStep(state) switch
        {
            OnboardingSteps.Welcome => OnboardingSteps.WritingJourney,
            OnboardingSteps.WritingJourney => OnboardingSteps.WritingSoftware,
            OnboardingSteps.WritingSoftware => OnboardingSteps.Project,
            OnboardingSteps.Project => OnboardingSteps.Book,
            OnboardingSteps.Book => OnboardingSteps.NextPathPreview,
            _ => OnboardingSteps.NextPathPreview
        };

        await onboarding.SetCurrentStepAsync(RequireUserId(), nextStep);
    }

    public async Task StartManualSetupAsync()
    {
        var userId = RequireUserId();
        await onboarding.SaveWritingJourneyAsync(userId, WritingJourneyValues.PlanningNewStory);
        var state = await onboarding.SaveWritingSoftwareAsync(userId, WritingSoftwareValues.Other);
        await scanPreviews.ClearAsync(userId, state.UserOnboardingStateID);
        await onboarding.SetCurrentStepAsync(userId, OnboardingSteps.Project);
    }

    public async Task StartManuscriptImportSetupAsync()
    {
        var userId = RequireUserId();
        await onboarding.SaveWritingJourneyAsync(userId, WritingJourneyValues.ManuscriptMostlyComplete);
        var state = await onboarding.SaveWritingSoftwareAsync(userId, WritingSoftwareValues.MicrosoftWord);
        await scanPreviews.ClearAsync(userId, state.UserOnboardingStateID);
        await onboarding.SetCurrentStepAsync(userId, OnboardingSteps.Project);
    }

    public async Task BeginManuscriptImportForBookAsync(int projectId, int bookId)
    {
        var userId = RequireUserId();
        var project = await projects.GetForUserAsync(projectId, userId);
        var book = await books.GetAsync(bookId);
        if (project is null || !project.IsOwner || project.IsArchived || book is null || book.ProjectID != project.ProjectID || book.IsArchived)
        {
            throw new InvalidOperationException("Choose one of your active books before importing a manuscript.");
        }

        await onboarding.SaveWritingJourneyAsync(userId, WritingJourneyValues.ManuscriptMostlyComplete);
        await onboarding.SaveWritingSoftwareAsync(userId, WritingSoftwareValues.MicrosoftWord);
        await onboarding.SaveProjectAsync(userId, project.ProjectID);
        var state = await onboarding.SaveBookAsync(userId, book.BookID);
        await scanPreviews.ClearAsync(userId, state.UserOnboardingStateID);
        await onboarding.SetCurrentStepAsync(userId, OnboardingSteps.NextPathPreview);
    }

    public async Task SetCurrentStepAsync(string currentStep)
    {
        if (currentStep is not (OnboardingSteps.Welcome
            or OnboardingSteps.WritingJourney
            or OnboardingSteps.WritingSoftware
            or OnboardingSteps.Project
            or OnboardingSteps.Book
            or OnboardingSteps.NextPathPreview
            or OnboardingSteps.Complete))
        {
            throw new InvalidOperationException("Choose a valid onboarding step.");
        }

        await onboarding.SetCurrentStepAsync(RequireUserId(), currentStep);
    }

    public async Task SaveWritingJourneyAsync(string writingJourney)
    {
        if (!WritingJourneys.Contains(writingJourney))
        {
            throw new InvalidOperationException("Choose a writing journey option.");
        }

        await onboarding.SaveWritingJourneyAsync(RequireUserId(), writingJourney);
    }

    public async Task SaveWritingSoftwareAsync(string writingSoftware)
    {
        if (!WritingSoftware.Contains(writingSoftware))
        {
            throw new InvalidOperationException("Choose a writing software option.");
        }

        await onboarding.SaveWritingSoftwareAsync(RequireUserId(), writingSoftware);
    }

    public async Task SaveProjectAsync(ProjectOnboardingForm form)
    {
        var userId = RequireUserId();
        if (string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal))
        {
            var projectId = form.SelectedProjectID.GetValueOrDefault();
            var project = projectId > 0 ? await projects.GetForUserAsync(projectId, userId) : null;
            if (project is null || !project.IsOwner || project.IsArchived)
            {
                throw new InvalidOperationException("Choose one of your active projects.");
            }

            await onboarding.SaveProjectAsync(userId, project.ProjectID);
            return;
        }

        if (!string.Equals(form.Mode, OnboardingSelectionModes.Create, StringComparison.Ordinal))
        {
            throw new InvalidOperationException("Choose whether to create or use an existing project.");
        }

        var projectName = Clean(form.ProjectName);
        if (string.IsNullOrWhiteSpace(projectName))
        {
            throw new InvalidOperationException("Enter a project name.");
        }

        var projectIdCreated = await projects.SaveAsync(new Project
        {
            ProjectName = projectName,
            Description = CleanOptional(form.Description)
        }, userId, "Custom");
        await activity.RecordAsync(projectIdCreated, "Created", "Project", projectIdCreated, projectName);
        await onboarding.SaveProjectAsync(userId, projectIdCreated);
    }

    public async Task SaveBookAsync(BookOnboardingForm form)
    {
        var userId = RequireUserId();
        var state = await GetOrCreateAsync();
        var projectId = state.ProjectID.GetValueOrDefault();
        var project = projectId > 0 ? await projects.GetForUserAsync(projectId, userId) : null;
        if (project is null || !project.IsOwner || project.IsArchived)
        {
            throw new InvalidOperationException("Choose a project before choosing a book.");
        }

        if (string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal))
        {
            var bookId = form.SelectedBookID.GetValueOrDefault();
            var book = bookId > 0 ? await books.GetAsync(bookId) : null;
            if (book is null || book.ProjectID != project.ProjectID || book.IsArchived)
            {
                throw new InvalidOperationException("Choose a book from the selected project.");
            }

            await onboarding.SaveBookAsync(userId, book.BookID);
            return;
        }

        if (!string.Equals(form.Mode, OnboardingSelectionModes.Create, StringComparison.Ordinal))
        {
            throw new InvalidOperationException("Choose whether to create or use an existing book.");
        }

        var bookTitle = Clean(form.BookTitle);
        if (string.IsNullOrWhiteSpace(bookTitle))
        {
            throw new InvalidOperationException("Enter a book title.");
        }

        var createModel = await bookService.GetCreateAsync(project.ProjectID)
            ?? throw new InvalidOperationException("The selected project could not be used.");
        createModel.BookTitle = bookTitle;
        createModel.Subtitle = CleanOptional(form.Subtitle);
        createModel.TargetWordCount = form.TargetWordCount;
        var result = await bookService.SaveAsync(createModel);
        if (!result.Succeeded)
        {
            throw new InvalidOperationException(result.ErrorMessage ?? "The book could not be created.");
        }

        await onboarding.SaveBookAsync(userId, result.BookID);
    }

    public async Task MarkCompletedAsync()
        => await onboarding.MarkCompletedAsync(RequireUserId());

    private int RequireUserId()
        => currentUser.UserId ?? throw new InvalidOperationException("Sign in to use onboarding.");

    private static string ResolveStep(UserOnboardingState state)
    {
        if (state.OnboardingCompletedUtc.HasValue)
        {
            return OnboardingSteps.NextPathPreview;
        }

        if (state.CurrentStep is OnboardingSteps.Welcome
            or OnboardingSteps.WritingJourney
            or OnboardingSteps.WritingSoftware
            or OnboardingSteps.Project
            or OnboardingSteps.Book
            or OnboardingSteps.NextPathPreview)
        {
            return state.CurrentStep;
        }

        if (string.IsNullOrWhiteSpace(state.WritingJourney))
        {
            return OnboardingSteps.WritingJourney;
        }

        if (string.IsNullOrWhiteSpace(state.WritingSoftware))
        {
            return OnboardingSteps.WritingSoftware;
        }

        if (!state.ProjectID.HasValue)
        {
            return OnboardingSteps.Project;
        }

        if (!state.BookID.HasValue)
        {
            return OnboardingSteps.Book;
        }

        return OnboardingSteps.NextPathPreview;
    }

    private static int StepNumber(string step) => step switch
    {
        OnboardingSteps.Welcome => 1,
        OnboardingSteps.WritingJourney => 2,
        OnboardingSteps.WritingSoftware => 3,
        OnboardingSteps.Project => 4,
        OnboardingSteps.Book => 5,
        _ => 6
    };

    private async Task<IReadOnlyList<OnboardingProjectOptionViewModel>> BuildProjectOptionsAsync()
    {
        var userId = RequireUserId();
        var rows = await projects.ListActiveForUserAsync(userId);
        return rows
            .Where(project => project.IsOwner && !project.IsArchived)
            .OrderBy(project => project.ProjectName)
            .Select(project => new OnboardingProjectOptionViewModel
            {
                ProjectID = project.ProjectID,
                ProjectName = project.ProjectName,
                Description = string.IsNullOrWhiteSpace(project.Description)
                    ? $"Updated {project.UpdatedDate:yyyy-MM-dd}"
                    : project.Description
            })
            .ToList();
    }

    private async Task<IReadOnlyList<OnboardingBookOptionViewModel>> BuildBookOptionsAsync(int projectId)
    {
        var project = await projects.GetForUserAsync(projectId, RequireUserId());
        if (project is null || !project.IsOwner || project.IsArchived)
        {
            return [];
        }

        var rows = await books.ListByProjectAsync(projectId);
        return rows
            .Where(book => !book.IsArchived)
            .OrderBy(book => book.SortOrder)
            .ThenBy(book => book.BookNumber)
            .ThenBy(book => book.BookTitle)
            .Select(book => new OnboardingBookOptionViewModel
            {
                BookID = book.BookID,
                DisplayTitle = BookOptionTitle(book),
                Detail = BookOptionDetail(book)
            })
            .ToList();
    }

    private static string BookOptionTitle(Book book)
        => $"Book {book.BookNumber}: {book.BookDisplayTitle}";

    private static string BookOptionDetail(Book book)
    {
        var parts = new List<string> { book.Status };
        if (book.TargetWordCount is > 0)
        {
            parts.Add($"{book.TargetWordCount.Value:N0} target words");
        }

        parts.Add($"Updated {book.UpdatedDate:yyyy-MM-dd}");
        return string.Join(" / ", parts);
    }

    private static string Clean(string? value) => (value ?? string.Empty).Trim();

    private static bool IsMicrosoftWordState(UserOnboardingState state)
        => string.Equals(state.WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal)
            || state.UsesWordCompanion == true;

    private static CompanionPresenceViewModel ToPresenceViewModel(WordCompanionPresenceStatus status)
        => new()
        {
            IsConnected = status.IsConnected,
            DocumentOpen = status.DocumentOpen,
            Status = status.Status,
            CompanionVersion = status.CompanionVersion,
            CurrentDocumentName = status.CurrentDocumentName,
            LastHeartbeatUtc = status.LastHeartbeatUtc
        };

    private static ManuscriptScanStateViewModel ToScanStateViewModel(ManuscriptScanState state)
        => new()
        {
            Status = state.Status,
            ReviewStatus = state.ReviewStatus,
            Message = state.Message,
            PercentComplete = state.PercentComplete,
            ChapterCount = state.ChapterCount,
            SceneCount = state.SceneCount,
            CharacterCandidateCount = state.CharacterCandidateCount,
            TotalWordCount = state.TotalWordCount,
            PreviewID = state.PreviewID
        };

    private static int CandidateCategorySortValue(string? category)
        => string.Equals(category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase) ? 4
            : string.Equals(category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase) ? 3
            : string.Equals(category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase) ? 2
            : 1;

    private static string? CleanOptional(string? value)
    {
        var cleaned = Clean(value);
        return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned;
    }

    private static IReadOnlyList<OnboardingOptionViewModel> WritingJourneyOptions() =>
    [
        new()
        {
            Value = WritingJourneyValues.PlanningNewStory,
            Title = "I am planning a brand new story",
            Description = "You are shaping the idea, structure, characters, or world before drafting."
        },
        new()
        {
            Value = WritingJourneyValues.AlreadyStarted,
            Title = "I have already started writing",
            Description = "You have draft material and want PlotDirector to help you organise what exists."
        },
        new()
        {
            Value = WritingJourneyValues.ManuscriptMostlyComplete,
            Title = "My manuscript is largely complete",
            Description = "You are ready to review structure, continuity, and story detail across the manuscript."
        }
    ];

    private static IReadOnlyList<OnboardingOptionViewModel> WritingSoftwareOptions() =>
    [
        new()
        {
            Value = WritingSoftwareValues.MicrosoftWord,
            Title = "Microsoft Word",
            Description = "Best fit for the PlotDirector Word Companion workflow.",
            Badge = "Recommended"
        },
        new()
        {
            Value = WritingSoftwareValues.GoogleDocs,
            Title = "Google Docs",
            Description = "Use exports later when manuscript import is added."
        },
        new()
        {
            Value = WritingSoftwareValues.Scrivener,
            Title = "Scrivener",
            Description = "Use a manuscript export later when import support arrives."
        },
        new()
        {
            Value = WritingSoftwareValues.LibreOfficeOpenOffice,
            Title = "LibreOffice / OpenOffice",
            Description = "Good for document-based workflows outside Microsoft Word."
        },
        new()
        {
            Value = WritingSoftwareValues.MarkdownObsidian,
            Title = "Markdown / Obsidian",
            Description = "For plain-text writing systems and linked-note workflows."
        },
        new()
        {
            Value = WritingSoftwareValues.Other,
            Title = "Other",
            Description = "PlotDirector can still tailor future setup around your tools."
        }
    ];
}
