using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Profile;
using Tiku.Domain.Catalog;
using Tiku.Domain.Commerce;
using Tiku.Domain.Common;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
using Tiku.Domain.Operations;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;

namespace Tiku.Infrastructure.Profile;

public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
{
    private static readonly HashSet<string> AllowedAvatarPresets = new(StringComparer.OrdinalIgnoreCase)
    {
        "male",
        "female"
    };

    public async Task<StudentProfileItem> GetMeAsync(
        ProfileActor actor,
        ProfileQuery query,
        CancellationToken cancellationToken = default)
    {
        var profile = await EnsureProfileAsync(actor, cancellationToken);
        return await BuildProfileItemAsync(actor, profile, query.RecentLimit, cancellationToken);
    }

    public async Task<StudentProfileItem> UpdateMeAsync(
        ProfileActor actor,
        UpdateProfileCommand command,
        CancellationToken cancellationToken = default)
    {
        var profile = await EnsureProfileAsync(actor, cancellationToken);
        var user = await dbContext.Users.FindAsync([actor.UserId], cancellationToken)
                   ?? throw new ProfileException("Current user was not found.", "profile_user_not_found");

        if (!string.IsNullOrWhiteSpace(command.Name)) user.Name = command.Name.Trim();

        if (command.AvatarPreset is not null)
        {
            var avatarPreset = command.AvatarPreset.Trim();
            if (!AllowedAvatarPresets.Contains(avatarPreset))
                throw new ProfileException("Avatar preset must be male or female.", "invalid_avatar_preset");

            profile.AvatarPreset = avatarPreset.ToLowerInvariant();
        }

        await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
        await AssertReferenceAsync<School>(actor.TenantId, command.SelectedSchoolId, "school_not_found",
            cancellationToken);
        await AssertReferenceAsync<Major>(actor.TenantId, command.SelectedMajorId, "major_not_found",
            cancellationToken);

        profile.RegionId = command.RegionId ?? profile.RegionId;
        profile.SelectedSchoolId = command.SelectedSchoolId ?? profile.SelectedSchoolId;
        profile.SelectedMajorId = command.SelectedMajorId ?? profile.SelectedMajorId;
        if (command.Stats.HasValue) profile.Stats = JsonObjectOrDefault(command.Stats.Value);

        if (command.Progress.HasValue) profile.Progress = JsonObjectOrDefault(command.Progress.Value);

        if (command.ModuleSelections.HasValue)
            profile.ModuleSelections = JsonObjectOrDefault(command.ModuleSelections.Value);

        if (command.RecentActivities.HasValue)
            profile.RecentActivities = JsonArrayOrDefault(command.RecentActivities.Value);

        await dbContext.SaveChangesAsync(cancellationToken);
        return await BuildProfileItemAsync(actor, profile, null, cancellationToken);
    }

    public async Task<ExamCountdownList> GetExamCountdownsAsync(
        ProfileActor actor,
        ProfileQuery query,
        CancellationToken cancellationToken = default)
    {
        var profile = await EnsureProfileAsync(actor, cancellationToken);
        var limit = Math.Clamp(query.Limit ?? 5, 1, 20);
        var items = await dbContext.ExamDates.AsNoTracking()
            .Where(item =>
                item.TenantId == actor.TenantId &&
                item.IsActive &&
                (profile.RegionId == null || item.RegionId == null || item.RegionId == profile.RegionId) &&
                (profile.SelectedSchoolId == null || item.SchoolId == null ||
                 item.SchoolId == profile.SelectedSchoolId))
            .OrderBy(item => item.ExamAt == null)
            .ThenBy(item => item.ExamAt)
            .ThenBy(item => item.SortOrder)
            .Take(limit)
            .ToArrayAsync(cancellationToken);
        var today = DateTimeOffset.UtcNow.Date;

        return new ExamCountdownList(
            items.Select(item => new ExamCountdownItem(
                    item.Id,
                    item.LegacyId,
                    item.RegionId,
                    item.SchoolId,
                    item.ExamName,
                    item.ExamAt,
                    item.ExamType,
                    item.Description,
                    item.Metadata,
                    item.SortOrder,
                    item.IsActive,
                    item.ExamAt.HasValue
                        ? (int)Math.Ceiling((item.ExamAt.Value.UtcDateTime.Date - today).TotalDays)
                        : null))
                .ToArray(),
            profile.RegionId,
            profile.SelectedSchoolId);
    }

    public async Task<ProfileNotificationList> GetNotificationsAsync(
        ProfileActor actor,
        ProfileNotificationQuery query,
        CancellationToken cancellationToken = default)
    {
        await EnsureProfileAsync(actor, cancellationToken);
        var notifications = dbContext.UserNotifications.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
        if (!string.IsNullOrWhiteSpace(query.Status))
            notifications = notifications.Where(item => item.Status == ParseNotificationStatus(query.Status));

        var limit = Math.Clamp(query.Limit ?? 50, 1, 100);
        var items = await notifications
            .OrderByDescending(item => item.CreatedAt)
            .Take(limit)
            .ToArrayAsync(cancellationToken);
        return new ProfileNotificationList(
            items.Select(ToNotificationItem).ToArray(),
            await BuildNotificationSummaryAsync(actor, cancellationToken));
    }

    public async Task<ProfileNotificationList> UpdateNotificationStatusAsync(
        ProfileActor actor,
        UpdateNotificationStatusCommand command,
        CancellationToken cancellationToken = default)
    {
        if (command.NotificationIds.Count is 0 or > 100)
            throw new ProfileException("Notification ids must contain 1 to 100 items.", "invalid_notification_ids");

        var status = ParseNotificationStatus(command.Status ?? "read");
        if (status == NotificationStatus.Unread)
            throw new ProfileException("Notification status cannot be set to unread.", "invalid_notification_status");

        var notifications = await dbContext.UserNotifications
            .Where(item =>
                item.TenantId == actor.TenantId &&
                item.UserId == actor.UserId &&
                command.NotificationIds.Contains(item.Id))
            .ToArrayAsync(cancellationToken);
        foreach (var notification in notifications)
        {
            notification.Status = status;
            notification.ReadAt = status == NotificationStatus.Read ? DateTimeOffset.UtcNow : notification.ReadAt;
        }

        await dbContext.SaveChangesAsync(cancellationToken);
        return new ProfileNotificationList(
            notifications.Select(ToNotificationItem).ToArray(),
            await BuildNotificationSummaryAsync(actor, cancellationToken));
    }

    public async Task<BadgeList> GetBadgesAsync(
        ProfileActor actor,
        BadgeQuery query,
        CancellationToken cancellationToken = default)
    {
        await EnsureProfileAsync(actor, cancellationToken);
        var badgesQuery = dbContext.Badges.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId && item.IsActive);
        if (!string.IsNullOrWhiteSpace(query.Category))
        {
            var category = query.Category.Trim();
            badgesQuery = badgesQuery.Where(item => item.Category == category);
        }

        var badges = await badgesQuery
            .OrderBy(item => item.SortOrder)
            .ThenBy(item => item.Name)
            .Take(Math.Clamp(query.Limit ?? 100, 1, 200))
            .ToArrayAsync(cancellationToken);
        var badgeIds = badges.Select(item => item.Id).ToArray();
        var grants = await dbContext.UserBadges.AsNoTracking()
            .Where(item =>
                item.TenantId == actor.TenantId &&
                item.UserId == actor.UserId &&
                item.BadgeId.HasValue &&
                badgeIds.Contains(item.BadgeId.Value))
            .ToDictionaryAsync(item => item.BadgeId!.Value, cancellationToken);
        var items = badges
            .Select(badge =>
            {
                grants.TryGetValue(badge.Id, out var grant);
                return new BadgeItem(
                    badge.Id,
                    badge.Name,
                    badge.Description,
                    badge.Category,
                    badge.IconUrl,
                    badge.Level,
                    badge.UnlockType,
                    badge.ConditionExtra,
                    grant is not null,
                    grant?.GrantedAt,
                    grant?.Note,
                    badge.SortOrder);
            })
            .Where(item => query.IncludeLocked || item.IsUnlocked)
            .ToArray();

        return new BadgeList(items, badges.Length, grants.Count, query.IncludeLocked);
    }

    public async Task<IReadOnlyCollection<FeedbackItem>> GetFeedbacksAsync(
        ProfileActor actor,
        FeedbackQuery query,
        CancellationToken cancellationToken = default)
    {
        await EnsureProfileAsync(actor, cancellationToken);
        var feedbacks = dbContext.Reports.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
        if (!string.IsNullOrWhiteSpace(query.Status))
            feedbacks = feedbacks.Where(item => item.Status == ParseReportStatus(query.Status));

        if (!string.IsNullOrWhiteSpace(query.Type))
            feedbacks = feedbacks.Where(item => item.Type == ParseReportType(query.Type));

        return await feedbacks
            .OrderByDescending(item => item.CreatedAt)
            .Take(Math.Clamp(query.Limit ?? 50, 1, 100))
            .Select(item => ToFeedbackItem(item))
            .ToArrayAsync(cancellationToken);
    }

    public async Task<FeedbackItem> SubmitFeedbackAsync(
        ProfileActor actor,
        SubmitFeedbackCommand command,
        CancellationToken cancellationToken = default)
    {
        await EnsureProfileAsync(actor, cancellationToken);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.Description);
        await AssertReferenceAsync<Question>(actor.TenantId, command.QuestionId, "question_not_found",
            cancellationToken);

        var feedback = new Report
        {
            TenantId = actor.TenantId,
            UserId = actor.UserId,
            QuestionId = command.QuestionId,
            Type = ParseReportType(command.Type ?? "other"),
            Title = string.IsNullOrWhiteSpace(command.Title) ? null : command.Title.Trim(),
            Category = string.IsNullOrWhiteSpace(command.Category) ? null : command.Category.Trim(),
            Description = command.Description.Trim(),
            Priority = ParseReportPriority(command.Priority ?? "normal"),
            Contact = string.IsNullOrWhiteSpace(command.Contact) ? null : command.Contact.Trim(),
            Attachments = JsonArrayOrDefault(command.Attachments),
            Metadata = JsonObjectOrDefault(command.Metadata)
        };
        dbContext.Reports.Add(feedback);
        dbContext.ReportStatusEvents.Add(new ReportStatusEvent
        {
            TenantId = actor.TenantId,
            ReportId = feedback.Id,
            ToStatus = ReportStatus.Pending,
            Note = "student submitted feedback",
            ActorUserId = actor.UserId
        });
        await dbContext.SaveChangesAsync(cancellationToken);
        return ToFeedbackItem(feedback);
    }

    public async Task<CheckInResult> CheckInAsync(
        ProfileActor actor,
        CancellationToken cancellationToken = default)
    {
        var profile = await EnsureProfileAsync(actor, cancellationToken);
        var today = DateOnly.FromDateTime(DateTime.UtcNow);
        var sourceId = CreateDeterministicGuid($"{actor.TenantId:N}:{actor.UserId:N}:check-in:{today:yyyyMMdd}");
        var idempotencyKey = $"check-in:{actor.TenantId:N}:{actor.UserId:N}:{today:yyyyMMdd}";
        var existingEvent = await dbContext.UserScoreEvents.AsNoTracking()
            .SingleOrDefaultAsync(
                item =>
                    item.TenantId == actor.TenantId &&
                    item.UserId == actor.UserId &&
                    item.IdempotencyKey == idempotencyKey,
                cancellationToken);
        if (existingEvent is not null)
        {
            var existingClaim = await dbContext.PointActivityClaims.AsNoTracking()
                .SingleOrDefaultAsync(
                    item =>
                        item.TenantId == actor.TenantId &&
                        item.UserId == actor.UserId &&
                        item.SourceType == "daily_check_in" &&
                        item.SourceId == sourceId,
                    cancellationToken);
            return new CheckInResult(
                today,
                true,
                existingEvent.Points,
                existingEvent.BalanceAfter,
                existingClaim?.Id,
                existingEvent.Id);
        }

        var task = await dbContext.PointActivityTasks
                       .Where(item =>
                           item.TenantId == actor.TenantId &&
                           item.Status == PointActivityTaskStatus.Active &&
                           (item.TaskKey == "daily_check_in" || item.TaskKey == "daily_login"))
                       .OrderByDescending(item => item.TaskKey == "daily_check_in")
                       .ThenBy(item => item.SortOrder)
                       .FirstOrDefaultAsync(cancellationToken)
                   ?? throw new ProfileException("Daily check-in point task was not configured.",
                       "check_in_task_not_found");
        var now = DateTimeOffset.UtcNow;
        var claim = new PointActivityClaim
        {
            TenantId = actor.TenantId,
            TaskId = task.Id,
            TaskKey = task.TaskKey,
            UserId = actor.UserId,
            Points = task.Points,
            Status = PointActivityClaimStatus.Claimed,
            SourceType = "daily_check_in",
            SourceId = sourceId,
            ClaimedAt = now,
            Metadata = JsonSerializer.SerializeToElement(new
            {
                checkInDate = today,
                source = "profile_check_in"
            })
        };
        dbContext.PointActivityClaims.Add(claim);
        var balanceAfter = await CalculatePointBalanceAsync(actor, cancellationToken) + task.Points;
        var scoreEvent = new UserScoreEvent
        {
            TenantId = actor.TenantId,
            UserId = actor.UserId,
            EventType = UserScoreEventType.CheckIn,
            Points = task.Points,
            BalanceAfter = balanceAfter,
            SourceType = "daily_check_in",
            SourceId = sourceId,
            IdempotencyKey = idempotencyKey,
            Metadata = JsonSerializer.SerializeToElement(new
            {
                task.Id,
                task.TaskKey,
                checkInDate = today
            }),
            CreatedAt = now
        };
        dbContext.UserScoreEvents.Add(scoreEvent);
        profile.LastCheckInDate = today;
        await dbContext.SaveChangesAsync(cancellationToken);
        return new CheckInResult(today, false, task.Points, balanceAfter, claim.Id, scoreEvent.Id);
    }

    public async Task<ProfileScoreEventList> GetScoreEventsAsync(
        ProfileActor actor,
        ProfileScoreEventQuery query,
        CancellationToken cancellationToken = default)
    {
        await EnsureProfileAsync(actor, cancellationToken);
        var events = dbContext.UserScoreEvents.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
        if (!string.IsNullOrWhiteSpace(query.SourceType))
        {
            var sourceType = query.SourceType.Trim();
            events = events.Where(item => item.SourceType == sourceType);
        }

        if (query.From.HasValue) events = events.Where(item => item.CreatedAt >= query.From.Value);

        if (query.To.HasValue) events = events.Where(item => item.CreatedAt <= query.To.Value);

        var items = await events
            .OrderByDescending(item => item.CreatedAt)
            .Take(Math.Clamp(query.Limit ?? 50, 1, 200))
            .Select(item => new ProfileScoreEventItem(
                item.Id,
                ToWire(item.EventType),
                item.Points,
                item.BalanceAfter,
                item.SourceType,
                item.SourceId,
                item.Metadata,
                item.CreatedAt))
            .ToArrayAsync(cancellationToken);
        return new ProfileScoreEventList(items);
    }

    private async Task<StudentProfile> EnsureProfileAsync(
        ProfileActor actor,
        CancellationToken cancellationToken)
    {
        var profile = await dbContext.StudentProfiles
            .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
                cancellationToken);
        if (profile is not null) return profile;

        var membershipExists = await dbContext.TenantMemberships.AnyAsync(
            membership =>
                membership.TenantId == actor.TenantId &&
                membership.UserId == actor.UserId &&
                membership.Status == MembershipStatus.Active,
            cancellationToken);
        if (!membershipExists)
            throw new ProfileException("Current user is not a tenant member.", "profile_access_denied");

        profile = new StudentProfile
        {
            TenantId = actor.TenantId,
            UserId = actor.UserId,
            AvatarPreset = "male"
        };
        dbContext.StudentProfiles.Add(profile);
        await dbContext.SaveChangesAsync(cancellationToken);
        return profile;
    }

    private async Task<IReadOnlyDictionary<string, int>> BuildNotificationSummaryAsync(
        ProfileActor actor,
        CancellationToken cancellationToken)
    {
        var items = await dbContext.UserNotifications.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId)
            .GroupBy(item => item.Status)
            .Select(group => new { Status = group.Key, Count = group.Count() })
            .ToArrayAsync(cancellationToken);
        return items.ToDictionary(item => ToWire(item.Status), item => item.Count, StringComparer.Ordinal);
    }

    private async Task<StudentProfileItem> BuildProfileItemAsync(
        ProfileActor actor,
        StudentProfile profile,
        int? recentLimit,
        CancellationToken cancellationToken)
    {
        var user = await dbContext.Users.AsNoTracking()
            .SingleAsync(item => item.Id == actor.UserId, cancellationToken);
        var regionName = profile.RegionId.HasValue
            ? await dbContext.Regions.AsNoTracking()
                .Where(item => item.TenantId == actor.TenantId && item.Id == profile.RegionId.Value)
                .Select(item => item.Name)
                .SingleOrDefaultAsync(cancellationToken)
            : null;
        var schoolName = profile.SelectedSchoolId.HasValue
            ? await dbContext.Schools.AsNoTracking()
                .Where(item => item.TenantId == actor.TenantId && item.Id == profile.SelectedSchoolId.Value)
                .Select(item => item.Name)
                .SingleOrDefaultAsync(cancellationToken)
            : null;
        var majorName = profile.SelectedMajorId.HasValue
            ? await dbContext.Majors.AsNoTracking()
                .Where(item => item.TenantId == actor.TenantId && item.Id == profile.SelectedMajorId.Value)
                .Select(item => item.Name)
                .SingleOrDefaultAsync(cancellationToken)
            : null;
        var limit = Math.Clamp(recentLimit ?? 8, 1, 50);
        var recentPractices = await dbContext.RecentPractices.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId)
            .OrderByDescending(item => item.LastPracticeAt)
            .ThenByDescending(item => item.LastAccessAt)
            .ThenByDescending(item => item.UpdatedAt)
            .Take(limit)
            .Select(item => new RecentPracticeItem(
                item.Id,
                item.PracticeType,
                item.TargetName,
                item.Progress,
                item.LastAccessAt,
                item.LastPracticeAt))
            .ToArrayAsync(cancellationToken);
        var entitlement = await dbContext.Entitlements.AsNoTracking()
            .Where(item =>
                item.TenantId == actor.TenantId &&
                item.UserId == actor.UserId &&
                item.EntitlementType == "svip" &&
                item.Status == EntitlementStatus.Active &&
                item.StartsAt <= DateTimeOffset.UtcNow &&
                (item.ExpiresAt == null || item.ExpiresAt > DateTimeOffset.UtcNow))
            .OrderByDescending(item => item.ExpiresAt)
            .FirstOrDefaultAsync(cancellationToken);

        return new StudentProfileItem(
            profile.Id,
            user.Id,
            user.UserName,
            user.Phone,
            user.Email,
            user.Name,
            profile.AvatarPreset,
            $"/assets/avatars/default-{profile.AvatarPreset}.svg",
            user.PrimaryRole,
            user.Score,
            profile.RegionId,
            regionName,
            profile.SelectedSchoolId,
            schoolName,
            profile.SelectedMajorId,
            majorName,
            profile.QuestionsAnsweredToday,
            profile.MasteredWordsCount,
            profile.LastCheckInDate,
            profile.Stats,
            profile.Progress,
            profile.ModuleSelections,
            profile.RecentActivities,
            recentPractices,
            new StudentMembershipItem(entitlement is not null, entitlement?.ExpiresAt));
    }

    private async Task AssertReferenceAsync<TEntity>(
        Guid tenantId,
        Guid? id,
        string code,
        CancellationToken cancellationToken)
        where TEntity : TenantEntity
    {
        if (!id.HasValue) return;

        var exists = await dbContext.Set<TEntity>().AnyAsync(
            entity => entity.TenantId == tenantId && entity.Id == id.Value,
            cancellationToken);
        if (!exists) throw new ProfileException("Profile reference was not found.", code);
    }

    private static JsonElement JsonObjectOrDefault(JsonElement value)
    {
        return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object();
    }

    private static JsonElement JsonArrayOrDefault(JsonElement value)
    {
        return value.ValueKind == JsonValueKind.Array ? value.Clone() : JsonDefaults.Array();
    }

    private static ProfileNotificationItem ToNotificationItem(UserNotification notification)
    {
        return new ProfileNotificationItem(
            notification.Id,
            notification.NotificationType,
            ToWire(notification.Status),
            ToWire(notification.Severity),
            notification.Title,
            notification.Message,
            notification.ActionLabel,
            notification.ActionPath,
            notification.SourceType,
            notification.SourceId,
            notification.Metadata,
            notification.ReadAt,
            notification.CreatedAt);
    }

    private static FeedbackItem ToFeedbackItem(Report report)
    {
        return new FeedbackItem(
            report.Id,
            report.QuestionId,
            report.Type.HasValue ? ToWire(report.Type.Value) : null,
            report.Title,
            report.Category,
            report.Description,
            ToWire(report.Status),
            ToWire(report.Priority),
            report.Contact,
            report.Attachments,
            report.Metadata,
            report.CreatedAt,
            report.UpdatedAt);
    }

    private static NotificationStatus ParseNotificationStatus(string value)
    {
        return ParseEnum<NotificationStatus>(value, "invalid_notification_status");
    }

    private static ReportType ParseReportType(string value)
    {
        return ParseEnum<ReportType>(value, "invalid_feedback_type");
    }

    private static ReportStatus ParseReportStatus(string value)
    {
        return ParseEnum<ReportStatus>(value, "invalid_feedback_status");
    }

    private static ReportPriority ParseReportPriority(string value)
    {
        return ParseEnum<ReportPriority>(value, "invalid_feedback_priority");
    }

    private static TEnum ParseEnum<TEnum>(string value, string code)
        where TEnum : struct, Enum
    {
        var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal)
            .Replace("-", string.Empty, StringComparison.Ordinal);
        if (Enum.TryParse<TEnum>(normalized, true, out var parsed)) return parsed;

        throw new ProfileException("Profile enum value is invalid.", code);
    }

    private static string ToWire<TEnum>(TEnum value)
        where TEnum : struct, Enum
    {
        var text = value.ToString();
        return string.Concat(text.Select((ch, index) =>
            index > 0 && char.IsUpper(ch)
                ? "_" + char.ToLowerInvariant(ch)
                : char.ToLowerInvariant(ch).ToString()));
    }

    private async Task<int> CalculatePointBalanceAsync(ProfileActor actor, CancellationToken cancellationToken)
    {
        var earned = await dbContext.PointActivityClaims.AsNoTracking()
            .Where(item =>
                item.TenantId == actor.TenantId &&
                item.UserId == actor.UserId &&
                item.Status == PointActivityClaimStatus.Claimed)
            .SumAsync(item => (int?)item.Points, cancellationToken) ?? 0;
        var spent = await dbContext.PointExchangeOrders.AsNoTracking()
            .Where(item =>
                item.TenantId == actor.TenantId &&
                item.UserId == actor.UserId &&
                item.Status == PointExchangeOrderStatus.Completed)
            .SumAsync(item => (int?)item.PointsCost, cancellationToken) ?? 0;
        return earned - spent;
    }

    private static Guid CreateDeterministicGuid(string value)
    {
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value));
        Span<byte> guidBytes = stackalloc byte[16];
        bytes.AsSpan(0, 16).CopyTo(guidBytes);
        return new Guid(guidBytes);
    }
}