using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json;
using Tiku.Application.Learning;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Learning;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;

namespace Tiku.Infrastructure.Learning;

public sealed class LearningActivityService(
    TikuDbContext dbContext,
    IQuestionReferenceService questionReferenceService,
    IPublicQuestionAccessPolicy publicQuestionAccessPolicy,
    ITenantExecutionScope tenantExecutionScope) : ILearningActivityService
{
    private const int DefaultLimit = 100;
    private const int MaxLimit = 500;

    public async Task<LearningStatsItem> GetStatsAsync(
        LearningActor actor,
        CancellationToken cancellationToken = default)
    {
        var answers = dbContext.AnswerRecords.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
        return new LearningStatsItem(
            await answers.CountAsync(cancellationToken),
            await answers.CountAsync(item => item.IsCorrect == true, cancellationToken),
            await dbContext.WrongQuestions.AsNoTracking().CountAsync(
                item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.ResolvedAt == null,
                cancellationToken),
            await dbContext.FavoriteQuestions.AsNoTracking().CountAsync(
                item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
                cancellationToken),
            await dbContext.UserWordFavorites.AsNoTracking().CountAsync(
                item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
                cancellationToken),
            await dbContext.UserWordProgress.AsNoTracking().CountAsync(
                item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
                cancellationToken),
            await dbContext.PracticeSessions.AsNoTracking().CountAsync(
                item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
                cancellationToken),
            await dbContext.PracticeSessionReports.AsNoTracking().CountAsync(
                item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
                cancellationToken));
    }

    public async Task<LearningList<LearningTrendItem>> GetTrendAsync(
        LearningActor actor,
        LearningLimitFilter filter,
        CancellationToken cancellationToken = default)
    {
        var since = DateTimeOffset.UtcNow.AddDays(-ResolveLimit(filter.Limit));
        var rows = await dbContext.AnswerRecords.AsNoTracking()
            .Where(item =>
                item.TenantId == actor.TenantId &&
                item.UserId == actor.UserId &&
                item.AnsweredAt >= since)
            .Select(item => new { item.AnsweredAt, item.IsCorrect })
            .ToArrayAsync(cancellationToken);
        var items = rows
            .GroupBy(item => DateOnly.FromDateTime(item.AnsweredAt.UtcDateTime))
            .OrderBy(group => group.Key)
            .Select(group => new LearningTrendItem(
                group.Key,
                group.Count(),
                group.Count(item => item.IsCorrect == true),
                group.Count(item => item.IsCorrect == false)))
            .ToArray();
        return new LearningList<LearningTrendItem>(items);
    }

    public async Task<LearningLeaderboardResult> GetLeaderboardAsync(
        LearningActor actor,
        LearningLimitFilter filter,
        CancellationToken cancellationToken = default)
    {
        var rows = await dbContext.AnswerRecords.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId)
            .GroupBy(item => item.UserId)
            .Select(group => new
            {
                UserId = group.Key,
                AnswerCount = group.Count(),
                CorrectCount = group.Count(item => item.IsCorrect == true),
                WrongCount = group.Count(item => item.IsCorrect == false)
            })
            .OrderByDescending(item => item.CorrectCount)
            .ThenByDescending(item => item.AnswerCount)
            .Take(ResolveLimit(filter.Limit))
            .ToArrayAsync(cancellationToken);
        var userIds = rows.Select(item => item.UserId).ToArray();
        var names = await dbContext.Users.AsNoTracking()
            .Where(item => userIds.Contains(item.Id))
            .ToDictionaryAsync(item => item.Id, item => item.Name ?? item.Phone, cancellationToken);
        var items = rows
            .Select(item => new LearningLeaderboardItem(
                item.UserId,
                names.GetValueOrDefault(item.UserId),
                item.AnswerCount,
                item.CorrectCount,
                item.WrongCount,
                item.AnswerCount == 0 ? 0 : decimal.Round((decimal)item.CorrectCount / item.AnswerCount, 4)))
            .ToArray();
        return new LearningLeaderboardResult(
            "correct_count",
            "all",
            items,
            items.FirstOrDefault(item => item.UserId == actor.UserId),
            DateTimeOffset.UtcNow);
    }

    public async Task<AnswerRecordItem> SubmitAnswerAsync(
        LearningActor actor,
        SubmitAnswerCommand command,
        CancellationToken cancellationToken = default)
    {
        var now = DateTimeOffset.UtcNow;
        var sessionQuestion = await dbContext.PracticeSessionQuestions
            .AsNoTracking()
            .Where(item =>
                item.TenantId == actor.TenantId &&
                item.Id == command.SessionQuestionId)
            .Join(
                dbContext.PracticeSessions.AsNoTracking().Where(session =>
                    session.TenantId == actor.TenantId &&
                    session.UserId == actor.UserId &&
                    session.FinishedAt == null &&
                    (!session.ExpiresAt.HasValue || session.ExpiresAt > now)),
                item => new { item.TenantId, Id = item.PracticeSessionId },
                session => new { session.TenantId, session.Id },
                (item, session) => item)
            .SingleOrDefaultAsync(cancellationToken);

        if (sessionQuestion is null)
        {
            throw new LearningResourceNotFoundException(
                "session_question_not_found",
                "An active practice session question was not found.");
        }

        var record = new AnswerRecord
        {
            TenantId = actor.TenantId,
            UserId = actor.UserId,
            PracticeSessionId = sessionQuestion.PracticeSessionId,
            SessionQuestionId = sessionQuestion.Id,
            SelectedOptions = JsonSerializer.SerializeToElement(command.SelectedOptions ?? []),
            AnswerText = command.AnswerText,
            IsCorrect = command.SelfJudgedCorrect,
            AnsweredAt = now,
            CreatedAt = now
        };
        dbContext.AnswerRecords.Add(record);

        if (command.SelfJudgedCorrect == false)
        {
            var wrongQuestion = await dbContext.WrongQuestions.FindAsync(
                [actor.TenantId, actor.UserId, sessionQuestion.QuestionReferenceId],
                cancellationToken);

            if (wrongQuestion is null)
            {
                dbContext.WrongQuestions.Add(new WrongQuestion
                {
                    TenantId = actor.TenantId,
                    UserId = actor.UserId,
                    QuestionReferenceId = sessionQuestion.QuestionReferenceId,
                    QuestionOwnerTenantId = sessionQuestion.QuestionOwnerTenantId,
                    QuestionId = sessionQuestion.QuestionId,
                    WrongCount = 1,
                    LastWrongAt = now
                });
            }
            else
            {
                wrongQuestion.WrongCount++;
                wrongQuestion.LastWrongAt = now;
                wrongQuestion.ResolvedAt = null;
            }
        }

        await dbContext.SaveChangesAsync(cancellationToken);
        return ToItem(record);
    }

    public async Task<LearningList<FavoriteQuestionItem>> GetFavoriteQuestionsAsync(
        LearningActor actor,
        LearningLimitFilter filter,
        CancellationToken cancellationToken = default)
    {
        var items = await dbContext.FavoriteQuestions
            .AsNoTracking()
            .Where(item =>
                item.TenantId == actor.TenantId &&
                item.UserId == actor.UserId)
            .OrderByDescending(item => item.CreatedAt)
            .Take(ResolveLimit(filter.Limit))
            .Select(item => new FavoriteQuestionItem(
                item.QuestionReferenceId,
                new QuestionLocator(
                    item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
                    item.QuestionId),
                item.CreatedAt))
            .ToArrayAsync(cancellationToken);

        return new LearningList<FavoriteQuestionItem>(items);
    }

    public async Task<LearningActionResult> ToggleFavoriteQuestionAsync(
        LearningActor actor,
        QuestionActionCommand command,
        CancellationToken cancellationToken = default)
    {
        var reference = await questionReferenceService.ResolveAsync(
            actor.TenantId,
            actor.UserId,
            command.Locator,
            cancellationToken);

        var favorite = command.Favorite ?? true;
        var item = await dbContext.FavoriteQuestions.FindAsync(
            [actor.TenantId, actor.UserId, reference.Id],
            cancellationToken);

        if (favorite)
        {
            if (item is null)
            {
                dbContext.FavoriteQuestions.Add(new FavoriteQuestion
                {
                    TenantId = actor.TenantId,
                    UserId = actor.UserId,
                    QuestionReferenceId = reference.Id,
                    QuestionOwnerTenantId = reference.QuestionOwnerTenantId,
                    QuestionId = reference.QuestionId,
                    Source = reference.Source.ToString().ToLowerInvariant(),
                    CreatedAt = DateTimeOffset.UtcNow
                });
            }
        }
        else if (item is not null)
        {
            dbContext.FavoriteQuestions.Remove(item);
        }

        await dbContext.SaveChangesAsync(cancellationToken);
        return new LearningActionResult(true, favorite);
    }

    public async Task<LearningList<WrongQuestionItem>> GetWrongQuestionsAsync(
        LearningActor actor,
        LearningLimitFilter filter,
        CancellationToken cancellationToken = default)
    {
        var query = dbContext.WrongQuestions
            .AsNoTracking()
            .Where(item =>
                item.TenantId == actor.TenantId &&
                item.UserId == actor.UserId);

        if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase))
        {
            query = query.Where(item => item.ResolvedAt == null);
        }

        var items = await query
            .OrderByDescending(item => item.LastWrongAt)
            .Take(ResolveLimit(filter.Limit))
            .Select(item => new WrongQuestionItem(
                item.QuestionReferenceId,
                new QuestionLocator(
                    item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
                    item.QuestionId),
                item.WrongCount,
                item.LastWrongAt,
                item.ResolvedAt))
            .ToArrayAsync(cancellationToken);

        return new LearningList<WrongQuestionItem>(items);
    }

    public async Task<LearningActionResult> ResolveWrongQuestionAsync(
        LearningActor actor,
        QuestionActionCommand command,
        CancellationToken cancellationToken = default)
    {
        var reference = await questionReferenceService.ResolveAsync(
            actor.TenantId,
            actor.UserId,
            command.Locator,
            cancellationToken);
        var item = await dbContext.WrongQuestions.FindAsync(
            [actor.TenantId, actor.UserId, reference.Id],
            cancellationToken);

        if (item is null)
        {
            throw new LearningResourceNotFoundException("wrong_question_not_found", "Wrong question was not found.");
        }

        item.ResolvedAt = DateTimeOffset.UtcNow;
        await dbContext.SaveChangesAsync(cancellationToken);
        return new LearningActionResult(true);
    }

    public async Task<WrongQuestionReviewPlan> GetWrongQuestionReviewPlanAsync(
        LearningActor actor,
        LearningLimitFilter filter,
        CancellationToken cancellationToken = default)
    {
        var items = await dbContext.WrongQuestions.AsNoTracking()
            .Where(item =>
                item.TenantId == actor.TenantId &&
                item.UserId == actor.UserId &&
                item.ResolvedAt == null)
            .OrderByDescending(item => item.WrongCount)
            .ThenBy(item => item.LastWrongAt)
            .Take(ResolveLimit(filter.Limit))
            .Select(item => new WrongQuestionReviewPlanItem(
                item.QuestionReferenceId,
                new QuestionLocator(
                    item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
                    item.QuestionId),
                item.WrongCount,
                item.LastWrongAt))
            .ToArrayAsync(cancellationToken);
        return new WrongQuestionReviewPlan(
            items,
            JsonSerializer.SerializeToElement(new
            {
                mode = "wrong_review",
                questionCount = items.Length,
                recommendedEndpoint = "/api/learning/practice-sessions"
            }));
    }

    public async Task<LearningList<WordProgressItem>> GetWordProgressAsync(
        LearningActor actor,
        LearningLimitFilter filter,
        CancellationToken cancellationToken = default)
    {
        var query = dbContext.UserWordProgress
            .AsNoTracking()
            .Where(item =>
                item.TenantId == actor.TenantId &&
                item.UserId == actor.UserId);

        if (filter.UnitId.HasValue)
        {
            query = query.Where(item => dbContext.VocabularyWords.Any(word =>
                word.TenantId == actor.TenantId &&
                word.Id == item.WordId &&
                word.UnitId == filter.UnitId.Value));
        }

        if (TryParseWordProgressStatus(filter.Status, out var status))
        {
            query = query.Where(item => item.Status == status);
        }

        var items = await query
            .OrderBy(item => item.NextReviewAt == null)
            .ThenBy(item => item.NextReviewAt)
            .ThenByDescending(item => item.UpdatedAt)
            .Take(ResolveLimit(filter.Limit))
            .Select(item => new WordProgressItem(
                item.WordId,
                item.Status,
                item.CorrectCount,
                item.WrongCount,
                item.LastReviewAt,
                item.NextReviewAt,
                item.ReviewCount,
                item.CorrectStreak,
                item.LastResult,
                item.DueLevel,
                item.Metadata))
            .ToArrayAsync(cancellationToken);

        return new LearningList<WordProgressItem>(items);
    }

    public async Task<WordProgressItem> UpdateWordProgressAsync(
        LearningActor actor,
        WordProgressCommand command,
        CancellationToken cancellationToken = default)
    {
        await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken);

        var now = DateTimeOffset.UtcNow;
        var item = await dbContext.UserWordProgress
            .SingleOrDefaultAsync(
                progress =>
                    progress.TenantId == actor.TenantId &&
                    progress.UserId == actor.UserId &&
                    progress.WordId == command.WordId,
                cancellationToken);

        if (item is null)
        {
            item = new UserWordProgress
            {
                TenantId = actor.TenantId,
                UserId = actor.UserId,
                WordId = command.WordId
            };
            dbContext.UserWordProgress.Add(item);
        }

        if (TryParseWordProgressStatus(command.Status, out var status))
        {
            item.Status = status;
        }
        else if (string.IsNullOrWhiteSpace(command.Status))
        {
            item.Status = WordProgressStatus.Learning;
        }
        else
        {
            throw new LearningValidationException("invalid_word_status", "Word progress status is invalid.");
        }

        var correctDelta = command.CorrectDelta ?? 0;
        var wrongDelta = command.WrongDelta ?? 0;
        item.CorrectCount += correctDelta;
        item.WrongCount += wrongDelta;
        item.ReviewCount += correctDelta + wrongDelta;
        item.CorrectStreak = wrongDelta > 0
            ? 0
            : item.CorrectStreak + correctDelta;
        item.LastResult = wrongDelta > 0
            ? WordReviewResult.Wrong
            : correctDelta > 0
                ? WordReviewResult.Correct
                : item.LastResult;
        item.LastReviewAt = correctDelta + wrongDelta > 0 ? now : item.LastReviewAt;
        item.NextReviewAt = command.NextReviewAt ?? item.NextReviewAt;
        item.DueLevel = item.Status == WordProgressStatus.Mastered
            ? WordDueLevel.Mastered
            : item.WrongCount > 0 && item.CorrectStreak == 0
                ? WordDueLevel.Again
                : item.DueLevel;

        await dbContext.SaveChangesAsync(cancellationToken);
        return ToItem(item);
    }

    public async Task<WordReviewPlan> GetWordReviewPlanAsync(
        LearningActor actor,
        LearningLimitFilter filter,
        CancellationToken cancellationToken = default)
    {
        var now = DateTimeOffset.UtcNow;
        var query = dbContext.UserWordProgress.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
        if (filter.UnitId.HasValue)
        {
            query = query.Where(item => dbContext.VocabularyWords.Any(word =>
                word.TenantId == actor.TenantId &&
                word.Id == item.WordId &&
                word.UnitId == filter.UnitId.Value));
        }

        var items = await query
            .Where(item => item.NextReviewAt == null || item.NextReviewAt <= now)
            .OrderBy(item => item.NextReviewAt == null)
            .ThenBy(item => item.NextReviewAt)
            .ThenByDescending(item => item.WrongCount)
            .Take(ResolveLimit(filter.Limit))
            .Select(item => new WordReviewPlanItem(
                item.WordId,
                item.Status,
                item.NextReviewAt,
                item.CorrectCount,
                item.WrongCount,
                item.DueLevel))
            .ToArrayAsync(cancellationToken);
        return new WordReviewPlan(
            items,
            JsonSerializer.SerializeToElement(new
            {
                mode = "word_review",
                wordCount = items.Length
            }));
    }

    public Task<WordProgressItem> ReviewWordAsync(
        LearningActor actor,
        WordReviewCommand command,
        CancellationToken cancellationToken = default)
    {
        var correct = string.Equals(command.Result, "correct", StringComparison.OrdinalIgnoreCase) ||
                      string.Equals(command.Result, "known", StringComparison.OrdinalIgnoreCase);
        return UpdateWordProgressAsync(
            actor,
            new WordProgressCommand(
                command.WordId,
                correct ? "Reviewing" : "Learning",
                correct ? 1 : 0,
                correct ? 0 : 1,
                command.NextReviewAt ?? DateTimeOffset.UtcNow.AddDays(correct ? 2 : 1)),
            cancellationToken);
    }

    public async Task<WordStatsItem> GetWordStatsAsync(
        LearningActor actor,
        LearningLimitFilter filter,
        CancellationToken cancellationToken = default)
    {
        var now = DateTimeOffset.UtcNow;
        var query = dbContext.UserWordProgress.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
        if (filter.UnitId.HasValue)
        {
            query = query.Where(item => dbContext.VocabularyWords.Any(word =>
                word.TenantId == actor.TenantId &&
                word.Id == item.WordId &&
                word.UnitId == filter.UnitId.Value));
        }

        return new WordStatsItem(
            await query.CountAsync(cancellationToken),
            await query.CountAsync(item => item.Status == WordProgressStatus.New, cancellationToken),
            await query.CountAsync(item => item.Status == WordProgressStatus.Learning, cancellationToken),
            await query.CountAsync(item => item.Status == WordProgressStatus.Reviewing, cancellationToken),
            await query.CountAsync(item => item.Status == WordProgressStatus.Mastered, cancellationToken),
            await query.CountAsync(item => item.NextReviewAt == null || item.NextReviewAt <= now, cancellationToken),
            await dbContext.UserWordFavorites.AsNoTracking().CountAsync(
                item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
                cancellationToken));
    }

    public async Task<LearningList<FavoriteWordItem>> GetFavoriteWordsAsync(
        LearningActor actor,
        LearningLimitFilter filter,
        CancellationToken cancellationToken = default)
    {
        var query = dbContext.UserWordFavorites
            .AsNoTracking()
            .Where(item =>
                item.TenantId == actor.TenantId &&
                item.UserId == actor.UserId);

        if (filter.UnitId.HasValue)
        {
            query = query.Where(item => dbContext.VocabularyWords.Any(word =>
                word.TenantId == actor.TenantId &&
                word.Id == item.WordId &&
                word.UnitId == filter.UnitId.Value));
        }

        var items = await query
            .OrderByDescending(item => item.FavoritedAt ?? item.CreatedAt)
            .Take(ResolveLimit(filter.Limit))
            .Select(item => new FavoriteWordItem(
                item.WordId,
                item.Note,
                item.FavoritedAt))
            .ToArrayAsync(cancellationToken);

        return new LearningList<FavoriteWordItem>(items);
    }

    public async Task<LearningActionResult> ToggleFavoriteWordAsync(
        LearningActor actor,
        FavoriteWordCommand command,
        CancellationToken cancellationToken = default)
    {
        await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken);

        var favorite = command.Favorite ?? true;
        var item = await dbContext.UserWordFavorites
            .SingleOrDefaultAsync(
                favoriteWord =>
                    favoriteWord.TenantId == actor.TenantId &&
                    favoriteWord.UserId == actor.UserId &&
                    favoriteWord.WordId == command.WordId,
                cancellationToken);

        if (favorite)
        {
            if (item is null)
            {
                item = new UserWordFavorite
                {
                    TenantId = actor.TenantId,
                    UserId = actor.UserId,
                    WordId = command.WordId
                };
                dbContext.UserWordFavorites.Add(item);
            }

            item.Note = command.Note ?? item.Note;
            item.FavoritedAt ??= DateTimeOffset.UtcNow;
        }
        else if (item is not null)
        {
            dbContext.UserWordFavorites.Remove(item);
        }

        await dbContext.SaveChangesAsync(cancellationToken);
        return new LearningActionResult(true, favorite);
    }

    public async Task<PracticeSessionItem> CreatePracticeSessionAsync(
        LearningActor actor,
        PracticeSessionCommand command,
        CancellationToken cancellationToken = default)
    {
        var assembly = await BuildPracticeAssemblyAsync(actor.TenantId, command, cancellationToken);
        var questionReferenceIds = await CollectQuestionReferenceIdsAsync(actor, assembly, cancellationToken);
        if (questionReferenceIds.Count == 0)
        {
            throw new LearningValidationException("no_practice_questions", "No published questions are available for this practice target.");
        }


        var containsPlatformQuestion = await dbContext.TenantQuestionReferences.AsNoTracking().AnyAsync(
            reference =>
                reference.TenantId == actor.TenantId &&
                questionReferenceIds.Contains(reference.Id) &&
                reference.Source == QuestionSource.Platform,
            cancellationToken);
        if (containsPlatformQuestion)
        {
            await publicQuestionAccessPolicy.EnsureCanStartAsync(actor.TenantId, cancellationToken);
        }

        var now = DateTimeOffset.UtcNow;
        var session = new PracticeSession
        {
            TenantId = actor.TenantId,
            UserId = actor.UserId,
            Mode = assembly.Mode,
            TargetType = assembly.TargetType,
            TargetId = assembly.TargetId,
            BlueprintId = assembly.BlueprintId,
            CollectionId = assembly.CollectionId,
            EntryId = assembly.EntryId,
            ContentNodeId = assembly.ContentNodeId,
            QuestionCount = questionReferenceIds.Count,
            DurationMinutes = assembly.DurationMinutes,
            TotalScore = assembly.TotalScore,
            ExpiresAt = assembly.DurationMinutes.HasValue
                ? now.AddMinutes(assembly.DurationMinutes.Value)
                : null,
            AccessMode = PracticeAccessMode.Free,
            ConsumedFreeQuota = questionReferenceIds.Count,
            AccessSnapshot = JsonSerializer.SerializeToElement(new
            {
                strategy = "v1_free",
                requestedCount = assembly.QuestionLimit,
                grantedCount = questionReferenceIds.Count
            }),
            Metadata = command.Metadata.ValueKind is JsonValueKind.Undefined
                ? JsonDefaults.Object()
                : command.Metadata
        };
        dbContext.PracticeSessions.Add(session);

        await dbContext.SaveChangesAsync(cancellationToken);
        var selections = await LoadQuestionSelectionsAsync(
            actor.TenantId,
            questionReferenceIds,
            cancellationToken);
        var scorePerQuestion = session.TotalScore.HasValue && selections.Count > 0
            ? session.TotalScore.Value / selections.Count
            : (decimal?)null;
        dbContext.PracticeSessionQuestions.AddRange(selections.Select((selection, index) =>
            new PracticeSessionQuestion
            {
                TenantId = actor.TenantId,
                PracticeSessionId = session.Id,
                QuestionReferenceId = selection.QuestionReferenceId,
                QuestionOwnerTenantId = selection.QuestionOwnerTenantId,
                QuestionId = selection.QuestionId,
                QuestionVersionId = selection.QuestionVersionId,
                Position = index,
                Score = scorePerQuestion
            }));
        dbContext.PracticeAccessEvents.Add(new PracticeAccessEvent
        {
            TenantId = actor.TenantId,
            UserId = actor.UserId,
            PracticeSessionId = session.Id,
            EventType = PracticeAccessEventType.SessionCreated,
            AccessMode = PracticeAccessEventMode.Free,
            RequestedCount = assembly.QuestionLimit,
            GrantedCount = selections.Count,
            ConsumedFreeQuota = selections.Count,
            Metadata = session.AccessSnapshot
        });

        await dbContext.SaveChangesAsync(cancellationToken);
        return ToItem(session);
    }

    public async Task<PracticeSessionDetailItem> GetPracticeSessionDetailAsync(
        LearningActor actor,
        PracticeSessionFilter filter,
        CancellationToken cancellationToken = default)
    {
        var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken);
        var orderedQuestions = await LoadSessionQuestionItemsAsync(
            actor.TenantId,
            session.Id,
            cancellationToken);

        var answers = await dbContext.AnswerRecords
            .AsNoTracking()
            .Where(answer =>
                answer.TenantId == actor.TenantId &&
                answer.UserId == actor.UserId &&
                answer.PracticeSessionId == session.Id)
            .ToArrayAsync(cancellationToken);
        var answersByQuestion = answers
            .GroupBy(answer => answer.SessionQuestionId)
            .ToDictionary(
                group => group.Key,
                group => ToItem(group.OrderByDescending(answer => answer.AnsweredAt).First()));

        return new PracticeSessionDetailItem(ToItem(session), orderedQuestions, answersByQuestion);
    }

    public async Task<PracticeSessionReportItem> SubmitPracticeSessionAsync(
        LearningActor actor,
        PracticeSessionFilter filter,
        CancellationToken cancellationToken = default)
    {
        var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken);
        var existing = await dbContext.PracticeSessionReports
            .AsNoTracking()
            .SingleOrDefaultAsync(
                report =>
                    report.TenantId == actor.TenantId &&
                    report.PracticeSessionId == session.Id,
                cancellationToken);
        if (existing is not null)
        {
            return ToItem(existing);
        }

        var report = await BuildPracticeSessionReportAsync(actor, session, cancellationToken);
        session.FinishedAt ??= report.SubmittedAt;
        await dbContext.SaveChangesAsync(cancellationToken);
        return ToItem(report);
    }

    public async Task<PracticeSessionReportItem> GetPracticeSessionReportAsync(
        LearningActor actor,
        PracticeSessionFilter filter,
        CancellationToken cancellationToken = default)
    {
        if (!filter.PracticeSessionId.HasValue)
        {
            throw new LearningValidationException("practice_session_id_required", "Practice session id is required.");
        }

        var report = await dbContext.PracticeSessionReports
            .AsNoTracking()
            .SingleOrDefaultAsync(
                item =>
                    item.TenantId == actor.TenantId &&
                    item.UserId == actor.UserId &&
                    item.PracticeSessionId == filter.PracticeSessionId.Value,
                cancellationToken);

        if (report is null)
        {
            throw new LearningResourceNotFoundException("practice_report_not_found", "Practice session report was not found.");
        }

        return ToItem(report);
    }

    public async Task<LearningList<PracticeSessionReportItem>> GetPracticeReportsAsync(
        LearningActor actor,
        PracticeSessionFilter filter,
        CancellationToken cancellationToken = default)
    {
        var query = dbContext.PracticeSessionReports
            .AsNoTracking()
            .Where(report =>
                report.TenantId == actor.TenantId &&
                report.UserId == actor.UserId);

        if (filter.BlueprintId.HasValue)
        {
            query = query.Where(report => report.BlueprintId == filter.BlueprintId.Value);
        }

        if (!string.IsNullOrWhiteSpace(filter.Mode))
        {
            query = query.Where(report => report.Mode == filter.Mode.Trim());
        }

        var items = await query
            .OrderByDescending(report => report.SubmittedAt)
            .Take(ResolveLimit(filter.Limit))
            .ToArrayAsync(cancellationToken);

        return new LearningList<PracticeSessionReportItem>(items.Select(ToItem).ToArray());
    }

    public async Task<LearningList<PracticeHistoryItem>> GetPracticeHistoryAsync(
        LearningActor actor,
        PracticeSessionFilter filter,
        CancellationToken cancellationToken = default)
    {
        var query = dbContext.PracticeSessions
            .AsNoTracking()
            .Where(session =>
                session.TenantId == actor.TenantId &&
                session.UserId == actor.UserId);

        if (!string.IsNullOrWhiteSpace(filter.Mode))
        {
            query = query.Where(session => session.Mode == filter.Mode.Trim());
        }

        var rows = await query
            .GroupJoin(
                dbContext.PracticeSessionReports.AsNoTracking(),
                session => new { session.TenantId, PracticeSessionId = session.Id },
                report => new { report.TenantId, report.PracticeSessionId },
                (session, reports) => new { session, report = reports.FirstOrDefault() })
            .OrderByDescending(row => row.session.FinishedAt ?? row.session.StartedAt)
            .Take(ResolveLimit(filter.Limit))
            .ToArrayAsync(cancellationToken);
        var now = DateTimeOffset.UtcNow;
        var items = rows
            .Select(row => new PracticeHistoryItem(
                row.session.Id,
                row.session.Mode,
                row.session.TargetType,
                row.session.TargetId,
                row.session.BlueprintId,
                row.session.CollectionId,
                row.session.EntryId,
                row.session.ContentNodeId,
                row.session.QuestionCount,
                row.report?.AnsweredCount ?? 0,
                row.report?.CorrectCount ?? 0,
                row.report?.WrongCount ?? 0,
                row.session.StartedAt,
                row.session.FinishedAt,
                row.session.ExpiresAt,
                PracticeSessionStatus(row.session, now),
                row.report?.Id,
                row.report?.Score,
                row.report?.TotalScore,
                row.report?.Accuracy))
            .Where(item => string.IsNullOrWhiteSpace(filter.Status) || item.Status == filter.Status.Trim())
            .ToArray();

        return new LearningList<PracticeHistoryItem>(items);
    }

    private async Task<PracticeAssembly> BuildPracticeAssemblyAsync(
        Guid tenantId,
        PracticeSessionCommand command,
        CancellationToken cancellationToken)
    {
        var mode = NormalizeMode(command.Mode);
        var assembly = new PracticeAssembly(
            mode,
            command.TargetType,
            command.TargetId,
            command.BlueprintId,
            command.CollectionId,
            command.EntryId,
            command.ContentNodeId,
            Math.Clamp(command.QuestionLimit ?? 100, 1, MaxLimit),
            command.DurationMinutes,
            command.TotalScore);

        if (!command.BlueprintId.HasValue)
        {
            return assembly;
        }

        var blueprint = await dbContext.PracticeBlueprints
            .AsNoTracking()
            .SingleOrDefaultAsync(
                item =>
                    item.TenantId == tenantId &&
                    item.Id == command.BlueprintId.Value &&
                    item.Status == ContentStatus.Active,
                cancellationToken);

        if (blueprint is null)
        {
            throw new LearningResourceNotFoundException("practice_blueprint_not_found", "Practice blueprint was not found.");
        }

        return assembly with
        {
            Mode = NormalizeMode(blueprint.Mode.ToString()),
            TargetType = command.TargetType ?? "blueprint",
            TargetId = command.TargetId ?? blueprint.Id,
            CollectionId = command.CollectionId ?? blueprint.CollectionId,
            EntryId = command.EntryId ?? blueprint.EntryId,
            ContentNodeId = command.ContentNodeId ?? blueprint.NodeId,
            QuestionLimit = Math.Clamp(command.QuestionLimit ?? blueprint.QuestionLimit ?? 100, 1, MaxLimit),
            DurationMinutes = command.DurationMinutes ?? blueprint.DurationMinutes,
            TotalScore = command.TotalScore ?? blueprint.TotalScore
        };
    }

    private async Task<List<Guid>> CollectQuestionReferenceIdsAsync(
        LearningActor actor,
        PracticeAssembly assembly,
        CancellationToken cancellationToken)
    {
        if (assembly.Mode == "wrong_review")
        {
            return await dbContext.WrongQuestions
                .AsNoTracking()
                .Where(item =>
                    item.TenantId == actor.TenantId &&
                    item.UserId == actor.UserId &&
                    item.ResolvedAt == null)
                .OrderByDescending(item => item.WrongCount)
                .ThenBy(item => item.LastWrongAt)
                .Take(assembly.QuestionLimit)
                .Select(item => item.QuestionReferenceId)
                .ToListAsync(cancellationToken);
        }

        if (assembly.Mode == "favorite_review")
        {
            return await dbContext.FavoriteQuestions
                .AsNoTracking()
                .Where(item =>
                    item.TenantId == actor.TenantId &&
                    item.UserId == actor.UserId)
                .OrderByDescending(item => item.CreatedAt)
                .Take(assembly.QuestionLimit)
                .Select(item => item.QuestionReferenceId)
                .ToListAsync(cancellationToken);
        }

        if (assembly.CollectionId.HasValue)
        {
            return await dbContext.QuestionCollectionItems
                .AsNoTracking()
                .Where(item =>
                    item.TenantId == actor.TenantId &&
                    item.CollectionId == assembly.CollectionId.Value)
                .OrderBy(item => item.SortOrder)
                .Take(assembly.QuestionLimit)
                .Select(item => item.QuestionReferenceId)
                .ToListAsync(cancellationToken);
        }

        var query = dbContext.Questions
            .AsNoTracking()
            .Where(question =>
                question.TenantId == actor.TenantId &&
                question.Status == QuestionStatus.Published);

        if (assembly.ContentNodeId.HasValue)
        {
            query = query.Where(question => question.ContentNodeId == assembly.ContentNodeId.Value);
        }
        else if (assembly.EntryId.HasValue)
        {
            query = query.Where(question => question.EntryId == assembly.EntryId.Value);
        }
        else if (assembly.TargetId.HasValue && !string.IsNullOrWhiteSpace(assembly.TargetType))
        {
            query = ApplyLegacyTargetFilter(query, assembly.TargetType, assembly.TargetId.Value);
        }
        else
        {
            throw new LearningValidationException("practice_target_required", "Practice target is required.");
        }

        var questionIds = await query
            .OrderBy(question => question.CreatedAt)
            .Take(assembly.QuestionLimit)
            .Select(question => question.Id)
            .ToListAsync(cancellationToken);
        var referenceIds = new List<Guid>(questionIds.Count);
        foreach (var questionId in questionIds)
        {
            var reference = await questionReferenceService.ResolveAsync(
                actor.TenantId,
                actor.UserId,
                new QuestionLocator(QuestionSource.Tenant, questionId),
                cancellationToken);
            referenceIds.Add(reference.Id);
        }

        return referenceIds;
    }

    private static IQueryable<Question> ApplyLegacyTargetFilter(
        IQueryable<Question> query,
        string? targetType,
        Guid targetId)
    {
        return NormalizeEnumValue(targetType) switch
        {
            "subject" => query.Where(question => question.SubjectId == targetId),
            "category" => query.Where(question => question.CategoryId == targetId),
            "node" => query.Where(question => question.NodeId == targetId),
            "questionbank" => query.Where(question => question.QuestionBankId == targetId),
            "contentnode" => query.Where(question => question.ContentNodeId == targetId),
            "entry" => query.Where(question => question.EntryId == targetId),
            _ => query.Where(_ => false)
        };
    }

    private async Task<IReadOnlyList<QuestionSelection>> LoadQuestionSelectionsAsync(
        Guid tenantId,
        IReadOnlyCollection<Guid> questionReferenceIds,
        CancellationToken cancellationToken)
    {
        var rows = await tenantExecutionScope.ExecuteAsync(
            new SystemScopeRequest(
                tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService),
                "Lock published question versions for a new practice session", Guid.NewGuid().ToString("N")),
            async (provider, token) =>
            {
                var systemDbContext = provider.GetRequiredService<TikuDbContext>();
                return await (
                    from reference in systemDbContext.TenantQuestionReferences.AsNoTracking()
                    join question in systemDbContext.Questions.AsNoTracking()
                        on new { TenantId = reference.QuestionOwnerTenantId, Id = reference.QuestionId }
                        equals new { question.TenantId, question.Id }
                    join version in systemDbContext.QuestionVersions.AsNoTracking()
                        on new
                        {
                            TenantId = reference.QuestionOwnerTenantId,
                            reference.QuestionId,
                            Id = question.CurrentVersionId
                        }
                        equals new
                        {
                            version.TenantId,
                            version.QuestionId,
                            Id = (Guid?)version.Id
                        }
                    where reference.TenantId == tenantId &&
                          questionReferenceIds.Contains(reference.Id) &&
                          question.Status == QuestionStatus.Published
                    select new QuestionSelection(
                        reference.Id,
                        reference.QuestionOwnerTenantId,
                        reference.QuestionId,
                        version.Id))
                    .ToArrayAsync(token);
            },
            cancellationToken);

        var byReference = rows.ToDictionary(row => row.QuestionReferenceId);
        if (byReference.Count != questionReferenceIds.Distinct().Count())
        {
            throw new LearningValidationException(
                "practice_question_unavailable",
                "One or more practice questions have no published version.");
        }

        return questionReferenceIds.Select(referenceId => byReference[referenceId]).ToArray();
    }

    private Task<PracticeSessionQuestionItem[]> LoadSessionQuestionItemsAsync(
        Guid tenantId,
        Guid practiceSessionId,
        CancellationToken cancellationToken)
    {
        return tenantExecutionScope.ExecuteAsync(
            new SystemScopeRequest(
                tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService),
                "Read locked question versions for a tenant practice session", Guid.NewGuid().ToString("N")),
            async (provider, token) =>
            {
                var systemDbContext = provider.GetRequiredService<TikuDbContext>();
                return await (
                    from sessionQuestion in systemDbContext.PracticeSessionQuestions.AsNoTracking()
                    join question in systemDbContext.Questions.AsNoTracking()
                        on new
                        {
                            TenantId = sessionQuestion.QuestionOwnerTenantId,
                            Id = sessionQuestion.QuestionId
                        }
                        equals new { question.TenantId, question.Id }
                    join version in systemDbContext.QuestionVersions.AsNoTracking()
                        on new
                        {
                            TenantId = sessionQuestion.QuestionOwnerTenantId,
                            sessionQuestion.QuestionId,
                            Id = sessionQuestion.QuestionVersionId
                        }
                        equals new { version.TenantId, version.QuestionId, version.Id }
                    where sessionQuestion.TenantId == tenantId &&
                          sessionQuestion.PracticeSessionId == practiceSessionId
                    orderby sessionQuestion.Position
                    select new PracticeSessionQuestionItem(
                        sessionQuestion.Id,
                        sessionQuestion.QuestionReferenceId,
                        new QuestionLocator(
                            sessionQuestion.QuestionOwnerTenantId == tenantId
                                ? QuestionSource.Tenant
                                : QuestionSource.Platform,
                            sessionQuestion.QuestionId),
                        question.Id,
                        question.Type,
                        question.TypeLabel,
                        question.Difficulty,
                        question.Tags,
                        version.Id,
                        version.Content,
                        version.Options,
                        version.Explanation))
                    .ToArrayAsync(token);
            },
            cancellationToken);
    }

    private async Task<PracticeSession> GetPracticeSessionAsync(
        LearningActor actor,
        Guid? practiceSessionId,
        CancellationToken cancellationToken)
    {
        if (!practiceSessionId.HasValue)
        {
            throw new LearningValidationException("practice_session_id_required", "Practice session id is required.");
        }

        var session = await dbContext.PracticeSessions
            .SingleOrDefaultAsync(
                item =>
                    item.TenantId == actor.TenantId &&
                    item.UserId == actor.UserId &&
                    item.Id == practiceSessionId.Value,
                cancellationToken);

        if (session is null)
        {
            throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found.");
        }

        return session;
    }

    private async Task<PracticeSessionReport> BuildPracticeSessionReportAsync(
        LearningActor actor,
        PracticeSession session,
        CancellationToken cancellationToken)
    {
        var sessionQuestions = await dbContext.PracticeSessionQuestions.AsNoTracking()
            .Where(item =>
                item.TenantId == actor.TenantId &&
                item.PracticeSessionId == session.Id)
            .OrderBy(item => item.Position)
            .ToArrayAsync(cancellationToken);
        if (sessionQuestions.Length == 0)
        {
            throw new LearningValidationException("practice_session_empty", "Practice session has no question snapshot.");
        }

        var answers = await dbContext.AnswerRecords
            .AsNoTracking()
            .Where(answer =>
                answer.TenantId == actor.TenantId &&
                answer.UserId == actor.UserId &&
                answer.PracticeSessionId == session.Id)
            .ToArrayAsync(cancellationToken);
        var latestAnswers = answers
            .GroupBy(answer => answer.SessionQuestionId)
            .ToDictionary(
                group => group.Key,
                group => group.OrderByDescending(answer => answer.AnsweredAt).First());
        var totalQuestions = sessionQuestions.Length;
        var answeredCount = sessionQuestions.Count(question => latestAnswers.ContainsKey(question.Id));
        var correctCount = sessionQuestions.Count(question =>
            latestAnswers.TryGetValue(question.Id, out var answer) &&
            answer.IsCorrect == true);
        var wrongCount = sessionQuestions.Count(question =>
            latestAnswers.TryGetValue(question.Id, out var answer) &&
            answer.IsCorrect != true);
        var unansweredCount = Math.Max(0, totalQuestions - answeredCount);
        var totalScore = session.TotalScore ?? totalQuestions;
        var scorePerQuestion = totalQuestions == 0 ? 0 : totalScore / totalQuestions;
        var score = Math.Round(correctCount * scorePerQuestion, 2);
        var accuracy = totalQuestions == 0 ? 0 : Math.Round((decimal)correctCount / totalQuestions, 4);
        var submittedAt = DateTimeOffset.UtcNow;
        var durationSeconds = Math.Max(0, (int)(submittedAt - session.StartedAt).TotalSeconds);
        var wrongQuestionIds = sessionQuestions
            .Where(question =>
                latestAnswers.TryGetValue(question.Id, out var answer) &&
                answer.IsCorrect != true)
            .Select(question => question.QuestionReferenceId)
            .ToArray();
        var questionResults = sessionQuestions
            .Select(question =>
            {
                latestAnswers.TryGetValue(question.Id, out var answer);
                return new
                {
                    sessionQuestionId = question.Id,
                    questionReferenceId = question.QuestionReferenceId,
                    questionId = question.QuestionId,
                    source = question.QuestionOwnerTenantId == actor.TenantId ? "tenant" : "platform",
                    answered = answer is not null,
                    isCorrect = answer?.IsCorrect,
                    score = answer?.IsCorrect == true ? scorePerQuestion : 0,
                    totalScore = scorePerQuestion,
                    answeredAt = answer?.AnsweredAt
                };
            })
            .ToArray();
        var sectionStats = new[]
        {
            new
            {
                key = "default",
                title = "默认",
                questionCount = totalQuestions,
                answeredCount,
                correctCount,
                wrongCount,
                unansweredCount,
                score,
                totalScore,
                accuracy,
                sortOrder = 0
            }
        };

        var report = new PracticeSessionReport
        {
            TenantId = actor.TenantId,
            UserId = actor.UserId,
            PracticeSessionId = session.Id,
            BlueprintId = session.BlueprintId,
            CollectionId = session.CollectionId,
            Mode = session.Mode,
            TotalQuestions = totalQuestions,
            AnsweredCount = answeredCount,
            CorrectCount = correctCount,
            WrongCount = wrongCount,
            UnansweredCount = unansweredCount,
            Score = score,
            TotalScore = totalScore,
            Accuracy = accuracy,
            DurationSeconds = durationSeconds,
            StartedAt = session.StartedAt,
            SubmittedAt = submittedAt,
            SectionStats = JsonSerializer.SerializeToElement(sectionStats),
            QuestionResults = JsonSerializer.SerializeToElement(questionResults),
            WrongQuestionIds = JsonSerializer.SerializeToElement(wrongQuestionIds),
            Metadata = JsonSerializer.SerializeToElement(new
            {
                scoringVersion = 1,
                scorePerQuestion
            })
        };
        dbContext.PracticeSessionReports.Add(report);
        dbContext.PracticeSessionReportSections.Add(new PracticeSessionReportSection
        {
            TenantId = actor.TenantId,
            ReportId = report.Id,
            PracticeSessionId = session.Id,
            SectionKey = "default",
            SectionName = "默认",
            QuestionCount = totalQuestions,
            AnsweredCount = answeredCount,
            CorrectCount = correctCount,
            WrongCount = wrongCount,
            UnansweredCount = unansweredCount,
            Score = score,
            TotalScore = totalScore,
            Accuracy = accuracy,
            SortOrder = 0
        });

        return report;
    }

    private async Task EnsureQuestionExistsAsync(
        Guid tenantId,
        Guid questionId,
        CancellationToken cancellationToken)
    {
        var exists = await dbContext.Questions.AnyAsync(
            question =>
                question.TenantId == tenantId &&
                question.Id == questionId &&
                question.Status == QuestionStatus.Published,
            cancellationToken);

        if (!exists)
        {
            throw new LearningResourceNotFoundException("question_not_found", "Question was not found.");
        }
    }

    private async Task EnsureWordExistsAsync(
        Guid tenantId,
        Guid wordId,
        CancellationToken cancellationToken)
    {
        var exists = await dbContext.VocabularyWords.AnyAsync(
            word =>
                word.TenantId == tenantId &&
                word.Id == wordId &&
                word.IsActive,
            cancellationToken);

        if (!exists)
        {
            throw new LearningResourceNotFoundException("word_not_found", "Word was not found.");
        }
    }

    private static AnswerRecordItem ToItem(AnswerRecord record)
    {
        return new AnswerRecordItem(
            record.Id,
            record.SessionQuestionId,
            record.PracticeSessionId,
            record.SelectedOptions,
            record.AnswerText,
            record.IsCorrect,
            record.AnsweredAt);
    }

    private static WordProgressItem ToItem(UserWordProgress item)
    {
        return new WordProgressItem(
            item.WordId,
            item.Status,
            item.CorrectCount,
            item.WrongCount,
            item.LastReviewAt,
            item.NextReviewAt,
            item.ReviewCount,
            item.CorrectStreak,
            item.LastResult,
            item.DueLevel,
            item.Metadata);
    }

    private static PracticeSessionItem ToItem(PracticeSession item)
    {
        return new PracticeSessionItem(
            item.Id,
            item.Mode,
            item.TargetType,
            item.TargetId,
            item.BlueprintId,
            item.CollectionId,
            item.EntryId,
            item.ContentNodeId,
            item.QuestionCount,
            item.DurationMinutes,
            item.TotalScore,
            item.AccessMode,
            item.AccessEntitlementId,
            item.ConsumedFreeQuota,
            item.AccessSnapshot,
            item.StartedAt,
            item.FinishedAt,
            item.ExpiresAt,
            item.Metadata,
            PracticeSessionStatus(item, DateTimeOffset.UtcNow));
    }

    private static PracticeSessionReportItem ToItem(PracticeSessionReport item)
    {
        return new PracticeSessionReportItem(
            item.Id,
            item.PracticeSessionId,
            item.BlueprintId,
            item.CollectionId,
            item.Mode,
            item.TotalQuestions,
            item.AnsweredCount,
            item.CorrectCount,
            item.WrongCount,
            item.UnansweredCount,
            item.Score,
            item.TotalScore,
            item.Accuracy,
            item.DurationSeconds,
            item.StartedAt,
            item.SubmittedAt,
            item.SectionStats,
            item.QuestionResults,
            item.WrongQuestionIds,
            item.Metadata);
    }

    private static List<Guid> ReadGuidArray(JsonElement value)
    {
        if (value.ValueKind is not JsonValueKind.Array)
        {
            return [];
        }

        return value.EnumerateArray()
            .Select(item => item.ValueKind == JsonValueKind.String && Guid.TryParse(item.GetString(), out var id)
                ? (Guid?)id
                : null)
            .Where(id => id.HasValue)
            .Select(id => id!.Value)
            .ToList();
    }

    private static string PracticeSessionStatus(PracticeSession session, DateTimeOffset now)
    {
        if (session.FinishedAt.HasValue)
        {
            return "finished";
        }

        if (session.ExpiresAt.HasValue && session.ExpiresAt.Value <= now)
        {
            return "expired";
        }

        return "active";
    }

    private static string NormalizeMode(string? mode)
    {
        return NormalizeEnumValue(mode) switch
        {
            "sequential" => "sequential",
            "random" => "random",
            "mockexam" => "mock_exam",
            "paper" => "paper",
            "wrongreview" => "wrong_review",
            "favoritereview" => "favorite_review",
            _ => "chapter"
        };
    }

    private static bool TryParseWordProgressStatus(string? value, out WordProgressStatus status)
    {
        return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out status);
    }

    private static int ResolveLimit(int? limit)
    {
        return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit);
    }

    private static string? NormalizeEnumValue(string? value)
    {
        return string.IsNullOrWhiteSpace(value)
            ? null
            : value.Replace("_", string.Empty, StringComparison.Ordinal)
                .Replace("-", string.Empty, StringComparison.Ordinal);
    }

    private sealed record PracticeAssembly(
        string Mode,
        string? TargetType,
        Guid? TargetId,
        Guid? BlueprintId,
        Guid? CollectionId,
        Guid? EntryId,
        Guid? ContentNodeId,
        int QuestionLimit,
        int? DurationMinutes,
        decimal? TotalScore);

    private sealed record QuestionSelection(
        Guid QuestionReferenceId,
        Guid QuestionOwnerTenantId,
        Guid QuestionId,
        Guid QuestionVersionId);
}

public class LearningException(string code, string message) : Exception(message)
{
    public string Code { get; } = code;
}

public sealed class LearningResourceNotFoundException(string code, string message) : LearningException(code, message);

public sealed class LearningValidationException(string code, string message) : LearningException(code, message);
