using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using Tiku.Application.Learning;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Learning;

namespace Tiku.Infrastructure.Learning;

public sealed partial class LearningActivityService
{
    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);

        var selections = await LoadQuestionSelectionsAsync(
            actor.TenantId,
            questionReferenceIds,
            cancellationToken);
        foreach (var selection in selections)
            if (!QuestionGrader.HasValidAuthoritativeAnswer(
                    selection.QuestionType,
                    selection.CorrectOptionIndex,
                    selection.CorrectOptionIndices,
                    selection.AnswerText))
                throw new LearningValidationException(
                    "practice_question_grading_rule_invalid",
                    $"Question '{selection.QuestionId}' has no valid authoritative grading rule.");

        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,
                QuestionType = selection.QuestionType,
                TypeLabelSnapshot = selection.TypeLabel,
                DifficultySnapshot = selection.Difficulty,
                TagsSnapshot = selection.Tags,
                ContentSnapshot = selection.Content,
                OptionsSnapshot = selection.Options,
                CorrectOptionIndexSnapshot = selection.CorrectOptionIndex,
                CorrectOptionIndicesSnapshot = selection.CorrectOptionIndices,
                AnswerTextSnapshot = selection.AnswerText,
                ExplanationSnapshot = selection.Explanation,
                GradingRulesSnapshot = BuildGradingRules(selection),
                SnapshotVersion = 1
            }));
        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 &&
                answer.IsCurrent)
            .ToArrayAsync(cancellationToken);
        var answersByQuestion = answers
            .GroupBy(answer => answer.SessionQuestionId)
            .ToDictionary(
                group => group.Key,
                group => ToItem(
                    group.OrderByDescending(answer => answer.Revision).First(),
                    session.Version));

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

    public async Task<PracticeSessionReportItem> SubmitPracticeSessionAsync(
        LearningActor actor,
        SubmitPracticeSessionCommand command,
        CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrWhiteSpace(command.IdempotencyKey))
            throw new LearningValidationException("idempotency_key_required", "An idempotency key is required.");

        await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
        var session = await GetPracticeSessionAsync(actor, command.PracticeSessionId, cancellationToken);
        var requestHash = HashSubmission(command);
        var existingOperation = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(
            item =>
                item.TenantId == actor.TenantId &&
                item.UserId == actor.UserId &&
                item.PracticeSessionId == session.Id &&
                item.OperationType == "submit" &&
                item.IdempotencyKey == command.IdempotencyKey, cancellationToken);
        if (existingOperation is not null)
        {
            if (!string.Equals(existingOperation.RequestHash, requestHash, StringComparison.Ordinal))
            {
                SubmissionConflicts.Add(1);
                throw new LearningValidationException("idempotency_conflict",
                    "The idempotency key was used with a different request.");
            }

            IdempotencyReplays.Add(1);
            return existingOperation.ResponseSnapshot.Deserialize<PracticeSessionReportItem>()
                   ?? throw new InvalidOperationException("The stored report response is invalid.");
        }

        if (session.Status != PracticeSessionStatus.Active)
            throw new LearningValidationException("practice_session_not_active",
                "Only an active practice session can be submitted.");
        if (session.ExpiresAt.HasValue && session.ExpiresAt <= DateTimeOffset.UtcNow)
        {
            session.Status = PracticeSessionStatus.Expired;
            session.Version++;
            await dbContext.SaveChangesAsync(cancellationToken);
            await transaction.CommitAsync(cancellationToken);
            throw new LearningValidationException("practice_session_expired", "The practice session has expired.");
        }

        if (session.Version != command.ExpectedSessionVersion)
            throw new LearningValidationException("practice_session_version_conflict",
                "The practice session changed. Reload it before submitting.");

        session.Status = PracticeSessionStatus.Scoring;
        session.Version++;
        var report = await BuildPracticeSessionReportAsync(actor, session, cancellationToken);
        session.Status = report.IsFinal ? PracticeSessionStatus.Submitted : PracticeSessionStatus.PendingReview;
        session.FinishedAt = report.SubmittedAt;
        session.Version++;
        var response = ToItem(report);
        dbContext.LearningOperationIdempotencies.Add(new LearningOperationIdempotency
        {
            TenantId = actor.TenantId,
            UserId = actor.UserId,
            PracticeSessionId = session.Id,
            OperationType = "submit",
            IdempotencyKey = command.IdempotencyKey.Trim(),
            RequestHash = requestHash,
            ResponseSnapshot = JsonSerializer.SerializeToElement(response),
            CompletedAt = report.SubmittedAt
        });
        try
        {
            await dbContext.SaveChangesAsync(cancellationToken);
            await transaction.CommitAsync(cancellationToken);
        }
        catch (DbUpdateConcurrencyException)
        {
            SubmissionConflicts.Add(1);
            throw new LearningValidationException("practice_session_version_conflict",
                "The practice session changed during submission.");
        }
        catch (DbUpdateException exception) when (exception.InnerException is NpgsqlException)
        {
            SubmissionConflicts.Add(1);
            throw new LearningValidationException("practice_submission_conflict",
                "The practice session was already submitted by another request.");
        }

        return response;
    }

    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,
                ResolvePracticeSessionHistoryStatus(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);
    }
}