using System.Text.Json;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;

namespace DysonNetwork.Sphere.Poll;

public class PollService(AppDatabase db, ICacheService cache)
{
    public void ValidatePoll(SnPoll poll)
    {
        if (poll.Questions.Count == 0)
            throw new Exception("Poll must have at least one question");
        foreach (var question in poll.Questions)
        {
            switch (question.Type)
            {
                case PollQuestionType.SingleChoice:
                case PollQuestionType.MultipleChoice:
                    if (question.Options is null)
                        throw new Exception("Poll question must have options");
                    if (question.Options.Count <= 1)
                        throw new Exception("Poll question must have at least two options");
                    break;
                case PollQuestionType.YesNo:
                case PollQuestionType.Rating:
                case PollQuestionType.FreeText:
                default:
                    continue;
            }
        }
    }

    public async Task<SnPoll?> GetPoll(Guid id)
    {
        var poll = await db.Polls
            .Where(e => e.Id == id)
            .Include(e => e.Questions)
            .FirstOrDefaultAsync();
        return poll;
    }

    private const string PollAnswerCachePrefix = "poll:answer:";

    public async Task<SnPollAnswer?> GetPollAnswer(Guid pollId, Guid accountId)
    {
        var cacheKey = $"{PollAnswerCachePrefix}{pollId}:{accountId}";
        var cachedAnswer = await cache.GetAsync<SnPollAnswer?>(cacheKey);
        if (cachedAnswer is not null)
            return cachedAnswer;

        var answer = await db.PollAnswers
            .Where(e => e.PollId == pollId && e.AccountId == accountId)
            .AsNoTracking()
            .FirstOrDefaultAsync();
        if (answer is not null)
            answer.Poll = null;

        // Set the answer even it is null, which stands for unanswered
        await cache.SetAsync(cacheKey, answer, TimeSpan.FromMinutes(30));

        return answer;
    }

    private async Task ValidatePollAnswer(Guid pollId, Dictionary<string, JsonElement> answer)
    {
        var questions = await db.PollQuestions
            .Where(e => e.PollId == pollId)
            .ToListAsync();
        if (questions is null)
            throw new Exception("Poll has no questions");

        foreach (var question in questions)
        {
            var questionId = question.Id.ToString();
            if (question.IsRequired && !answer.ContainsKey(questionId))
                throw new Exception($"Missing required field: {question.Title}");
            if (!answer.ContainsKey(questionId))
                continue;
            switch (question.Type)
            {
                case PollQuestionType.Rating when answer[questionId].ValueKind != JsonValueKind.Number:
                    throw new Exception($"Answer for question {question.Title} expected to be a number");
                case PollQuestionType.FreeText when answer[questionId].ValueKind != JsonValueKind.String:
                    throw new Exception($"Answer for question {question.Title} expected to be a string");
                case PollQuestionType.SingleChoice when question.Options is not null:
                    if (answer[questionId].ValueKind != JsonValueKind.String)
                        throw new Exception($"Answer for question {question.Title} expected to be a string");
                    if (question.Options.All(e => e.Id.ToString() != answer[questionId].GetString()))
                        throw new Exception($"Answer for question {question.Title} is invalid");
                    break;
                case PollQuestionType.MultipleChoice when question.Options is not null:
                    if (answer[questionId].ValueKind != JsonValueKind.Array)
                        throw new Exception($"Answer for question {question.Title} expected to be an array");
                    if (answer[questionId].EnumerateArray().Any(option =>
                            question.Options.All(e => e.Id.ToString() != option.GetString())))
                        throw new Exception($"Answer for question {question.Title} is invalid");
                    break;
                case PollQuestionType.YesNo when answer[questionId].ValueKind != JsonValueKind.True &&
                                                 answer[questionId].ValueKind != JsonValueKind.False:
                    throw new Exception($"Answer for question {question.Title} expected to be a boolean");
            }
        }
    }

    public async Task<SnPollAnswer> AnswerPoll(Guid pollId, Guid accountId, Dictionary<string, JsonElement> answer)
    {
        // Validation
        var poll = await db.Polls
            .Where(e => e.Id == pollId)
            .FirstOrDefaultAsync();
        if (poll is null)
            throw new Exception("Poll not found");
        if (poll.EndedAt < SystemClock.Instance.GetCurrentInstant())
            throw new Exception("Poll has ended");

        await ValidatePollAnswer(pollId, answer);

        // Remove the existing answer
        var existingAnswer = await db.PollAnswers
            .Where(e => e.PollId == pollId && e.AccountId == accountId)
            .FirstOrDefaultAsync();
        if (existingAnswer is not null)
            await UnAnswerPoll(pollId, accountId);

        // Save the new answer
        var answerRecord = new SnPollAnswer
        {
            PollId = pollId,
            AccountId = accountId,
            Answer = answer
        };
        await db.PollAnswers.AddAsync(answerRecord);
        await db.SaveChangesAsync();

        // Update cache for this poll answer and invalidate stats cache
        var answerCacheKey = $"poll:answer:{pollId}:{accountId}";
        await cache.SetAsync(answerCacheKey, answerRecord, TimeSpan.FromMinutes(30));

        // Invalidate all stats cache for this poll since answers have changed
        await cache.RemoveGroupAsync(PollCacheGroupPrefix + pollId);

        return answerRecord;
    }

    public async Task<bool> UnAnswerPoll(Guid pollId, Guid accountId)
    {
        var now = SystemClock.Instance.GetCurrentInstant();
        var result = await db.PollAnswers
            .Where(e => e.PollId == pollId && e.AccountId == accountId)
            .ExecuteUpdateAsync(s => s.SetProperty(e => e.DeletedAt, now)) > 0;

        if (!result) return result;

        // Remove the cached answer if it exists
        var answerCacheKey = $"poll:answer:{pollId}:{accountId}";
        await cache.RemoveAsync(answerCacheKey);

        // Invalidate all stats cache for this poll since answers have changed
        await cache.RemoveGroupAsync(PollCacheGroupPrefix + pollId);

        return result;
    }

    private const string PollStatsCachePrefix = "poll:stats:";
    private const string PollCacheGroupPrefix = "poll:";

    // Returns stats for a single question (option id -> count)
    public async Task<Dictionary<string, int>> GetPollQuestionStats(Guid questionId)
    {
        var cacheKey = $"{PollStatsCachePrefix}{questionId}";

        // Try to get from cache first
        var (found, cachedStats) = await cache.GetAsyncWithStatus<Dictionary<string, int>>(cacheKey);
        if (found && cachedStats != null)
        {
            return cachedStats;
        }

        var question = await db.PollQuestions
            .Where(q => q.Id == questionId)
            .FirstOrDefaultAsync();

        if (question == null)
            throw new Exception("Question not found");

        var answers = await db.PollAnswers
            .Where(a => a.PollId == question.PollId && a.DeletedAt == null)
            .ToListAsync();

        var stats = new Dictionary<string, int>();

        foreach (var answer in answers)
        {
            if (!answer.Answer.TryGetValue(questionId.ToString(), out var value))
                continue;

            switch (question.Type)
            {
                case PollQuestionType.SingleChoice:
                    if (value.ValueKind == JsonValueKind.String &&
                        Guid.TryParse(value.GetString(), out var selected))
                    {
                        stats.TryGetValue(selected.ToString(), out var count);
                        stats[selected.ToString()] = count + 1;
                    }

                    break;

                case PollQuestionType.MultipleChoice:
                    if (value.ValueKind == JsonValueKind.Array)
                    {
                        foreach (var element in value.EnumerateArray())
                        {
                            if (element.ValueKind != JsonValueKind.String ||
                                !Guid.TryParse(element.GetString(), out var opt)) continue;
                            stats.TryGetValue(opt.ToString(), out var count);
                            stats[opt.ToString()] = count + 1;
                        }
                    }

                    break;

                case PollQuestionType.YesNo:
                    var id = value.ValueKind switch
                    {
                        JsonValueKind.True => "true",
                        JsonValueKind.False => "false",
                        _ => "neither"
                    };

                    stats.TryGetValue(id, out var ynCount);
                    stats[id] = ynCount + 1;
                    break;

                case PollQuestionType.Rating:
                    double sum = 0;
                    var countRating = 0;

                    foreach (var rating in answers)
                    {
                        if (!rating.Answer.TryGetValue(questionId.ToString(), out var ratingValue))
                            continue;

                        if (ratingValue.ValueKind == JsonValueKind.Number &&
                            ratingValue.TryGetDouble(out var ratingNumber))
                        {
                            sum += ratingNumber;
                            countRating++;
                        }
                    }

                    if (countRating > 0)
                    {
                        var avgRating = sum / countRating;
                        stats["rating"] = (int)Math.Round(avgRating);
                    }

                    break;

                case PollQuestionType.FreeText:
                    break;
                default:
                    throw new ArgumentOutOfRangeException();
            }
        }

        // Cache the result with a 1-hour expiration and add to the poll cache group
        await cache.SetWithGroupsAsync(
            cacheKey,
            stats,
            [PollCacheGroupPrefix + question.PollId],
            TimeSpan.FromHours(1));

        return stats;
    }

    // Returns stats for all questions in a poll (question id -> (option id -> count))
    public async Task<Dictionary<Guid, Dictionary<string, int>>> GetPollStats(Guid pollId)
    {
        var questions = await db.PollQuestions
            .Where(q => q.PollId == pollId)
            .ToListAsync();

        var result = new Dictionary<Guid, Dictionary<string, int>>();

        foreach (var question in questions)
        {
            var stats = await GetPollQuestionStats(question.Id);
            result[question.Id] = stats;
        }

        return result;
    }

    public async Task<PollEmbed> MakePollEmbed(Guid pollId)
    {
        // Do not read the cache here
        var poll = await db.Polls
            .Where(e => e.Id == pollId)
            .FirstOrDefaultAsync();
        return poll is null ? throw new Exception("Poll not found") : new PollEmbed { Id = poll.Id };
    }
}
