using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog;
using Tiku.Application.Auth;
using Tiku.Application.Content;
using Tiku.Application.Notifications;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Application.TenantAdmin;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus;
using OrderStatus = Tiku.Domain.Commerce.OrderStatus;
using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus;
using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus;
using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus;

namespace Tiku.Infrastructure.TenantAdmin;

public sealed class TenantAdminDirectService(
    TikuDbContext dbContext,
    ITenantExternalProviderConfigService providerConfigService,
    INotificationProvider notificationProvider,
    ICurrentAccessContext currentAccessContext,
    IAuthSessionStore sessionStore,
    IFeatureAccessService featureAccessService) : ITenantAdminDirectService
{
    public async Task<TenantAdminOverviewItem> GetOverviewAsync(
        TenantAdminActor actor,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var classIds = scope.ClassIds.ToArray();
        var now = DateTimeOffset.UtcNow;
        var today = new DateTimeOffset(now.UtcDateTime.Date, TimeSpan.Zero);
        var scopedClasses = dbContext.TenantClasses.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId)
            .ApplyDataScope(
                scope,
                item => item.CreatedBy == actor.UserId,
                item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)));
        var scopedStudents = dbContext.StudentProfiles.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId)
            .ApplyDataScope(
                scope,
                item => item.UserId == actor.UserId,
                item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
        var scopedOrders = dbContext.Orders.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId)
            .ApplyDataScope(
                scope,
                item => item.UserId == actor.UserId,
                item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));

        var studentCount = await scopedStudents.CountAsync(cancellationToken);
        var classCount = await scopedClasses.CountAsync(item => item.Status == TenantRecordStatus.Active, cancellationToken);
        var staffCount = await dbContext.TenantMemberships.AsNoTracking()
            .CountAsync(item =>
                item.TenantId == actor.TenantId &&
                item.Status == MembershipStatus.Active &&
                item.Role != TenantRole.Student,
                cancellationToken);
        var activePracticeCount = await dbContext.PracticeSessions.AsNoTracking()
            .CountAsync(item =>
                item.TenantId == actor.TenantId &&
                item.FinishedAt == null &&
                (!item.ExpiresAt.HasValue || item.ExpiresAt > now),
                cancellationToken);
        var todayPracticeCount = await dbContext.PracticeSessions.AsNoTracking()
            .CountAsync(item => item.TenantId == actor.TenantId && item.StartedAt >= today, cancellationToken);
        var pendingFollowupCount = await dbContext.TenantStudentFollowups.AsNoTracking()
            .CountAsync(item =>
                item.TenantId == actor.TenantId &&
                (item.Status == StudentFollowupStatus.Open || item.Status == StudentFollowupStatus.InProgress),
                cancellationToken);
        var unreadNotificationCount = await dbContext.UserNotifications.AsNoTracking()
            .CountAsync(item => item.TenantId == actor.TenantId && item.Status == NotificationStatus.Unread, cancellationToken);
        var paidOrderCount = await scopedOrders.CountAsync(item => item.Status == OrderStatus.Paid, cancellationToken);
        var revenueCents = await scopedOrders
            .Where(item => item.Status == OrderStatus.Paid || item.Status == OrderStatus.PartiallyRefunded || item.Status == OrderStatus.Refunded)
            .SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken);
        var pendingRefundCount = await dbContext.CommerceRefundRequests.AsNoTracking()
            .CountAsync(item =>
                item.TenantId == actor.TenantId &&
                (item.Status == CommerceRefundStatus.Requested ||
                 item.Status == CommerceRefundStatus.Approved ||
                 item.Status == CommerceRefundStatus.Processing),
                cancellationToken);
        var openReconciliationIssueCount = await dbContext.CommerceReconciliationIssues.AsNoTracking()
            .CountAsync(item =>
                item.TenantId == actor.TenantId &&
                item.Status != ReconciliationIssueStatus.Resolved &&
                item.Status != ReconciliationIssueStatus.Ignored,
                cancellationToken);

        return new TenantAdminOverviewItem(
            studentCount,
            classCount,
            staffCount,
            activePracticeCount,
            todayPracticeCount,
            pendingFollowupCount,
            unreadNotificationCount,
            paidOrderCount,
            revenueCents,
            pendingRefundCount,
            openReconciliationIssueCount,
            now);
    }

    public async Task<TenantAdminClassList> GetClassesAsync(
        TenantAdminActor actor,
        TenantAdminClassFilter filter,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var classIds = scope.ClassIds.ToArray();
        var query = dbContext.TenantClasses.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId)
            .ApplyDataScope(
                scope,
                item => item.CreatedBy == actor.UserId,
                item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)));
        if (filter.RegionId.HasValue)
        {
            query = query.Where(item => item.RegionId == filter.RegionId.Value);
        }

        if (!string.IsNullOrWhiteSpace(filter.Status))
        {
            query = query.Where(item => item.Status == ParseEnum<TenantRecordStatus>(filter.Status, "invalid_class_status"));
        }
        else
        {
            query = query.Where(item => item.Status != TenantRecordStatus.Archived);
        }

        if (!string.IsNullOrWhiteSpace(filter.Keyword))
        {
            var keyword = filter.Keyword.Trim();
            query = query.Where(item =>
                item.Name.Contains(keyword) ||
                (item.Code != null && item.Code.Contains(keyword)));
        }

        var items = await query
            .OrderBy(item => item.SortOrder)
            .ThenByDescending(item => item.CreatedAt)
            .Take(ResolveLimit(filter.Limit))
            .Select(item => new
            {
                Class = item,
                RegionName = dbContext.Regions
                    .Where(region => region.TenantId == actor.TenantId && region.Id == item.RegionId)
                    .Select(region => region.Name)
                    .FirstOrDefault(),
                StudentCount = dbContext.TenantClassMembers.Count(member =>
                    member.TenantId == actor.TenantId &&
                    member.ClassId == item.Id &&
                    member.Status == TenantClassMemberStatus.Active &&
                    member.MemberType == TenantClassMemberType.Student),
                StaffCount = dbContext.TenantClassMembers.Count(member =>
                    member.TenantId == actor.TenantId &&
                    member.ClassId == item.Id &&
                    member.Status == TenantClassMemberStatus.Active &&
                    member.MemberType != TenantClassMemberType.Student)
            })
            .ToArrayAsync(cancellationToken);

        return new TenantAdminClassList(
            items.Select(item => ToClassItem(item.Class, item.RegionName, item.StudentCount, item.StaffCount)).ToArray(),
            Scoped: scope.Mode != DataScopeMode.All);
    }

    public async Task<ContentManagementResult<TenantAdminClassItem>> UpsertClassAsync(
        TenantAdminActor actor,
        UpsertTenantAdminClassCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
        await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);

        var item = await ResolveTenantEntityAsync(dbContext.TenantClasses, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
        var isNew = item is null;
        if (item is not null && !scope.AllowsResource(actor.UserId, item.CreatedBy, item.RegionId, item.Id))
        {
            throw new TenantAdminDirectException("Class was not found.", "class_not_found");
        }

        if (item is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId, command.Id))
        {
            throw new TenantAdminDirectException("Class was not found.", "class_not_found");
        }

        item ??= new TenantClass { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, CreatedBy = actor.UserId };
        item.RegionId = command.RegionId;
        item.LegacyId = Normalize(command.LegacyId);
        item.Code = Normalize(command.Code);
        item.Name = command.Name.Trim();
        item.Description = Normalize(command.Description);
        item.Status = ParseEnum(command.Status, TenantRecordStatus.Active, "invalid_class_status");
        item.SortOrder = command.Order ?? item.SortOrder;
        item.Metadata = JsonObjectOrDefault(command.Metadata);
        item.UpdatedBy = actor.UserId;

        if (isNew)
        {
            dbContext.TenantClasses.Add(item);
        }

        await AddAuditAsync(actor, "tenant.class.upserted", "tenant_classes", item.Id, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);

        var regionName = item.RegionId.HasValue
            ? await dbContext.Regions
                .Where(region => region.TenantId == actor.TenantId && region.Id == item.RegionId.Value)
                .Select(region => region.Name)
                .FirstOrDefaultAsync(cancellationToken)
            : null;
        return new ContentManagementResult<TenantAdminClassItem>(ToClassItem(item, regionName, 0, 0));
    }

    public async Task<ContentManagementResult<TenantAdminClassItem>> DisableClassAsync(
        TenantAdminActor actor,
        Guid classId,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var classIds = scope.ClassIds.ToArray();
        var item = await dbContext.TenantClasses
            .Where(entity => entity.TenantId == actor.TenantId && entity.Id == classId)
            .ApplyDataScope(
                scope,
                entity => entity.CreatedBy == actor.UserId,
                entity => classIds.Contains(entity.Id) || (entity.RegionId.HasValue && regionIds.Contains(entity.RegionId.Value)))
            .FirstOrDefaultAsync(cancellationToken);
        if (item is null)
        {
            throw new TenantAdminDirectException("Class was not found.", "class_not_found");
        }

        item.Status = TenantRecordStatus.Disabled;
        item.UpdatedBy = actor.UserId;
        await AddAuditAsync(actor, "tenant.class.disabled", "tenant_classes", item.Id, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<TenantAdminClassItem>(ToClassItem(item, null, 0, 0));
    }

    public async Task<CatalogList<TenantAdminClassMemberItem>> GetClassMembersAsync(
        TenantAdminActor actor,
        TenantAdminClassMemberFilter filter,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        await AssertClassAsync(actor, scope, filter.ClassId, cancellationToken);
        var classIds = scope.ClassIds.ToArray();
        var regionIds = scope.RegionIds.ToArray();
        var query = dbContext.TenantClassMembers.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId && item.ClassId == filter.ClassId)
            .ApplyDataScope(
                scope,
                item => item.UserId == actor.UserId || item.CreatedBy == actor.UserId,
                item => classIds.Contains(item.ClassId) || dbContext.TenantClasses.Any(tenantClass =>
                    tenantClass.TenantId == actor.TenantId &&
                    tenantClass.Id == item.ClassId &&
                    tenantClass.RegionId.HasValue &&
                    regionIds.Contains(tenantClass.RegionId.Value)));

        if (!string.IsNullOrWhiteSpace(filter.MemberType))
        {
            query = query.Where(item => item.MemberType == ParseEnum<TenantClassMemberType>(filter.MemberType, "invalid_class_member_type"));
        }

        query = query.Where(item => item.Status == ParseEnum(filter.Status, TenantClassMemberStatus.Active, "invalid_class_member_status"));

        var items = await query
            .OrderBy(item => item.MemberType == TenantClassMemberType.HeadTeacher ? 0 :
                item.MemberType == TenantClassMemberType.Teacher ? 1 :
                item.MemberType == TenantClassMemberType.Assistant ? 2 : 9)
            .ThenBy(item => item.JoinedAt)
            .Take(ResolveLimit(filter.Limit))
            .Join(
                dbContext.Users.AsNoTracking(),
                member => member.UserId,
                user => user.Id,
                (member, user) => ToClassMemberItem(member, ToUserSummary(user)))
            .ToArrayAsync(cancellationToken);

        return new CatalogList<TenantAdminClassMemberItem>(items);
    }

    public async Task<ContentManagementResult<TenantAdminClassMemberItem>> UpsertClassMemberAsync(
        TenantAdminActor actor,
        UpsertTenantAdminClassMemberCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        await AssertClassAsync(actor, scope, command.ClassId, cancellationToken);
        await using var transaction = dbContext.Database.CurrentTransaction is null
            ? await dbContext.Database.BeginTransactionAsync(cancellationToken)
            : null;
        var memberType = ParseEnum(command.MemberType, TenantClassMemberType.Student, "invalid_class_member_type");
        var status = ParseEnum(command.Status, TenantClassMemberStatus.Active, "invalid_class_member_status");
        var user = await ResolveUserAsync(command.User, memberType == TenantClassMemberType.Student ? "student" : "teacher", cancellationToken);
        await EnsureMembershipAsync(actor.TenantId, user.Id, memberType == TenantClassMemberType.Student ? TenantRole.Student : TenantRole.Teacher, cancellationToken);
        if (memberType == TenantClassMemberType.Student)
        {
            await EnsureStudentProfileAsync(actor.TenantId, user.Id, null, null, null, null, JsonDefaults.Object(), JsonDefaults.Object(), JsonDefaults.Object(), cancellationToken);
        }

        var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member =>
            member.TenantId == actor.TenantId &&
            member.ClassId == command.ClassId &&
            member.UserId == user.Id &&
            member.MemberType == memberType,
            cancellationToken);
        var isNew = item is null;
        item ??= new TenantClassMember
        {
            TenantId = actor.TenantId,
            ClassId = command.ClassId,
            UserId = user.Id,
            MemberType = memberType,
            CreatedBy = actor.UserId
        };
        item.Status = status;
        item.LeftAt = status is TenantClassMemberStatus.Active ? null : DateTimeOffset.UtcNow;
        item.Metadata = JsonObjectOrDefault(command.Metadata);
        item.UpdatedBy = actor.UserId;
        if (isNew)
        {
            dbContext.TenantClassMembers.Add(item);
        }

        await AddAuditAsync(actor, "tenant.class_member.upserted", "tenant_class_members", item.Id, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        if (transaction is not null)
        {
            await transaction.CommitAsync(cancellationToken);
        }
        return new ContentManagementResult<TenantAdminClassMemberItem>(ToClassMemberItem(item, ToUserSummary(user)));
    }

    public async Task<ContentManagementResult<TenantAdminClassMemberItem>> RemoveClassMemberAsync(
        TenantAdminActor actor,
        Guid classMemberId,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var classIds = scope.ClassIds.ToArray();
        var regionIds = scope.RegionIds.ToArray();
        var item = await dbContext.TenantClassMembers
            .Where(member => member.TenantId == actor.TenantId && member.Id == classMemberId)
            .ApplyDataScope(
                scope,
                member => member.UserId == actor.UserId || member.CreatedBy == actor.UserId,
                member => classIds.Contains(member.ClassId) || dbContext.TenantClasses.Any(tenantClass =>
                    tenantClass.TenantId == actor.TenantId &&
                    tenantClass.Id == member.ClassId &&
                    tenantClass.RegionId.HasValue &&
                    regionIds.Contains(tenantClass.RegionId.Value)))
            .FirstOrDefaultAsync(cancellationToken);
        if (item is null)
        {
            throw new TenantAdminDirectException("Class member was not found.", "class_member_not_found");
        }

        var user = await dbContext.Users.SingleAsync(user => user.Id == item.UserId, cancellationToken);
        item.Status = TenantClassMemberStatus.Removed;
        item.LeftAt = DateTimeOffset.UtcNow;
        item.UpdatedBy = actor.UserId;
        await AddAuditAsync(actor, "tenant.class_member.removed", "tenant_class_members", item.Id, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<TenantAdminClassMemberItem>(ToClassMemberItem(item, ToUserSummary(user)));
    }

    public async Task<TenantAdminStudentList> GetStudentsAsync(
        TenantAdminActor actor,
        TenantAdminStudentFilter filter,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var status = ParseEnum(filter.Status, MembershipStatus.Active, "invalid_student_status");
        var regionIds = scope.RegionIds.ToArray();
        var classIds = scope.ClassIds.ToArray();
        var query = dbContext.TenantMemberships.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId && item.Role == TenantRole.Student && item.Status == status)
            .ApplyDataScope(
                scope,
                item => item.UserId == actor.UserId,
                item => dbContext.StudentProfiles.Any(profile =>
                            profile.TenantId == actor.TenantId &&
                            profile.UserId == item.UserId &&
                            profile.RegionId.HasValue &&
                            regionIds.Contains(profile.RegionId.Value)) ||
                        dbContext.TenantClassMembers.Any(member =>
                            member.TenantId == actor.TenantId &&
                            member.UserId == item.UserId &&
                            member.Status == TenantClassMemberStatus.Active &&
                            classIds.Contains(member.ClassId)));

        if (filter.ClassId.HasValue)
        {
            await AssertClassAsync(actor, scope, filter.ClassId.Value, cancellationToken);
            query = query.Where(item => dbContext.TenantClassMembers.Any(member =>
                member.TenantId == actor.TenantId &&
                member.ClassId == filter.ClassId.Value &&
                member.UserId == item.UserId &&
                member.MemberType == TenantClassMemberType.Student &&
                member.Status == TenantClassMemberStatus.Active));
        }

        if (filter.RegionId.HasValue)
        {
            query = query.Where(item => dbContext.StudentProfiles.Any(profile =>
                profile.TenantId == actor.TenantId &&
                profile.UserId == item.UserId &&
                profile.RegionId == filter.RegionId.Value));
        }

        if (!string.IsNullOrWhiteSpace(filter.Keyword))
        {
            var keyword = filter.Keyword.Trim();
            query = query.Where(item => dbContext.Users.Any(user =>
                user.Id == item.UserId &&
                ((user.UserName != null && user.UserName.Contains(keyword)) ||
                 (user.Phone != null && user.Phone.Contains(keyword)) ||
                 (user.Email != null && user.Email.Contains(keyword)) ||
                 (user.Name != null && user.Name.Contains(keyword)))));
        }

        var memberships = await query
            .OrderByDescending(item => item.CreatedAt)
            .Take(ResolveLimit(filter.Limit))
            .ToArrayAsync(cancellationToken);
        var userIds = memberships.Select(item => item.UserId).ToArray();
        var users = await dbContext.Users.AsNoTracking()
            .Where(user => userIds.Contains(user.Id))
            .ToDictionaryAsync(user => user.Id, cancellationToken);
        var profiles = await dbContext.StudentProfiles.AsNoTracking()
            .Where(profile => profile.TenantId == actor.TenantId && userIds.Contains(profile.UserId))
            .ToDictionaryAsync(profile => profile.UserId, cancellationToken);
        var regions = await dbContext.Regions.AsNoTracking()
            .Where(region => region.TenantId == actor.TenantId)
            .ToDictionaryAsync(region => region.Id, region => region.Name, cancellationToken);
        var schools = await dbContext.Schools.AsNoTracking()
            .Where(school => school.TenantId == actor.TenantId)
            .ToDictionaryAsync(school => school.Id, school => school.Name, cancellationToken);
        var majors = await dbContext.Majors.AsNoTracking()
            .Where(major => major.TenantId == actor.TenantId)
            .ToDictionaryAsync(major => major.Id, major => major.Name, cancellationToken);
        var classes = await (
            from member in dbContext.TenantClassMembers.AsNoTracking()
            join tenantClass in dbContext.TenantClasses.AsNoTracking() on member.ClassId equals tenantClass.Id
            where member.TenantId == actor.TenantId &&
                  tenantClass.TenantId == actor.TenantId &&
                  userIds.Contains(member.UserId) &&
                  member.Status == TenantClassMemberStatus.Active &&
                  member.MemberType == TenantClassMemberType.Student
            orderby tenantClass.SortOrder, tenantClass.CreatedAt descending
            select new { member.UserId, member.MemberType, member.JoinedAt, ClassId = tenantClass.Id, tenantClass.Name, tenantClass.Code })
            .ToArrayAsync(cancellationToken);
        var classLookup = classes
            .GroupBy(item => item.UserId)
            .ToDictionary(
                group => group.Key,
                group => group.Select(item => new TenantAdminStudentClassSummary(
                    item.ClassId,
                    item.Name,
                    item.Code,
                    item.MemberType,
                    item.JoinedAt)).ToArray() as IReadOnlyCollection<TenantAdminStudentClassSummary>);

        return new TenantAdminStudentList(
            memberships.Select(membership =>
            {
                users.TryGetValue(membership.UserId, out var user);
                profiles.TryGetValue(membership.UserId, out var profile);
                classLookup.TryGetValue(membership.UserId, out var studentClasses);
                return ToStudentItem(
                    membership,
                    user ?? new User { Id = membership.UserId },
                    profile,
                    regions,
                    schools,
                    majors,
                    studentClasses ?? []);
            }).ToArray(),
            Scoped: scope.Mode != DataScopeMode.All);
    }

    public async Task<ContentManagementResult<TenantAdminStudentItem>> UpsertStudentAsync(
        TenantAdminActor actor,
        UpsertTenantAdminStudentCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        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);
        await using var transaction = dbContext.Database.CurrentTransaction is null
            ? await dbContext.Database.BeginTransactionAsync(cancellationToken)
            : null;
        var user = await ResolveUserAsync(command.User, "student", cancellationToken);
        if (!scope.AllowsResource(actor.UserId, user.Id, command.RegionId))
        {
            throw new TenantAdminDirectException("Student was not found.", "student_not_found");
        }
        if (command.RawProfile.ValueKind == JsonValueKind.Object)
        {
            user.RawProfile = command.RawProfile.Clone();
        }

        var membership = await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken);
        var profile = await EnsureStudentProfileAsync(
            actor.TenantId,
            user.Id,
            command.RegionId,
            command.SelectedSchoolId,
            command.SelectedMajorId,
            Normalize(command.AvatarPreset),
            command.Stats,
            command.Progress,
            command.ModuleSelections,
            cancellationToken);

        await AddAuditAsync(actor, "tenant.student.upserted", "student_profiles", profile.Id, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        if (transaction is not null)
        {
            await transaction.CommitAsync(cancellationToken);
        }

        return new ContentManagementResult<TenantAdminStudentItem>(
            ToStudentItem(
                membership,
                user,
                profile,
                new Dictionary<Guid, string>(),
                new Dictionary<Guid, string>(),
                new Dictionary<Guid, string>(),
                []));
    }

    public async Task<ContentManagementResult<TenantAdminStudentStatusItem>> UpdateStudentStatusAsync(
        TenantAdminActor actor,
        UpdateTenantAdminStudentStatusCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_student_status");
        var regionIds = scope.RegionIds.ToArray();
        var classIds = scope.ClassIds.ToArray();
        var membership = await dbContext.TenantMemberships
            .Where(item =>
                item.TenantId == actor.TenantId &&
                item.UserId == command.UserId &&
                item.Role == TenantRole.Student)
            .ApplyDataScope(
                scope,
                item => item.UserId == actor.UserId,
                item => dbContext.StudentProfiles.Any(profile =>
                            profile.TenantId == actor.TenantId &&
                            profile.UserId == item.UserId &&
                            profile.RegionId.HasValue &&
                            regionIds.Contains(profile.RegionId.Value)) ||
                        dbContext.TenantClassMembers.Any(member =>
                            member.TenantId == actor.TenantId &&
                            member.UserId == item.UserId &&
                            member.Status == TenantClassMemberStatus.Active &&
                            classIds.Contains(member.ClassId)))
            .FirstOrDefaultAsync(cancellationToken);
        if (membership is null)
        {
            throw new TenantAdminDirectException("Student membership was not found.", "student_not_found");
        }

        await using var transaction = dbContext.Database.CurrentTransaction is null
            ? await dbContext.Database.BeginTransactionAsync(cancellationToken)
            : null;
        var wasCounted = await IsUserCountedForMetricAsync(
            actor.TenantId, membership.UserId, SaasQuotaMetricCatalog.StudentCount, cancellationToken);
        var otherMembershipCounted = await IsUserCountedForMetricAsync(
            actor.TenantId, membership.UserId, SaasQuotaMetricCatalog.StudentCount, cancellationToken, membership.Id);
        var willBeCounted = otherMembershipCounted || status == MembershipStatus.Active;
        if (!wasCounted && willBeCounted)
        {
            await featureAccessService.ConsumeQuotaIfConfiguredAsync(
                actor.TenantId,
                SaasQuotaMetricCatalog.StudentCount,
                cancellationToken: cancellationToken);
        }
        membership.Status = status;
        if (status != MembershipStatus.Active)
        {
            await sessionStore.RevokeRealmAsync(
                command.UserId, AuthRealm.Tenant, actor.TenantId, "membership_disabled", cancellationToken);
        }

        await AddAuditAsync(actor, "tenant.student.status_updated", "tenant_memberships", membership.Id, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        if (wasCounted && !willBeCounted)
        {
            await featureAccessService.ReleaseQuotaAsync(
                actor.TenantId,
                SaasQuotaMetricCatalog.StudentCount,
                1,
                cancellationToken);
        }
        if (transaction is not null)
        {
            await transaction.CommitAsync(cancellationToken);
        }
        return new ContentManagementResult<TenantAdminStudentStatusItem>(
            new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt));
    }

    public async Task<TenantAdminStudentImportPreview> PreviewStudentImportAsync(
        TenantAdminActor actor,
        TenantAdminStudentImportCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var items = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken);
        return new TenantAdminStudentImportPreview(
            command.Rows.Count,
            items.Count(item => item.Valid),
            items.Count(item => !item.Valid),
            items);
    }

    public async Task<TenantAdminStudentImportResult> ImportStudentsAsync(
        TenantAdminActor actor,
        TenantAdminStudentImportCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var previewItems = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken);
        var invalidItems = previewItems.Where(item => !item.Valid).ToArray();
        await using var transaction = dbContext.Database.CurrentTransaction is null
            ? await dbContext.Database.BeginTransactionAsync(cancellationToken)
            : null;
        var createdOrUpdated = 0;
        var classAssigned = 0;
        var rowNo = 0;
        foreach (var row in command.Rows)
        {
            rowNo++;
            if (invalidItems.Any(item => item.RowNo == rowNo))
            {
                continue;
            }

            var user = await ResolveUserAsync(row.User, "student", cancellationToken);
            if (row.RawProfile.ValueKind == JsonValueKind.Object)
            {
                user.RawProfile = row.RawProfile.Clone();
            }

            await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken);
            await EnsureStudentProfileAsync(
                actor.TenantId,
                user.Id,
                row.RegionId,
                null,
                null,
                Normalize(row.AvatarPreset),
                JsonDefaults.Object(),
                JsonDefaults.Object(),
                JsonDefaults.Object(),
                cancellationToken);
            createdOrUpdated++;

            if (row.ClassId.HasValue)
            {
                await UpsertClassMemberCoreAsync(
                    actor,
                    row.ClassId.Value,
                    user.Id,
                    TenantClassMemberType.Student,
                    TenantClassMemberStatus.Active,
                    JsonSerializer.SerializeToElement(new { source = "student_import" }),
                    cancellationToken);
                classAssigned++;
            }
        }

        await AddAuditAsync(actor, "tenant.student.imported", "student_profiles", actor.TenantId, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        if (transaction is not null)
        {
            await transaction.CommitAsync(cancellationToken);
        }
        return new TenantAdminStudentImportResult(command.Rows.Count, createdOrUpdated, classAssigned, invalidItems);
    }

    public async Task<TenantAdminBulkOperationResult> BulkAssignClassAsync(
        TenantAdminActor actor,
        TenantAdminBulkAssignClassCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        await AssertClassAsync(actor, scope, command.ClassId, cancellationToken);
        var failed = new List<Guid>();
        var succeeded = 0;
        foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct())
        {
            try
            {
                await AssertStudentAsync(actor, scope, userId, cancellationToken);
                await UpsertClassMemberCoreAsync(
                    actor,
                    command.ClassId,
                    userId,
                    TenantClassMemberType.Student,
                    TenantClassMemberStatus.Active,
                    JsonSerializer.SerializeToElement(new { source = "bulk_assign_class" }),
                    cancellationToken);
                succeeded++;
            }
            catch (TenantAdminDirectException)
            {
                failed.Add(userId);
            }
        }

        await AddAuditAsync(actor, "tenant.student.bulk_class_assigned", "tenant_classes", command.ClassId, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed);
    }

    public async Task<TenantAdminBulkOperationResult> BulkUpdateStudentStatusAsync(
        TenantAdminActor actor,
        TenantAdminBulkStatusCommand command,
        CancellationToken cancellationToken = default)
    {
        var failed = new List<Guid>();
        var succeeded = 0;
        foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct())
        {
            try
            {
                await UpdateStudentStatusAsync(
                    actor,
                    new UpdateTenantAdminStudentStatusCommand(userId, command.Status, command.Reason),
                    cancellationToken);
                succeeded++;
            }
            catch (TenantAdminDirectException)
            {
                failed.Add(userId);
            }
        }

        await AddAuditAsync(actor, "tenant.student.bulk_status_updated", "tenant_memberships", actor.TenantId, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed);
    }

    public async Task<CatalogList<TenantSupervisionRuleItem>> GetSupervisionRulesAsync(
        TenantAdminActor actor,
        CancellationToken cancellationToken = default)
    {
        await RequireDataScopeAsync(actor, cancellationToken);
        return new CatalogList<TenantSupervisionRuleItem>(await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken));
    }

    public async Task<ContentManagementResult<TenantSupervisionRuleItem>> UpsertSupervisionRuleAsync(
        TenantAdminActor actor,
        UpsertTenantSupervisionRuleCommand command,
        CancellationToken cancellationToken = default)
    {
        await RequireDataScopeAsync(actor, cancellationToken);
        var code = NormalizeCode(command.Code);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.Title);
        var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)).ToList();
        var item = new TenantSupervisionRuleItem(
            code,
            command.Title.Trim(),
            command.Enabled,
            command.DaysWithoutCheckIn,
            command.MaxQuestionsAnsweredToday,
            Normalize(command.FollowupType) ?? StudentFollowupType.Risk.ToString(),
            Normalize(command.Priority) ?? StudentFollowupPriority.High.ToString(),
            JsonObjectOrDefault(command.Metadata));
        rules.RemoveAll(rule => string.Equals(rule.Code, code, StringComparison.Ordinal));
        rules.Add(item);
        await SaveSupervisionRulesCoreAsync(actor.TenantId, rules.OrderBy(rule => rule.Code).ToArray(), cancellationToken);
        await AddAuditAsync(actor, "tenant.supervision_rule.upserted", "tenant_settings", actor.TenantId, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<TenantSupervisionRuleItem>(item);
    }

    public async Task<TenantSupervisionPreview> PreviewSupervisionAsync(
        TenantAdminActor actor,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        return new TenantSupervisionPreview(await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken));
    }

    public async Task<TenantSupervisionGenerateResult> GenerateSupervisionFollowupsAsync(
        TenantAdminActor actor,
        TenantSupervisionGenerateCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken);
        var riskStudents = await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken);
        if (command.UserIds is { Count: > 0 })
        {
            var selected = command.UserIds.Where(id => id != Guid.Empty).Distinct().ToHashSet();
            riskStudents = riskStudents.Where(item => selected.Contains(item.UserId)).ToArray();
        }

        var created = 0;
        foreach (var student in riskStudents)
        {
            if (await dbContext.TenantStudentFollowups.AnyAsync(item =>
                    item.TenantId == actor.TenantId &&
                    item.StudentUserId == student.UserId &&
                    item.Status != StudentFollowupStatus.Done &&
                    item.FollowupType == StudentFollowupType.Risk,
                    cancellationToken))
            {
                continue;
            }

            dbContext.TenantStudentFollowups.Add(new TenantStudentFollowup
            {
                TenantId = actor.TenantId,
                StudentUserId = student.UserId,
                AssignedToUserId = command.AssignedToUserId,
                Title = "学习风险督导",
                Description = string.Join("；", student.Reasons),
                FollowupType = StudentFollowupType.Risk,
                Priority = StudentFollowupPriority.High,
                Status = StudentFollowupStatus.Open,
                DueAt = command.DueAt,
                CreatedBy = actor.UserId,
                UpdatedBy = actor.UserId,
                Metadata = JsonSerializer.SerializeToElement(new { ruleCodes = student.RuleCodes })
            });
            created++;
        }

        await AddAuditAsync(actor, "tenant.supervision_followups.generated", "tenant_student_followups", actor.TenantId, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return new TenantSupervisionGenerateResult(created);
    }

    public async Task<TenantFollowupReport> GetFollowupReportAsync(
        TenantAdminActor actor,
        CancellationToken cancellationToken = default)
    {
        await RequireDataScopeAsync(actor, cancellationToken);
        var now = DateTimeOffset.UtcNow;
        return new TenantFollowupReport(
            await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Open, cancellationToken),
            await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.InProgress, cancellationToken),
            await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Done, cancellationToken),
            await dbContext.TenantStudentFollowups.CountAsync(item =>
                item.TenantId == actor.TenantId &&
                item.DueAt.HasValue &&
                item.DueAt < now &&
                item.Status != StudentFollowupStatus.Done &&
                item.Status != StudentFollowupStatus.Cancelled,
                cancellationToken));
    }

    public async Task<TenantFeedbackReport> GetFeedbackReportAsync(
        TenantAdminActor actor,
        CancellationToken cancellationToken = default)
    {
        await RequireDataScopeAsync(actor, cancellationToken);
        return new TenantFeedbackReport(
            await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Pending, cancellationToken),
            await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Accepted, cancellationToken),
            await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Rejected, cancellationToken),
            await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Resolved, cancellationToken),
            await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Closed, cancellationToken));
    }

    public async Task<TenantPointRiskReport> GetPointRiskReportAsync(
        TenantAdminActor actor,
        CancellationToken cancellationToken = default)
    {
        await RequireDataScopeAsync(actor, cancellationToken);
        var negativeScoreUsers = await dbContext.Users
            .Where(user => user.Score < 0 &&
                           dbContext.TenantMemberships.Any(member =>
                               member.TenantId == actor.TenantId &&
                               member.UserId == user.Id &&
                               member.Status == MembershipStatus.Active))
            .CountAsync(cancellationToken);
        var since = DateTimeOffset.UtcNow.AddDays(-7);
        var highClaimUsers = await dbContext.PointActivityClaims
            .Where(item => item.TenantId == actor.TenantId && item.Status == PointActivityClaimStatus.Claimed && item.ClaimedAt >= since)
            .GroupBy(item => item.UserId)
            .Where(group => group.Sum(item => item.Points) >= 1000)
            .CountAsync(cancellationToken);
        var cancelledExchangeOrders = await dbContext.PointExchangeOrders.CountAsync(
            item => item.TenantId == actor.TenantId && item.Status == PointExchangeOrderStatus.Cancelled,
            cancellationToken);
        return new TenantPointRiskReport(negativeScoreUsers, highClaimUsers, cancelledExchangeOrders);
    }

    public async Task<CatalogList<TenantAdminStudentNoteItem>> GetStudentNotesAsync(
        TenantAdminActor actor,
        TenantAdminStudentActivityFilter filter,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var classIds = scope.ClassIds.ToArray();
        var query = dbContext.TenantStudentNotes.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId)
            .ApplyDataScope(
                scope,
                item => item.StudentUserId == actor.UserId || item.CreatedBy == actor.UserId,
                item => dbContext.StudentProfiles.Any(profile =>
                            profile.TenantId == actor.TenantId &&
                            profile.UserId == item.StudentUserId &&
                            profile.RegionId.HasValue &&
                            regionIds.Contains(profile.RegionId.Value)) ||
                        dbContext.TenantClassMembers.Any(member =>
                            member.TenantId == actor.TenantId &&
                            member.UserId == item.StudentUserId &&
                            member.Status == TenantClassMemberStatus.Active &&
                            classIds.Contains(member.ClassId)));
        if (filter.StudentUserId.HasValue)
        {
            query = query.Where(item => item.StudentUserId == filter.StudentUserId.Value);
        }

        var items = await query
            .OrderByDescending(item => item.IsPinned)
            .ThenByDescending(item => item.CreatedAt)
            .Take(ResolveLimit(filter.Limit))
            .Select(item => ToNoteItem(item))
            .ToArrayAsync(cancellationToken);
        return new CatalogList<TenantAdminStudentNoteItem>(items);
    }

    public async Task<ContentManagementResult<TenantAdminStudentNoteItem>> UpsertStudentNoteAsync(
        TenantAdminActor actor,
        UpsertTenantAdminStudentNoteCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.Content);
        await AssertStudentAsync(actor, scope, command.StudentUserId, cancellationToken);
        TenantStudentNote? item = null;
        if (command.Id.HasValue)
        {
            var regionIds = scope.RegionIds.ToArray();
            var classIds = scope.ClassIds.ToArray();
            item = await dbContext.TenantStudentNotes
                .Where(note => note.TenantId == actor.TenantId && note.Id == command.Id.Value)
                .ApplyDataScope(
                    scope,
                    note => note.StudentUserId == actor.UserId || note.CreatedBy == actor.UserId,
                    note => dbContext.StudentProfiles.Any(profile =>
                                profile.TenantId == actor.TenantId &&
                                profile.UserId == note.StudentUserId &&
                                profile.RegionId.HasValue &&
                                regionIds.Contains(profile.RegionId.Value)) ||
                            dbContext.TenantClassMembers.Any(member =>
                                member.TenantId == actor.TenantId &&
                                member.UserId == note.StudentUserId &&
                                member.Status == TenantClassMemberStatus.Active &&
                                classIds.Contains(member.ClassId)))
                .FirstOrDefaultAsync(cancellationToken);
            if (item is null)
            {
                throw new TenantAdminDirectException("Student note was not found.", "student_note_not_found");
            }
        }

        var isNew = item is null;
        item ??= new TenantStudentNote { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, StudentUserId = command.StudentUserId, CreatedBy = actor.UserId };
        item.NoteType = ParseEnum(command.NoteType, StudentNoteType.General, "invalid_student_note_type");
        item.Content = command.Content.Trim();
        item.Visibility = ParseEnum(command.Visibility, StudentNoteVisibility.TenantStaff, "invalid_student_note_visibility");
        item.IsPinned = command.IsPinned ?? item.IsPinned;
        item.Metadata = JsonObjectOrDefault(command.Metadata);
        item.UpdatedBy = actor.UserId;
        if (isNew)
        {
            dbContext.TenantStudentNotes.Add(item);
        }

        await AddAuditAsync(actor, "tenant.student_note.upserted", "tenant_student_notes", item.Id, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<TenantAdminStudentNoteItem>(ToNoteItem(item));
    }

    public async Task<CatalogList<TenantAdminStudentFollowupItem>> GetStudentFollowupsAsync(
        TenantAdminActor actor,
        TenantAdminStudentActivityFilter filter,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var classIds = scope.ClassIds.ToArray();
        var query = dbContext.TenantStudentFollowups.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId)
            .ApplyDataScope(
                scope,
                item => item.StudentUserId == actor.UserId || item.AssignedToUserId == actor.UserId || item.CreatedBy == actor.UserId,
                item => (item.ClassId.HasValue && classIds.Contains(item.ClassId.Value)) ||
                        dbContext.StudentProfiles.Any(profile =>
                            profile.TenantId == actor.TenantId &&
                            profile.UserId == item.StudentUserId &&
                            profile.RegionId.HasValue &&
                            regionIds.Contains(profile.RegionId.Value)) ||
                        dbContext.TenantClassMembers.Any(member =>
                            member.TenantId == actor.TenantId &&
                            member.UserId == item.StudentUserId &&
                            member.Status == TenantClassMemberStatus.Active &&
                            classIds.Contains(member.ClassId)));
        if (filter.StudentUserId.HasValue)
        {
            query = query.Where(item => item.StudentUserId == filter.StudentUserId.Value);
        }

        if (!string.IsNullOrWhiteSpace(filter.Status))
        {
            query = query.Where(item => item.Status == ParseEnum<StudentFollowupStatus>(filter.Status, "invalid_student_followup_status"));
        }

        var items = await query
            .OrderBy(item => item.Status == StudentFollowupStatus.Done || item.Status == StudentFollowupStatus.Cancelled)
            .ThenBy(item => item.DueAt ?? DateTimeOffset.MaxValue)
            .ThenByDescending(item => item.CreatedAt)
            .Take(ResolveLimit(filter.Limit))
            .Select(item => ToFollowupItem(item))
            .ToArrayAsync(cancellationToken);
        return new CatalogList<TenantAdminStudentFollowupItem>(items);
    }

    public async Task<ContentManagementResult<TenantAdminStudentFollowupItem>> UpsertStudentFollowupAsync(
        TenantAdminActor actor,
        UpsertTenantAdminStudentFollowupCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.Title);
        await AssertStudentAsync(actor, scope, command.StudentUserId, cancellationToken);
        await AssertClassAsync(actor, scope, command.ClassId, cancellationToken);
        await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken);

        TenantStudentFollowup? item = null;
        if (command.Id.HasValue)
        {
            var regionIds = scope.RegionIds.ToArray();
            var classIds = scope.ClassIds.ToArray();
            item = await dbContext.TenantStudentFollowups
                .Where(followup => followup.TenantId == actor.TenantId && followup.Id == command.Id.Value)
                .ApplyDataScope(
                    scope,
                    followup => followup.StudentUserId == actor.UserId ||
                                followup.AssignedToUserId == actor.UserId ||
                                followup.CreatedBy == actor.UserId,
                    followup => (followup.ClassId.HasValue && classIds.Contains(followup.ClassId.Value)) ||
                                dbContext.StudentProfiles.Any(profile =>
                                    profile.TenantId == actor.TenantId &&
                                    profile.UserId == followup.StudentUserId &&
                                    profile.RegionId.HasValue &&
                                    regionIds.Contains(profile.RegionId.Value)) ||
                                dbContext.TenantClassMembers.Any(member =>
                                    member.TenantId == actor.TenantId &&
                                    member.UserId == followup.StudentUserId &&
                                    member.Status == TenantClassMemberStatus.Active &&
                                    classIds.Contains(member.ClassId)))
                .FirstOrDefaultAsync(cancellationToken);
            if (item is null)
            {
                throw new TenantAdminDirectException("Student followup was not found.", "student_followup_not_found");
            }
        }

        var isNew = item is null;
        item ??= new TenantStudentFollowup
        {
            Id = command.Id ?? Guid.NewGuid(),
            TenantId = actor.TenantId,
            StudentUserId = command.StudentUserId,
            CreatedBy = actor.UserId
        };
        item.AssignedToUserId = command.AssignedToUserId;
        item.ClassId = command.ClassId;
        item.Title = command.Title.Trim();
        item.Description = Normalize(command.Description);
        item.FollowupType = ParseEnum(command.FollowupType, StudentFollowupType.Learning, "invalid_student_followup_type");
        item.Priority = ParseEnum(command.Priority, StudentFollowupPriority.Normal, "invalid_student_followup_priority");
        item.Status = ParseEnum(command.Status, StudentFollowupStatus.Open, "invalid_student_followup_status");
        item.DueAt = command.DueAt;
        item.CompletedAt = item.Status == StudentFollowupStatus.Done ? DateTimeOffset.UtcNow : null;
        item.CompletedBy = item.Status == StudentFollowupStatus.Done ? actor.UserId : null;
        item.Metadata = JsonObjectOrDefault(command.Metadata);
        item.UpdatedBy = actor.UserId;
        if (isNew)
        {
            dbContext.TenantStudentFollowups.Add(item);
        }

        await AddAuditAsync(actor, "tenant.student_followup.upserted", "tenant_student_followups", item.Id, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<TenantAdminStudentFollowupItem>(ToFollowupItem(item));
    }

    public async Task<CatalogList<TenantAdminMemberItem>> GetMembersAsync(
        TenantAdminActor actor,
        TenantAdminMemberFilter filter,
        CancellationToken cancellationToken = default)
    {
        await RequireAllDataScopeAsync(actor, cancellationToken);
        var query = dbContext.TenantMemberships.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
        if (!string.IsNullOrWhiteSpace(filter.Role))
        {
            query = query.Where(item => item.Role == ParseEnum<TenantRole>(filter.Role, "invalid_member_role"));
        }

        if (!string.IsNullOrWhiteSpace(filter.Status))
        {
            query = query.Where(item => item.Status == ParseEnum<MembershipStatus>(filter.Status, "invalid_member_status"));
        }

        if (!string.IsNullOrWhiteSpace(filter.Keyword))
        {
            var keyword = filter.Keyword.Trim();
            query = query.Where(item => dbContext.Users.Any(user =>
                user.Id == item.UserId &&
                ((user.UserName != null && user.UserName.Contains(keyword)) ||
                 (user.Phone != null && user.Phone.Contains(keyword)) ||
                 (user.Email != null && user.Email.Contains(keyword)) ||
                 (user.Name != null && user.Name.Contains(keyword)))));
        }

        var memberships = await query
            .OrderBy(item => item.Role == TenantRole.TenantOwner ? 0 :
                item.Role == TenantRole.TenantAdmin ? 1 :
                item.Role == TenantRole.TenantOperator ? 2 :
                item.Role == TenantRole.Teacher ? 3 : 9)
            .ThenBy(item => item.CreatedAt)
            .Take(ResolveLimit(filter.Limit))
            .ToArrayAsync(cancellationToken);
        var userIds = memberships.Select(item => item.UserId).ToArray();
        var users = await dbContext.Users.AsNoTracking()
            .Where(user => userIds.Contains(user.Id))
            .ToDictionaryAsync(user => user.Id, cancellationToken);

        return new CatalogList<TenantAdminMemberItem>(memberships.Select(item =>
        {
            users.TryGetValue(item.UserId, out var user);
            return ToMemberItem(item, user ?? new User { Id = item.UserId });
        }).ToArray());
    }

    public async Task<ContentManagementResult<TenantAdminMemberItem>> UpsertMemberAsync(
        TenantAdminActor actor,
        UpsertTenantAdminMemberCommand command,
        CancellationToken cancellationToken = default)
    {
        await RequireAllDataScopeAsync(actor, cancellationToken);
        await using var transaction = dbContext.Database.CurrentTransaction is null
            ? await dbContext.Database.BeginTransactionAsync(cancellationToken)
            : null;
        var role = ParseEnum(command.Role, TenantRole.Student, "invalid_member_role");
        var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_member_status");
        await AssertGrantableAsync(actor, role, cancellationToken);
        var primaryRole = Normalize(command.PrimaryRole) ?? RoleToPrimaryRole(role);
        var user = await ResolveUserAsync(command.User, primaryRole, cancellationToken);
        if (user.Id == actor.UserId && status == MembershipStatus.Disabled)
        {
            throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self");
        }

        TenantMembership? membership = null;
        if (command.MembershipId.HasValue)
        {
            membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(
                item => item.TenantId == actor.TenantId && item.Id == command.MembershipId.Value,
                cancellationToken);
            if (membership is null)
            {
                throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found");
            }
        }
        else
        {
            membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(
                item => item.TenantId == actor.TenantId && item.UserId == user.Id && item.Role == role,
                cancellationToken);
        }

        var isNew = membership is null;
        var previousStatus = membership?.Status.ToString() ?? "none";
        var wasStaffCounted = await IsUserCountedForMetricAsync(
            actor.TenantId, user.Id, SaasQuotaMetricCatalog.StaffCount, cancellationToken);
        var wasStudentCounted = await IsUserCountedForMetricAsync(
            actor.TenantId, user.Id, SaasQuotaMetricCatalog.StudentCount, cancellationToken);
        Guid? excludedMembershipId = isNew ? null : membership!.Id;
        var otherStaffCounted = await IsUserCountedForMetricAsync(
            actor.TenantId, user.Id, SaasQuotaMetricCatalog.StaffCount, cancellationToken, excludedMembershipId);
        var otherStudentCounted = await IsUserCountedForMetricAsync(
            actor.TenantId, user.Id, SaasQuotaMetricCatalog.StudentCount, cancellationToken, excludedMembershipId);
        var willStaffBeCounted = otherStaffCounted || (status == MembershipStatus.Active && role != TenantRole.Student);
        var willStudentBeCounted = otherStudentCounted || (status == MembershipStatus.Active && role == TenantRole.Student);
        if (membership?.Role == TenantRole.TenantOwner && role != TenantRole.TenantOwner)
        {
            throw new TenantAdminDirectException("Tenant owner membership cannot be downgraded.", "tenant_owner_required");
        }

        if (!wasStaffCounted && willStaffBeCounted)
        {
            await featureAccessService.ConsumeQuotaIfConfiguredAsync(
                actor.TenantId,
                SaasQuotaMetricCatalog.StaffCount,
                cancellationToken: cancellationToken);
        }
        if (!wasStudentCounted && willStudentBeCounted)
        {
            await featureAccessService.ConsumeQuotaIfConfiguredAsync(
                actor.TenantId,
                SaasQuotaMetricCatalog.StudentCount,
                cancellationToken: cancellationToken);
        }

        membership ??= new TenantMembership
        {
            TenantId = actor.TenantId,
            UserId = user.Id
        };
        membership.UserId = user.Id;
        membership.Role = role;
        membership.Status = status;
        if (isNew)
        {
            dbContext.TenantMemberships.Add(membership);
        }

        if (status != MembershipStatus.Active)
        {
            await RevokeSessionsAsync(actor.TenantId, user.Id, cancellationToken);
        }
        else if (role == TenantRole.TenantOwner)
        {
            await EnsureTenantOwnerBackendRoleAsync(actor.TenantId, user.Id, cancellationToken);
        }

        await AddAuditAsync(actor, "tenant.member.upserted", "tenant_memberships", membership.Id, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        if (wasStaffCounted && !willStaffBeCounted)
        {
            await featureAccessService.ReleaseQuotaAsync(
                actor.TenantId,
                SaasQuotaMetricCatalog.StaffCount,
                1,
                cancellationToken);
        }
        if (wasStudentCounted && !willStudentBeCounted)
        {
            await featureAccessService.ReleaseQuotaAsync(
                actor.TenantId,
                SaasQuotaMetricCatalog.StudentCount,
                1,
                cancellationToken);
        }
        if (transaction is not null)
        {
            await transaction.CommitAsync(cancellationToken);
        }
        return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
    }

    public async Task<ContentManagementResult<TenantAdminMemberItem>> DisableMemberAsync(
        TenantAdminActor actor,
        Guid membershipId,
        CancellationToken cancellationToken = default)
    {
        await RequireAllDataScopeAsync(actor, cancellationToken);
        await using var transaction = dbContext.Database.CurrentTransaction is null
            ? await dbContext.Database.BeginTransactionAsync(cancellationToken)
            : null;
        var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(
            item => item.TenantId == actor.TenantId && item.Id == membershipId,
            cancellationToken);
        if (membership is null)
        {
            throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found");
        }

        if (membership.UserId == actor.UserId)
        {
            throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self");
        }

        await AssertGrantableAsync(actor, membership.Role, cancellationToken);
        var previousStatus = membership.Status;
        var metricCode = QuotaMetricForRole(membership.Role);
        var wasCounted = await IsUserCountedForMetricAsync(
            actor.TenantId, membership.UserId, metricCode, cancellationToken);
        var otherMembershipCounted = await IsUserCountedForMetricAsync(
            actor.TenantId, membership.UserId, metricCode, cancellationToken, membership.Id);
        membership.Status = MembershipStatus.Disabled;
        await RevokeSessionsAsync(actor.TenantId, membership.UserId, cancellationToken);
        await AddAuditAsync(actor, "tenant.member.disabled", "tenant_memberships", membership.Id, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        if (previousStatus == MembershipStatus.Active && wasCounted && !otherMembershipCounted)
        {
            await featureAccessService.ReleaseQuotaAsync(
                actor.TenantId,
                metricCode,
                1,
                cancellationToken);
        }
        if (transaction is not null)
        {
            await transaction.CommitAsync(cancellationToken);
        }
        var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken);
        return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
    }

    public async Task<CatalogList<TenantAdminAuditLogItem>> GetAuditLogsAsync(
        TenantAdminActor actor,
        TenantAdminAuditLogFilter filter,
        CancellationToken cancellationToken = default)
    {
        await RequireAllDataScopeAsync(actor, cancellationToken);
        var query = dbContext.AuditLogs.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
        if (!string.IsNullOrWhiteSpace(filter.Action))
        {
            var action = filter.Action.Trim();
            query = query.Where(item => item.Action.StartsWith(action));
        }

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

        if (filter.ActorUserId.HasValue)
        {
            query = query.Where(item => item.ActorUserId == filter.ActorUserId.Value);
        }

        var logs = await query
            .OrderByDescending(item => item.CreatedAt)
            .Take(ResolveLimit(filter.Limit))
            .ToArrayAsync(cancellationToken);
        var actorIds = logs.Where(item => item.ActorUserId.HasValue).Select(item => item.ActorUserId!.Value).ToArray();
        var users = await dbContext.Users.AsNoTracking()
            .Where(user => actorIds.Contains(user.Id))
            .ToDictionaryAsync(user => user.Id, cancellationToken);

        return new CatalogList<TenantAdminAuditLogItem>(logs.Select(item =>
        {
            var user = item.ActorUserId.HasValue && users.TryGetValue(item.ActorUserId.Value, out var actorUser)
                ? actorUser
                : null;
            return new TenantAdminAuditLogItem(
                item.Id,
                item.ActorUserId,
                item.Action,
                item.TargetType,
                item.TargetId,
                item.Details,
                item.IpAddress,
                item.UserAgent,
                user?.Name ?? user?.UserName,
                user?.Phone,
                item.CreatedAt);
        }).ToArray());
    }

    public async Task<ContentManagementResult<TenantBrandingItem>> UpsertBrandingAsync(
        TenantAdminActor actor,
        UpsertTenantBrandingCommand command,
        CancellationToken cancellationToken = default)
    {
        await RequireAllDataScopeAsync(actor, cancellationToken);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.BrandName);
        var item = await dbContext.TenantBrandings.FirstOrDefaultAsync(branding => branding.TenantId == actor.TenantId, cancellationToken);
        if (item is null)
        {
            item = new TenantBranding { TenantId = actor.TenantId };
            dbContext.TenantBrandings.Add(item);
        }

        item.BrandName = command.BrandName.Trim();
        item.ShortName = Normalize(command.ShortName);
        item.Slogan = Normalize(command.Slogan);
        item.OrganizationName = Normalize(command.OrganizationName);
        item.LogoUrl = Normalize(command.LogoUrl);
        item.FaviconUrl = Normalize(command.FaviconUrl);
        item.ServiceWechat = Normalize(command.ServiceWechat);
        item.ServiceAccountName = Normalize(command.ServiceAccountName);
        await AddAuditAsync(actor, "tenant.branding.updated", "tenant_branding", actor.TenantId, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<TenantBrandingItem>(ToBrandingItem(item));
    }

    public async Task<ContentManagementResult<TenantSettingsItem>> UpsertSettingsAsync(
        TenantAdminActor actor,
        UpsertTenantSettingsCommand command,
        CancellationToken cancellationToken = default)
    {
        await RequireAllDataScopeAsync(actor, cancellationToken);
        AssertNoSecrets(command.PublicConfig, "public_config");
        var item = await dbContext.TenantSettings.FirstOrDefaultAsync(settings => settings.TenantId == actor.TenantId, cancellationToken);
        if (item is null)
        {
            item = new TenantSettings { TenantId = actor.TenantId };
            dbContext.TenantSettings.Add(item);
        }

        item.FeatureFlags = JsonObjectOrDefault(command.FeatureFlags);
        item.AdminFeatureFlags = JsonObjectOrDefault(command.AdminFeatureFlags);
        item.PublicConfig = JsonObjectOrDefault(command.PublicConfig);
        await AddAuditAsync(actor, "tenant.settings.updated", "tenant_settings", actor.TenantId, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<TenantSettingsItem>(ToSettingsItem(item));
    }

    public async Task<CatalogList<TenantThemeTemplateItem>> GetThemeTemplatesAsync(
        TenantAdminActor actor,
        CancellationToken cancellationToken = default)
    {
        await RequireAllDataScopeAsync(actor, cancellationToken);
        await Task.CompletedTask.WaitAsync(cancellationToken);
        var items = await dbContext.TenantThemeTemplates.AsNoTracking()
            .Where(item => item.Status == TenantThemeTemplateStatus.Active)
            .OrderBy(item => item.SortOrder)
            .ThenBy(item => item.Code)
            .Select(item => new TenantThemeTemplateItem(
                item.Code,
                item.Name,
                item.Description,
                item.PreviewImageUrl,
                item.Theme,
                item.PublicAssets,
                item.SortOrder))
            .ToArrayAsync(cancellationToken);
        return new CatalogList<TenantThemeTemplateItem>(items);
    }

    public async Task<ContentManagementResult<TenantThemeItem>> GetThemeAsync(
        TenantAdminActor actor,
        CancellationToken cancellationToken = default)
    {
        await RequireAllDataScopeAsync(actor, cancellationToken);
        var item = await dbContext.TenantThemeConfigs.AsNoTracking()
            .FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken);
        if (item is not null)
        {
            return new ContentManagementResult<TenantThemeItem>(ToThemeItem(item));
        }

        var branding = await dbContext.TenantBrandings.AsNoTracking()
            .FirstOrDefaultAsync(tenantBranding => tenantBranding.TenantId == actor.TenantId, cancellationToken);
        return new ContentManagementResult<TenantThemeItem>(new TenantThemeItem(
            actor.TenantId,
            null,
            branding?.Theme ?? JsonDefaults.Object(),
            branding?.PublicAssets ?? JsonDefaults.Object(),
            null,
            JsonDefaults.Object(),
            JsonDefaults.Object(),
            TenantThemeConfigStatus.Published,
            null,
            null,
            null,
            branding?.UpdatedAt ?? DateTimeOffset.UtcNow));
    }

    public async Task<ContentManagementResult<TenantThemeItem>> PreviewThemeAsync(
        TenantAdminActor actor,
        PreviewTenantThemeCommand command,
        CancellationToken cancellationToken = default)
    {
        await RequireAllDataScopeAsync(actor, cancellationToken);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.TemplateCode);
        var template = await dbContext.TenantThemeTemplates.FirstOrDefaultAsync(
            item => item.Code == command.TemplateCode && item.Status == TenantThemeTemplateStatus.Active,
            cancellationToken);
        if (template is null)
        {
            throw new TenantAdminDirectException("Theme template was not found.", "theme_template_not_found");
        }

        var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken);
        if (item is null)
        {
            item = new TenantThemeConfig { TenantId = actor.TenantId };
            dbContext.TenantThemeConfigs.Add(item);
        }

        item.DraftTemplateCode = template.Code;
        item.DraftTheme = MergeJsonObjects(template.Theme, command.Theme);
        item.DraftPublicAssets = MergeJsonObjects(template.PublicAssets, command.PublicAssets);
        item.Status = TenantThemeConfigStatus.Draft;
        item.DraftUpdatedBy = actor.UserId;
        await AddAuditAsync(actor, "tenant.theme.previewed", "tenant_theme_configs", actor.TenantId, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<TenantThemeItem>(ToThemeItem(item));
    }

    public async Task<ContentManagementResult<TenantThemeItem>> PublishThemeAsync(
        TenantAdminActor actor,
        PublishTenantThemeCommand command,
        CancellationToken cancellationToken = default)
    {
        await RequireAllDataScopeAsync(actor, cancellationToken);
        var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken);
        JsonElement activeTheme;
        JsonElement activeAssets;
        string? templateCode;
        if (command.UseDraft)
        {
            if (item is null || string.IsNullOrWhiteSpace(item.DraftTemplateCode))
            {
                throw new TenantAdminDirectException("No draft theme to publish.", "theme_draft_not_found");
            }

            activeTheme = item.DraftTheme;
            activeAssets = item.DraftPublicAssets;
            templateCode = item.DraftTemplateCode;
        }
        else
        {
            ArgumentException.ThrowIfNullOrWhiteSpace(command.TemplateCode);
            var template = await dbContext.TenantThemeTemplates.FirstOrDefaultAsync(
                theme => theme.Code == command.TemplateCode && theme.Status == TenantThemeTemplateStatus.Active,
                cancellationToken);
            if (template is null)
            {
                throw new TenantAdminDirectException("Theme template was not found.", "theme_template_not_found");
            }

            item ??= new TenantThemeConfig { TenantId = actor.TenantId };
            if (dbContext.Entry(item).State == EntityState.Detached)
            {
                dbContext.TenantThemeConfigs.Add(item);
            }

            activeTheme = MergeJsonObjects(template.Theme, command.Theme);
            activeAssets = MergeJsonObjects(template.PublicAssets, command.PublicAssets);
            templateCode = template.Code;
        }

        item ??= new TenantThemeConfig { TenantId = actor.TenantId };
        if (dbContext.Entry(item).State == EntityState.Detached)
        {
            dbContext.TenantThemeConfigs.Add(item);
        }

        item.ActiveTemplateCode = templateCode;
        item.ActiveTheme = activeTheme.Clone();
        item.ActivePublicAssets = activeAssets.Clone();
        item.DraftTemplateCode = null;
        item.DraftTheme = JsonDefaults.Object();
        item.DraftPublicAssets = JsonDefaults.Object();
        item.Status = TenantThemeConfigStatus.Published;
        item.PublishedAt = DateTimeOffset.UtcNow;
        item.PublishedBy = actor.UserId;
        await EnsureBrandingThemeAsync(actor.TenantId, activeTheme, activeAssets, cancellationToken);
        await AddAuditAsync(actor, "tenant.theme.published", "tenant_theme_configs", actor.TenantId, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<TenantThemeItem>(ToThemeItem(item));
    }

    public async Task<CatalogList<TenantDomainItem>> GetDomainsAsync(
        TenantAdminActor actor,
        CancellationToken cancellationToken = default)
    {
        await RequireAllDataScopeAsync(actor, cancellationToken);
        var items = await dbContext.TenantDomains.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId)
            .OrderByDescending(item => item.IsPrimary)
            .ThenBy(item => item.CreatedAt)
            .Select(item => ToDomainItem(item))
            .ToArrayAsync(cancellationToken);
        return new CatalogList<TenantDomainItem>(items);
    }

    public async Task<ContentManagementResult<TenantDomainItem>> CreateDomainAsync(
        TenantAdminActor actor,
        CreateTenantDomainCommand command,
        CancellationToken cancellationToken = default)
    {
        await RequireAllDataScopeAsync(actor, cancellationToken);
        var host = NormalizeDomain(command.Host);
        if (command.IsPrimary)
        {
            var primaryDomains = await dbContext.TenantDomains
                .Where(item => item.TenantId == actor.TenantId && item.IsPrimary)
                .ToArrayAsync(cancellationToken);
            foreach (var domain in primaryDomains)
            {
                domain.IsPrimary = false;
            }
        }

        var item = new TenantDomain
        {
            TenantId = actor.TenantId,
            Host = host,
            DomainType = ParseEnum(command.DomainType, TenantDomainType.Custom, "invalid_domain_type"),
            Status = TenantDomainStatus.Pending,
            IsPrimary = command.IsPrimary,
            VerificationToken = $"tenant-{actor.TenantId:N}"[..23]
        };
        dbContext.TenantDomains.Add(item);
        await AddAuditAsync(actor, "tenant.domain.created", "tenant_domains", item.Id, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<TenantDomainItem>(ToDomainItem(item));
    }

    public async Task<CatalogList<TenantIdentityProviderItem>> GetAuthProvidersAsync(
        TenantAdminActor actor,
        CancellationToken cancellationToken = default)
    {
        await RequireAllDataScopeAsync(actor, cancellationToken);
        var items = await providerConfigService.GetProvidersAsync(
            actor.TenantId,
            TenantExternalProviderCapability.Identity,
            cancellationToken: cancellationToken);
        return new CatalogList<TenantIdentityProviderItem>(items.Select(ToAuthProviderItem).ToArray());
    }

    public async Task<ContentManagementResult<TenantIdentityProviderItem>> UpsertAuthProviderAsync(
        TenantAdminActor actor,
        UpsertTenantIdentityProviderCommand command,
        CancellationToken cancellationToken = default)
    {
        await RequireAllDataScopeAsync(actor, cancellationToken);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.Provider);
        var item = await providerConfigService.UpsertProviderAsync(
            actor.TenantId,
            new UpsertTenantExternalProviderCommand(
                TenantExternalProviderCapability.Identity,
                command.Provider,
                ParseEnum(command.Status, TenantExternalProviderStatus.Disabled, "invalid_auth_provider_status"),
                command.DisplayName,
                command.SecretRef,
                command.Priority,
                command.ConfigPublic,
                JsonObjectOrDefault(default)),
            cancellationToken);

        await AddAuditAsync(actor, "tenant.auth_provider.upserted", "tenant_external_providers", item.Id, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<TenantIdentityProviderItem>(ToAuthProviderItem(item));
    }

    public async Task<CatalogList<TenantAdminBadgeItem>> GetBadgesAsync(
        TenantAdminActor actor,
        TenantAdminBadgeFilter filter,
        CancellationToken cancellationToken = default)
    {
        await RequireAllDataScopeAsync(actor, cancellationToken);
        var query = dbContext.Badges.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
        if (!string.IsNullOrWhiteSpace(filter.Category))
        {
            query = query.Where(item => item.Category == filter.Category.Trim());
        }

        if (!filter.IncludeInactive)
        {
            query = query.Where(item => item.IsActive);
        }

        var items = await query
            .OrderBy(item => item.SortOrder)
            .ThenBy(item => item.Level ?? int.MaxValue)
            .ThenByDescending(item => item.CreatedAt)
            .Take(ResolveLimit(filter.Limit))
            .Select(item => ToBadgeItem(item))
            .ToArrayAsync(cancellationToken);
        return new CatalogList<TenantAdminBadgeItem>(items);
    }

    public async Task<ContentManagementResult<TenantAdminBadgeItem>> UpsertBadgeAsync(
        TenantAdminActor actor,
        UpsertTenantAdminBadgeCommand command,
        CancellationToken cancellationToken = default)
    {
        await RequireAllDataScopeAsync(actor, cancellationToken);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
        var item = await ResolveTenantEntityAsync(dbContext.Badges, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
        var isNew = item is null;
        item ??= new Badge { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
        item.LegacyId = Normalize(command.LegacyId);
        item.Name = command.Name.Trim();
        item.Description = Normalize(command.Description);
        item.Category = Normalize(command.Category) ?? "custom";
        item.IconUrl = Normalize(command.IconUrl);
        item.Level = command.Level;
        item.UnlockType = Normalize(command.UnlockType) ?? "manual";
        item.ConditionField = Normalize(command.ConditionField);
        item.ConditionOperator = Normalize(command.ConditionOperator);
        item.ConditionValue = command.ConditionValue;
        item.ConditionExtra = JsonObjectOrDefault(command.ConditionExtra);
        item.SortOrder = command.Order ?? item.SortOrder;
        item.IsActive = command.IsActive ?? item.IsActive;
        if (isNew)
        {
            dbContext.Badges.Add(item);
        }

        await AddAuditAsync(actor, "tenant.badge.upserted", "badges", item.Id, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<TenantAdminBadgeItem>(ToBadgeItem(item));
    }

    public async Task<CatalogList<TenantAdminBadgeGrantItem>> GetBadgeGrantsAsync(
        TenantAdminActor actor,
        TenantAdminBadgeGrantFilter filter,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var classIds = scope.ClassIds.ToArray();
        var query = dbContext.UserBadges.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId)
            .ApplyDataScope(
                scope,
                item => item.UserId == actor.UserId || item.GrantedBy == actor.UserId,
                item => item.UserId.HasValue &&
                        (dbContext.StudentProfiles.Any(profile =>
                             profile.TenantId == actor.TenantId &&
                             profile.UserId == item.UserId.Value &&
                             profile.RegionId.HasValue &&
                             regionIds.Contains(profile.RegionId.Value)) ||
                         dbContext.TenantClassMembers.Any(member =>
                             member.TenantId == actor.TenantId &&
                             member.UserId == item.UserId.Value &&
                             member.Status == TenantClassMemberStatus.Active &&
                             classIds.Contains(member.ClassId))));
        if (filter.UserId.HasValue)
        {
            query = query.Where(item => item.UserId == filter.UserId.Value);
        }

        if (filter.BadgeId.HasValue)
        {
            query = query.Where(item => item.BadgeId == filter.BadgeId.Value);
        }

        var grants = await query
            .OrderByDescending(item => item.GrantedAt ?? item.CreatedAt)
            .Take(ResolveLimit(filter.Limit))
            .ToArrayAsync(cancellationToken);
        var userIds = grants.Select(item => item.UserId).OfType<Guid>().Concat(grants.Select(item => item.GrantedBy).OfType<Guid>()).Distinct().ToArray();
        var badgeIds = grants.Select(item => item.BadgeId).OfType<Guid>().Distinct().ToArray();
        var users = await dbContext.Users.AsNoTracking()
            .Where(user => userIds.Contains(user.Id))
            .ToDictionaryAsync(user => user.Id, cancellationToken);
        var badges = await dbContext.Badges.AsNoTracking()
            .Where(badge => badge.TenantId == actor.TenantId && badgeIds.Contains(badge.Id))
            .ToDictionaryAsync(badge => badge.Id, cancellationToken);

        return new CatalogList<TenantAdminBadgeGrantItem>(grants.Select(grant =>
            ToBadgeGrantItem(
                grant,
                grant.UserId.HasValue && users.TryGetValue(grant.UserId.Value, out var user) ? user : null,
                grant.GrantedBy.HasValue && users.TryGetValue(grant.GrantedBy.Value, out var grantedBy) ? grantedBy : null,
                grant.BadgeId.HasValue && badges.TryGetValue(grant.BadgeId.Value, out var badge) ? badge : null)).ToArray());
    }

    public async Task<ContentManagementResult<TenantAdminBadgeGrantItem>> GrantBadgeAsync(
        TenantAdminActor actor,
        GrantTenantAdminBadgeCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var badge = await dbContext.Badges.FirstOrDefaultAsync(
            item => item.TenantId == actor.TenantId && item.Id == command.BadgeId,
            cancellationToken);
        if (badge is null)
        {
            throw new TenantAdminDirectException("Badge was not found.", "badge_not_found");
        }

        if (!badge.IsActive)
        {
            throw new TenantAdminDirectException("Cannot grant inactive badge.", "badge_inactive");
        }

        await AssertStudentAsync(actor, scope, command.UserId, cancellationToken);
        var grant = await dbContext.UserBadges.FirstOrDefaultAsync(
            item => item.TenantId == actor.TenantId && item.UserId == command.UserId && item.BadgeId == command.BadgeId,
            cancellationToken);
        var isNew = grant is null;
        grant ??= new UserBadge
        {
            TenantId = actor.TenantId,
            UserId = command.UserId,
            BadgeId = command.BadgeId,
            GrantedBy = actor.UserId,
            GrantedAt = command.GrantedAt ?? DateTimeOffset.UtcNow
        };
        grant.LegacyId = Normalize(command.LegacyId) ?? grant.LegacyId;
        grant.Note = Normalize(command.Note) ?? grant.Note;
        grant.GrantedBy ??= actor.UserId;
        grant.GrantedAt ??= command.GrantedAt ?? DateTimeOffset.UtcNow;
        if (isNew)
        {
            dbContext.UserBadges.Add(grant);
        }

        var dedupeKey = $"badge:{grant.Id:N}";
        await notificationProvider.UpsertInAppAsync(
            new InAppNotificationRequest(
                actor.TenantId,
                command.UserId,
                "badge_granted",
                NotificationSeverity.Success,
                $"获得勋章：{badge.Name}",
                Normalize(command.Note) ?? "管理员为你发放了一枚新的学习勋章。",
                actor.UserId,
                "查看勋章",
                "/profile?tab=badges",
                "user_badges",
                grant.Id,
                dedupeKey,
                JsonSerializer.SerializeToElement(new { badgeId = badge.Id, badgeName = badge.Name })),
            cancellationToken);

        await AddAuditAsync(actor, "tenant.badge.granted", "user_badges", grant.Id, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        var user = await dbContext.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == command.UserId, cancellationToken);
        return new ContentManagementResult<TenantAdminBadgeGrantItem>(ToBadgeGrantItem(grant, user, null, badge));
    }

    public async Task<CatalogList<TenantAdminNotificationItem>> GetNotificationsAsync(
        TenantAdminActor actor,
        TenantAdminNotificationFilter filter,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var classIds = scope.ClassIds.ToArray();
        var query = dbContext.UserNotifications.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId)
            .ApplyDataScope(
                scope,
                item => item.UserId == actor.UserId || item.CreatedBy == actor.UserId,
                item => dbContext.StudentProfiles.Any(profile =>
                            profile.TenantId == actor.TenantId &&
                            profile.UserId == item.UserId &&
                            profile.RegionId.HasValue &&
                            regionIds.Contains(profile.RegionId.Value)) ||
                        dbContext.TenantClassMembers.Any(member =>
                            member.TenantId == actor.TenantId &&
                            member.UserId == item.UserId &&
                            member.Status == TenantClassMemberStatus.Active &&
                            classIds.Contains(member.ClassId)));
        if (filter.UserId.HasValue)
        {
            query = query.Where(item => item.UserId == filter.UserId.Value);
        }

        if (!string.IsNullOrWhiteSpace(filter.Status))
        {
            query = query.Where(item => item.Status == ParseEnum<NotificationStatus>(filter.Status, "invalid_notification_status"));
        }

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

        var items = await query
            .OrderByDescending(item => item.CreatedAt)
            .Take(ResolveLimit(filter.Limit))
            .Select(item => ToNotificationItem(item))
            .ToArrayAsync(cancellationToken);
        return new CatalogList<TenantAdminNotificationItem>(items);
    }

    public async Task<ContentManagementResult<TenantAdminNotificationItem>> UpsertNotificationAsync(
        TenantAdminActor actor,
        UpsertTenantAdminNotificationCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.NotificationType);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.Title);
        ArgumentException.ThrowIfNullOrWhiteSpace(command.Message);
        await AssertStudentAsync(actor, scope, command.UserId, cancellationToken);

        var item = await notificationProvider.UpsertInAppAsync(
            new InAppNotificationRequest(
                actor.TenantId,
                command.UserId,
                command.NotificationType,
                ParseEnum(command.Severity, NotificationSeverity.Info, "invalid_notification_severity"),
                command.Title,
                command.Message,
                actor.UserId,
                command.ActionLabel,
                command.ActionPath,
                command.SourceType,
                command.SourceId,
                command.DedupeKey,
                JsonObjectOrDefault(command.Metadata)),
            cancellationToken);

        await AddAuditAsync(actor, "tenant.notification.upserted", "user_notifications", item.Id, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        return new ContentManagementResult<TenantAdminNotificationItem>(ToNotificationItem(item));
    }

    public async Task<CatalogList<TenantAdminFeedbackItem>> GetFeedbacksAsync(
        TenantAdminActor actor,
        TenantAdminFeedbackFilter filter,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var classIds = scope.ClassIds.ToArray();
        var query = dbContext.Reports.AsNoTracking()
            .Where(item => item.TenantId == actor.TenantId)
            .ApplyDataScope(
                scope,
                item => item.UserId == actor.UserId || item.HandledBy == actor.UserId,
                item => item.UserId.HasValue &&
                        (dbContext.StudentProfiles.Any(profile =>
                             profile.TenantId == actor.TenantId &&
                             profile.UserId == item.UserId.Value &&
                             profile.RegionId.HasValue &&
                             regionIds.Contains(profile.RegionId.Value)) ||
                         dbContext.TenantClassMembers.Any(member =>
                             member.TenantId == actor.TenantId &&
                             member.UserId == item.UserId.Value &&
                             member.Status == TenantClassMemberStatus.Active &&
                             classIds.Contains(member.ClassId))));
        if (filter.UserId.HasValue)
        {
            query = query.Where(item => item.UserId == filter.UserId.Value);
        }

        if (!string.IsNullOrWhiteSpace(filter.Status))
        {
            query = query.Where(item => item.Status == ParseEnum<ReportStatus>(filter.Status, "invalid_feedback_status"));
        }

        if (!string.IsNullOrWhiteSpace(filter.Type))
        {
            query = query.Where(item => item.Type == ParseEnum<ReportType>(filter.Type, "invalid_feedback_type"));
        }

        if (!string.IsNullOrWhiteSpace(filter.Keyword))
        {
            var keyword = filter.Keyword.Trim();
            query = query.Where(item =>
                (item.Title != null && item.Title.Contains(keyword)) ||
                (item.Description != null && item.Description.Contains(keyword)) ||
                (item.Contact != null && item.Contact.Contains(keyword)));
        }

        var reports = await query
            .OrderByDescending(item => item.CreatedAt)
            .Take(ResolveLimit(filter.Limit))
            .ToArrayAsync(cancellationToken);
        var userIds = reports.Select(item => item.UserId).OfType<Guid>().Distinct().ToArray();
        var users = await dbContext.Users.AsNoTracking()
            .Where(user => userIds.Contains(user.Id))
            .ToDictionaryAsync(user => user.Id, cancellationToken);
        return new CatalogList<TenantAdminFeedbackItem>(reports.Select(report =>
            ToFeedbackItem(report, report.UserId.HasValue && users.TryGetValue(report.UserId.Value, out var user) ? user : null)).ToArray());
    }

    public async Task<ContentManagementResult<TenantAdminFeedbackItem>> UpdateFeedbackAsync(
        TenantAdminActor actor,
        UpdateTenantAdminFeedbackCommand command,
        CancellationToken cancellationToken = default)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        var regionIds = scope.RegionIds.ToArray();
        var classIds = scope.ClassIds.ToArray();
        var report = await dbContext.Reports
            .Where(item => item.TenantId == actor.TenantId && item.Id == command.FeedbackId)
            .ApplyDataScope(
                scope,
                item => item.UserId == actor.UserId || item.HandledBy == actor.UserId,
                item => item.UserId.HasValue &&
                        (dbContext.StudentProfiles.Any(profile =>
                             profile.TenantId == actor.TenantId &&
                             profile.UserId == item.UserId.Value &&
                             profile.RegionId.HasValue &&
                             regionIds.Contains(profile.RegionId.Value)) ||
                         dbContext.TenantClassMembers.Any(member =>
                             member.TenantId == actor.TenantId &&
                             member.UserId == item.UserId.Value &&
                             member.Status == TenantClassMemberStatus.Active &&
                             classIds.Contains(member.ClassId))))
            .FirstOrDefaultAsync(cancellationToken);
        if (report is null)
        {
            throw new TenantAdminDirectException("Feedback was not found.", "feedback_not_found");
        }

        var fromStatus = report.Status;
        report.Status = ParseEnum(command.Status, report.Status, "invalid_feedback_status");
        report.Priority = ParseEnum(command.Priority, report.Priority, "invalid_feedback_priority");
        report.Resolution = Normalize(command.Resolution) ?? report.Resolution;
        if (report.Status is ReportStatus.Accepted or ReportStatus.Rejected or ReportStatus.Resolved or ReportStatus.Closed)
        {
            report.HandledBy = actor.UserId;
            report.HandledAt = DateTimeOffset.UtcNow;
        }

        dbContext.ReportStatusEvents.Add(new ReportStatusEvent
        {
            TenantId = actor.TenantId,
            ReportId = report.Id,
            FromStatus = fromStatus,
            ToStatus = report.Status,
            Note = Normalize(command.Note),
            ActorUserId = actor.UserId,
            Metadata = JsonObjectOrDefault(command.Metadata)
        });
        await AddAuditAsync(actor, "tenant.feedback.updated", "reports", report.Id, cancellationToken);
        await dbContext.SaveChangesAsync(cancellationToken);
        var user = report.UserId.HasValue
            ? await dbContext.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == report.UserId.Value, cancellationToken)
            : null;
        return new ContentManagementResult<TenantAdminFeedbackItem>(ToFeedbackItem(report, user));
    }

    private async Task<User> ResolveUserAsync(UserLookupCommand command, string primaryRole, CancellationToken cancellationToken)
    {
        User? user = null;
        if (command.UserId.HasValue)
        {
            user = await dbContext.Users.FirstOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken);
            if (user is null)
            {
                throw new TenantAdminDirectException("User was not found.", "user_not_found");
            }
        }
        else
        {
            var phone = Normalize(command.Phone);
            var email = Normalize(command.Email);
            var username = Normalize(command.Username);
            user = await dbContext.Users.FirstOrDefaultAsync(item =>
                (phone != null && item.Phone == phone) ||
                (email != null && item.Email == email) ||
                (username != null && item.UserName == username),
                cancellationToken);

            if (user is null)
            {
                if (phone is null && email is null && username is null && Normalize(command.Name) is null)
                {
                    throw new TenantAdminDirectException("userId, phone, email, username or name is required.", "user_required");
                }

                user = new User
                {
                    UserName = username ?? phone ?? email,
                    Email = email,
                    Phone = phone,
                    Name = Normalize(command.Name) ?? username ?? phone ?? email,
                    PrimaryRole = primaryRole,
                    RawProfile = JsonDefaults.Object()
                };
                dbContext.Users.Add(user);
            }
        }

        user.UserName = Normalize(command.Username) ?? user.UserName;
        user.Email = Normalize(command.Email) ?? user.Email;
        user.Phone = Normalize(command.Phone) ?? user.Phone;
        user.Name = Normalize(command.Name) ?? user.Name;
        user.AvatarUrl = Normalize(command.AvatarUrl) ?? user.AvatarUrl;
        user.PrimaryRole = string.IsNullOrWhiteSpace(user.PrimaryRole) ? primaryRole : user.PrimaryRole;
        return user;
    }

    private async Task<TenantMembership> EnsureMembershipAsync(
        Guid tenantId,
        Guid userId,
        TenantRole role,
        CancellationToken cancellationToken)
    {
        var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(item =>
            item.TenantId == tenantId && item.UserId == userId && item.Role == role,
            cancellationToken);
        if (membership is null)
        {
            var metricCode = QuotaMetricForRole(role);
            if (!await IsUserCountedForMetricAsync(tenantId, userId, metricCode, cancellationToken))
            {
                await featureAccessService.ConsumeQuotaIfConfiguredAsync(
                    tenantId,
                    metricCode,
                    cancellationToken: cancellationToken);
            }
            membership = new TenantMembership
            {
                TenantId = tenantId,
                UserId = userId,
                Role = role,
                Status = MembershipStatus.Active
            };
            dbContext.TenantMemberships.Add(membership);
        }
        else
        {
            if (membership.Status != MembershipStatus.Active)
            {
                var metricCode = QuotaMetricForRole(role);
                if (!await IsUserCountedForMetricAsync(tenantId, userId, metricCode, cancellationToken))
                {
                    await featureAccessService.ConsumeQuotaIfConfiguredAsync(
                        tenantId,
                        metricCode,
                        cancellationToken: cancellationToken);
                }
            }
            membership.Status = MembershipStatus.Active;
        }

        return membership;
    }

    private static string QuotaMetricForRole(TenantRole role) => role == TenantRole.Student
        ? SaasQuotaMetricCatalog.StudentCount
        : SaasQuotaMetricCatalog.StaffCount;

    private Task<bool> IsUserCountedForMetricAsync(
        Guid tenantId,
        Guid userId,
        string metricCode,
        CancellationToken cancellationToken,
        Guid? excludedMembershipId = null)
    {
        var query = dbContext.TenantMemberships.AsNoTracking().Where(item =>
            item.TenantId == tenantId &&
            item.UserId == userId &&
            item.Status == MembershipStatus.Active);
        if (excludedMembershipId.HasValue)
        {
            query = query.Where(item => item.Id != excludedMembershipId.Value);
        }

        return metricCode == SaasQuotaMetricCatalog.StudentCount
            ? query.AnyAsync(item => item.Role == TenantRole.Student, cancellationToken)
            : query.AnyAsync(item => item.Role != TenantRole.Student, cancellationToken);
    }

    private async Task<StudentProfile> EnsureStudentProfileAsync(
        Guid tenantId,
        Guid userId,
        Guid? regionId,
        Guid? schoolId,
        Guid? majorId,
        string? avatarPreset,
        JsonElement stats,
        JsonElement progress,
        JsonElement moduleSelections,
        CancellationToken cancellationToken)
    {
        var profile = await dbContext.StudentProfiles.FirstOrDefaultAsync(item =>
            item.TenantId == tenantId && item.UserId == userId,
            cancellationToken);
        if (profile is null)
        {
            profile = new StudentProfile
            {
                TenantId = tenantId,
                UserId = userId,
                Stats = JsonDefaults.Object(),
                Progress = JsonDefaults.Object(),
                ModuleSelections = JsonDefaults.Object(),
                RecentActivities = JsonDefaults.Array()
            };
            dbContext.StudentProfiles.Add(profile);
        }

        profile.RegionId = regionId ?? profile.RegionId;
        profile.SelectedSchoolId = schoolId ?? profile.SelectedSchoolId;
        profile.SelectedMajorId = majorId ?? profile.SelectedMajorId;
        profile.AvatarPreset = avatarPreset ?? profile.AvatarPreset;
        profile.Stats = JsonObjectOrDefault(stats);
        profile.Progress = JsonObjectOrDefault(progress);
        profile.ModuleSelections = JsonObjectOrDefault(moduleSelections);
        return profile;
    }

    private async Task<IReadOnlyCollection<TenantAdminStudentImportPreviewItem>> BuildStudentImportPreviewAsync(
        TenantAdminActor actor,
        CurrentDataScope scope,
        TenantAdminStudentImportCommand command,
        CancellationToken cancellationToken)
    {
        var items = new List<TenantAdminStudentImportPreviewItem>();
        var rowNo = 0;
        foreach (var row in command.Rows.Take(1000))
        {
            rowNo++;
            string? reason = null;
            var phone = Normalize(row.User.Phone);
            var email = Normalize(row.User.Email);
            var name = Normalize(row.User.Name);
            if (row.User.UserId is null && phone is null && email is null && name is null)
            {
                reason = "user_required";
            }
            else if (!scope.AllowsResource(actor.UserId, actor.UserId, row.RegionId))
            {
                reason = "data_scope_denied";
            }
            else if (row.RegionId.HasValue && !await dbContext.Regions.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == row.RegionId.Value, cancellationToken))
            {
                reason = "region_not_found";
            }
            else if (row.ClassId.HasValue)
            {
                try
                {
                    await AssertClassAsync(actor, scope, row.ClassId, cancellationToken);
                }
                catch (TenantAdminDirectException exception)
                {
                    reason = exception.Code;
                }
            }

            items.Add(new TenantAdminStudentImportPreviewItem(
                rowNo,
                reason is null,
                reason,
                phone,
                email,
                name,
                row.RegionId,
                row.ClassId));
        }

        return items;
    }

    private async Task<TenantClassMember> UpsertClassMemberCoreAsync(
        TenantAdminActor actor,
        Guid classId,
        Guid userId,
        TenantClassMemberType memberType,
        TenantClassMemberStatus status,
        JsonElement metadata,
        CancellationToken cancellationToken)
    {
        var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member =>
            member.TenantId == actor.TenantId &&
            member.ClassId == classId &&
            member.UserId == userId &&
            member.MemberType == memberType,
            cancellationToken);
        if (item is null)
        {
            item = new TenantClassMember
            {
                TenantId = actor.TenantId,
                ClassId = classId,
                UserId = userId,
                MemberType = memberType,
                JoinedAt = DateTimeOffset.UtcNow
            };
            dbContext.TenantClassMembers.Add(item);
        }

        item.Status = status;
        item.LeftAt = status == TenantClassMemberStatus.Removed ? DateTimeOffset.UtcNow : null;
        item.Metadata = JsonObjectOrDefault(metadata);
        return item;
    }

    private async Task<IReadOnlyCollection<TenantSupervisionRuleItem>> GetSupervisionRulesCoreAsync(
        Guid tenantId,
        CancellationToken cancellationToken)
    {
        var settings = await dbContext.TenantSettings.AsNoTracking().SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
        if (settings is null ||
            settings.AdminFeatureFlags.ValueKind != JsonValueKind.Object ||
            !settings.AdminFeatureFlags.TryGetProperty("supervisionRules", out var rulesElement) ||
            rulesElement.ValueKind != JsonValueKind.Array)
        {
            return [];
        }

        return JsonSerializer.Deserialize<TenantSupervisionRuleItem[]>(rulesElement.GetRawText()) ?? [];
    }

    private async Task SaveSupervisionRulesCoreAsync(
        Guid tenantId,
        IReadOnlyCollection<TenantSupervisionRuleItem> rules,
        CancellationToken cancellationToken)
    {
        var settings = await dbContext.TenantSettings.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
        if (settings is null)
        {
            settings = new TenantSettings { TenantId = tenantId };
            dbContext.TenantSettings.Add(settings);
        }

        var existing = settings.AdminFeatureFlags.ValueKind == JsonValueKind.Object
            ? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(settings.AdminFeatureFlags.GetRawText()) ?? []
            : [];
        existing["supervisionRules"] = JsonSerializer.SerializeToElement(rules);
        settings.AdminFeatureFlags = JsonSerializer.SerializeToElement(existing);
    }

    private async Task<IReadOnlyCollection<TenantSupervisionRiskStudentItem>> BuildSupervisionRiskStudentsAsync(
        TenantAdminActor actor,
        CurrentDataScope scope,
        CancellationToken cancellationToken)
    {
        var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken))
            .Where(rule => rule.Enabled)
            .ToArray();
        if (rules.Length == 0)
        {
            return [];
        }

        var regionIds = scope.RegionIds.ToArray();
        var students = await dbContext.StudentProfiles.AsNoTracking()
            .Where(profile => profile.TenantId == actor.TenantId)
            .ApplyDataScope(
                scope,
                profile => profile.UserId == actor.UserId,
                profile => profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value))
            .Select(profile => new
            {
                Profile = profile,
                User = dbContext.Users.Where(user => user.Id == profile.UserId).FirstOrDefault()
            })
            .ToArrayAsync(cancellationToken);
        var today = DateOnly.FromDateTime(DateTime.UtcNow);
        var result = new List<TenantSupervisionRiskStudentItem>();
        foreach (var row in students)
        {
            var hitRules = new List<string>();
            var reasons = new List<string>();
            foreach (var rule in rules)
            {
                if (rule.DaysWithoutCheckIn.HasValue)
                {
                    var days = row.Profile.LastCheckInDate.HasValue
                        ? today.DayNumber - row.Profile.LastCheckInDate.Value.DayNumber
                        : int.MaxValue;
                    if (days >= rule.DaysWithoutCheckIn.Value)
                    {
                        hitRules.Add(rule.Code);
                        reasons.Add($"{rule.Title}: {days} days without check-in");
                    }
                }

                if (rule.MaxQuestionsAnsweredToday.HasValue &&
                    row.Profile.QuestionsAnsweredToday <= rule.MaxQuestionsAnsweredToday.Value)
                {
                    hitRules.Add(rule.Code);
                    reasons.Add($"{rule.Title}: questions answered today <= {rule.MaxQuestionsAnsweredToday.Value}");
                }
            }

            if (hitRules.Count > 0)
            {
                result.Add(new TenantSupervisionRiskStudentItem(
                    row.Profile.UserId,
                    row.User?.Name,
                    MaskPhone(row.User?.Phone),
                    row.Profile.RegionId,
                    hitRules.Distinct(StringComparer.Ordinal).ToArray(),
                    reasons.Distinct(StringComparer.Ordinal).ToArray()));
            }
        }

        return result;
    }

    private async Task AssertStudentAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken)
    {
        var exists = await dbContext.TenantMemberships.AnyAsync(
            item => item.TenantId == tenantId && item.UserId == userId && item.Role == TenantRole.Student,
            cancellationToken);
        if (!exists)
        {
            throw new TenantAdminDirectException("Student was not found.", "student_not_found");
        }
    }

    private async Task AssertStudentAsync(
        TenantAdminActor actor,
        CurrentDataScope scope,
        Guid userId,
        CancellationToken cancellationToken)
    {
        var regionIds = scope.RegionIds.ToArray();
        var classIds = scope.ClassIds.ToArray();
        var exists = await dbContext.TenantMemberships
            .Where(item => item.TenantId == actor.TenantId && item.UserId == userId && item.Role == TenantRole.Student)
            .ApplyDataScope(
                scope,
                item => item.UserId == actor.UserId,
                item => dbContext.StudentProfiles.Any(profile =>
                            profile.TenantId == actor.TenantId &&
                            profile.UserId == item.UserId &&
                            profile.RegionId.HasValue &&
                            regionIds.Contains(profile.RegionId.Value)) ||
                        dbContext.TenantClassMembers.Any(member =>
                            member.TenantId == actor.TenantId &&
                            member.UserId == item.UserId &&
                            member.Status == TenantClassMemberStatus.Active &&
                            classIds.Contains(member.ClassId)))
            .AnyAsync(cancellationToken);
        if (!exists)
        {
            throw new TenantAdminDirectException("Student was not found.", "student_not_found");
        }
    }

    private async Task AssertTenantMemberAsync(Guid tenantId, Guid? userId, CancellationToken cancellationToken)
    {
        if (!userId.HasValue)
        {
            return;
        }

        var exists = await dbContext.TenantMemberships.AnyAsync(
            item => item.TenantId == tenantId && item.UserId == userId.Value && item.Status == MembershipStatus.Active,
            cancellationToken);
        if (!exists)
        {
            throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found");
        }
    }

    private async Task RevokeSessionsAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken)
    {
        await sessionStore.RevokeRealmAsync(
            userId, AuthRealm.Tenant, tenantId, "membership_disabled", cancellationToken);
    }

    private async Task EnsureTenantOwnerBackendRoleAsync(
        Guid tenantId,
        Guid userId,
        CancellationToken cancellationToken)
    {
        const string roleCode = "tenant_owner";
        var role = await dbContext.TenantBackendRoles.FirstOrDefaultAsync(
            item => item.TenantId == tenantId && item.Code == roleCode,
            cancellationToken);
        if (role is null)
        {
            role = new TenantBackendRole
            {
                TenantId = tenantId,
                Code = roleCode,
                Name = "租户所有者",
                Status = BackendRoleStatus.Active,
                IsSystem = true,
                Description = "系统内置租户所有者角色",
                DataScope = JsonSerializer.SerializeToElement(new { mode = "All" })
            };
            dbContext.TenantBackendRoles.Add(role);
        }
        else
        {
            role.Status = BackendRoleStatus.Active;
            role.IsSystem = true;
            role.DataScope = JsonSerializer.SerializeToElement(new { mode = "All" });
        }

        var tenantPermissionCodes = BackendPermissions.Tenant.ToArray();
        var existingPermissionCodes = await dbContext.BackendPermissions
            .Where(permission => tenantPermissionCodes.Contains(permission.Code))
            .Select(permission => permission.Code)
            .ToArrayAsync(cancellationToken);
        foreach (var permissionCode in BackendPermissions.Tenant.Except(existingPermissionCodes, StringComparer.Ordinal))
        {
            dbContext.BackendPermissions.Add(new BackendPermission
            {
                Code = permissionCode,
                Name = permissionCode,
                Area = BackendPermissionArea.Tenant,
                PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode),
                IsSystem = true
            });
        }

        var boundPermissionCodes = await dbContext.TenantBackendRolePermissions
            .Where(binding => binding.TenantId == tenantId && binding.RoleId == role.Id)
            .Select(binding => binding.PermissionCode)
            .ToArrayAsync(cancellationToken);
        dbContext.TenantBackendRolePermissions.AddRange(
            tenantPermissionCodes
                .Except(boundPermissionCodes, StringComparer.Ordinal)
                .Select(permissionCode => new TenantBackendRolePermission
                {
                    TenantId = tenantId,
                    RoleId = role.Id,
                    PermissionCode = permissionCode
                }));

        if (!await dbContext.TenantBackendUserRoles.AnyAsync(
                binding => binding.TenantId == tenantId && binding.UserId == userId && binding.RoleId == role.Id,
                cancellationToken))
        {
            dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole
            {
                TenantId = tenantId,
                UserId = userId,
                RoleId = role.Id
            });
        }
    }

    private async Task EnsureBrandingThemeAsync(
        Guid tenantId,
        JsonElement theme,
        JsonElement publicAssets,
        CancellationToken cancellationToken)
    {
        var branding = await dbContext.TenantBrandings.FirstOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
        if (branding is null)
        {
            var tenantName = await dbContext.Tenants
                .Where(tenant => tenant.Id == tenantId)
                .Select(tenant => tenant.Name)
                .FirstOrDefaultAsync(cancellationToken);
            branding = new TenantBranding
            {
                TenantId = tenantId,
                BrandName = tenantName ?? "租户题库"
            };
            dbContext.TenantBrandings.Add(branding);
        }

        branding.Theme = theme.Clone();
        branding.PublicAssets = MergeJsonObjects(branding.PublicAssets, publicAssets);
    }

    private async Task AssertClassAsync(Guid tenantId, Guid? classId, CancellationToken cancellationToken)
    {
        if (!classId.HasValue)
        {
            return;
        }

        var exists = await dbContext.TenantClasses.AnyAsync(
            item => item.TenantId == tenantId && item.Id == classId.Value,
            cancellationToken);
        if (!exists)
        {
            throw new TenantAdminDirectException("Class was not found.", "class_not_found");
        }
    }

    private async Task AssertClassAsync(
        TenantAdminActor actor,
        CurrentDataScope scope,
        Guid? classId,
        CancellationToken cancellationToken)
    {
        if (!classId.HasValue)
        {
            return;
        }

        var regionIds = scope.RegionIds.ToArray();
        var classIds = scope.ClassIds.ToArray();
        var exists = await dbContext.TenantClasses
            .Where(item => item.TenantId == actor.TenantId && item.Id == classId.Value)
            .ApplyDataScope(
                scope,
                item => item.CreatedBy == actor.UserId,
                item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)))
            .AnyAsync(cancellationToken);
        if (!exists)
        {
            throw new TenantAdminDirectException("Class was not found.", "class_not_found");
        }
    }

    private async Task<CurrentDataScope> RequireDataScopeAsync(
        TenantAdminActor actor,
        CancellationToken cancellationToken)
    {
        var access = await currentAccessContext.GetAsync(cancellationToken);
        if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId)
        {
            throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found");
        }

        return access.DataScope;
    }

    private async Task RequireAllDataScopeAsync(
        TenantAdminActor actor,
        CancellationToken cancellationToken)
    {
        var scope = await RequireDataScopeAsync(actor, cancellationToken);
        if (scope.Mode != DataScopeMode.All)
        {
            throw new TenantAdminDirectException("Tenant-wide resource was not found.", "tenant_resource_not_found");
        }
    }

    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 TenantAdminDirectException("Referenced entity was not found in this tenant.", code);
        }
    }

    private async Task<TEntity?> ResolveTenantEntityAsync<TEntity>(
        DbSet<TEntity> set,
        Guid tenantId,
        Guid? id,
        string? legacyId,
        CancellationToken cancellationToken)
        where TEntity : AuditableTenantEntity
    {
        if (id.HasValue)
        {
            return await set.FirstOrDefaultAsync(entity => entity.TenantId == tenantId && entity.Id == id.Value, cancellationToken);
        }

        legacyId = Normalize(legacyId);
        if (legacyId is null)
        {
            return null;
        }

        return typeof(TEntity).GetProperty("LegacyId") is null
            ? null
            : await set.FirstOrDefaultAsync(
                entity => entity.TenantId == tenantId && EF.Property<string?>(entity, "LegacyId") == legacyId,
                cancellationToken);
    }

    private async Task AddAuditAsync(
        TenantAdminActor actor,
        string action,
        string targetType,
        Guid targetId,
        CancellationToken cancellationToken)
    {
        dbContext.AuditLogs.Add(new AuditLog
        {
            TenantId = actor.TenantId,
            ActorUserId = actor.UserId,
            Action = action,
            TargetType = targetType,
            TargetId = targetId.ToString(),
            Details = JsonDefaults.Object()
        });
        await Task.CompletedTask.WaitAsync(cancellationToken);
    }

    private static TenantAdminClassItem ToClassItem(TenantClass item, string? regionName, int studentCount, int staffCount)
    {
        return new TenantAdminClassItem(
            item.Id,
            item.RegionId,
            regionName,
            item.LegacyId,
            item.Code,
            item.Name,
            item.Description,
            item.Status,
            item.SortOrder,
            item.Metadata,
            studentCount,
            staffCount,
            item.CreatedAt,
            item.UpdatedAt);
    }

    private static TenantAdminClassMemberItem ToClassMemberItem(TenantClassMember item, TenantAdminUserSummary user)
    {
        return new TenantAdminClassMemberItem(
            item.Id,
            item.ClassId,
            item.UserId,
            item.MemberType,
            item.Status,
            item.JoinedAt,
            item.LeftAt,
            item.Metadata,
            user);
    }

    private static TenantAdminStudentItem ToStudentItem(
        TenantMembership membership,
        User user,
        StudentProfile? profile,
        IReadOnlyDictionary<Guid, string> regions,
        IReadOnlyDictionary<Guid, string> schools,
        IReadOnlyDictionary<Guid, string> majors,
        IReadOnlyCollection<TenantAdminStudentClassSummary> classes)
    {
        return new TenantAdminStudentItem(
            membership.Id,
            membership.UserId,
            membership.Status,
            ToUserSummary(user),
            profile?.Id,
            profile?.AvatarPreset ?? "male",
            profile?.RegionId,
            profile?.RegionId is Guid regionId && regions.TryGetValue(regionId, out var regionName) ? regionName : null,
            profile?.SelectedSchoolId,
            profile?.SelectedSchoolId is Guid schoolId && schools.TryGetValue(schoolId, out var schoolName) ? schoolName : null,
            profile?.SelectedMajorId,
            profile?.SelectedMajorId is Guid majorId && majors.TryGetValue(majorId, out var majorName) ? majorName : null,
            profile?.Stats ?? JsonDefaults.Object(),
            profile?.Progress ?? JsonDefaults.Object(),
            profile?.ModuleSelections ?? JsonDefaults.Object(),
            classes,
            membership.CreatedAt,
            membership.UpdatedAt);
    }

    private static TenantAdminUserSummary ToUserSummary(User user)
    {
        return new TenantAdminUserSummary(
            user.Id,
            user.UserName,
            user.Email,
            user.Phone,
            user.Name,
            user.AvatarUrl,
            user.PrimaryRole);
    }

    private static TenantAdminStudentNoteItem ToNoteItem(TenantStudentNote item)
    {
        return new TenantAdminStudentNoteItem(
            item.Id,
            item.StudentUserId,
            item.NoteType,
            item.Content,
            item.Visibility,
            item.IsPinned,
            item.Metadata,
            item.CreatedBy,
            item.UpdatedBy,
            item.CreatedAt,
            item.UpdatedAt);
    }

    private static TenantAdminStudentFollowupItem ToFollowupItem(TenantStudentFollowup item)
    {
        return new TenantAdminStudentFollowupItem(
            item.Id,
            item.StudentUserId,
            item.AssignedToUserId,
            item.ClassId,
            item.Title,
            item.Description,
            item.FollowupType,
            item.Priority,
            item.Status,
            item.DueAt,
            item.CompletedAt,
            item.CompletedBy,
            item.Metadata,
            item.CreatedBy,
            item.UpdatedBy,
            item.CreatedAt,
            item.UpdatedAt);
    }

    private static TenantAdminMemberItem ToMemberItem(TenantMembership membership, User user)
    {
        return new TenantAdminMemberItem(
            membership.Id,
            membership.UserId,
            membership.Role,
            membership.Status,
            membership.LegacyRole,
            ToUserSummary(user),
            membership.CreatedAt,
            membership.UpdatedAt);
    }

    private static TenantBrandingItem ToBrandingItem(TenantBranding item)
    {
        return new TenantBrandingItem(
            item.TenantId,
            item.BrandName,
            item.ShortName,
            item.Slogan,
            item.OrganizationName,
            item.LogoUrl,
            item.FaviconUrl,
            item.ServiceWechat,
            item.ServiceAccountName,
            item.Theme,
            item.PublicAssets,
            item.UpdatedAt);
    }

    private static TenantSettingsItem ToSettingsItem(TenantSettings item)
    {
        return new TenantSettingsItem(
            item.TenantId,
            item.FeatureFlags,
            item.AdminFeatureFlags,
            item.PublicConfig,
            item.UpdatedAt);
    }

    private static TenantThemeItem ToThemeItem(TenantThemeConfig item)
    {
        return new TenantThemeItem(
            item.TenantId,
            item.ActiveTemplateCode,
            item.ActiveTheme,
            item.ActivePublicAssets,
            item.DraftTemplateCode,
            item.DraftTheme,
            item.DraftPublicAssets,
            item.Status,
            item.PublishedAt,
            item.PublishedBy,
            item.DraftUpdatedBy,
            item.UpdatedAt);
    }

    private static TenantDomainItem ToDomainItem(TenantDomain item)
    {
        return new TenantDomainItem(
            item.Id,
            item.Host,
            item.DomainType,
            item.Status,
            item.IsPrimary,
            item.VerificationToken,
            item.VerifiedAt,
            item.LastCheckedAt,
            item.DnsVerifiedAt,
            item.TlsReadyAt,
            item.LastFailureReason,
            item.CreatedAt,
            item.UpdatedAt);
    }

    private static TenantIdentityProviderItem ToAuthProviderItem(TenantExternalProviderItem item)
    {
        return new TenantIdentityProviderItem(
            item.Id,
            item.Provider,
            item.Status,
            item.DisplayName,
            item.SecretRef,
            item.Priority,
            item.ConfigPublic,
            item.CreatedAt,
            item.UpdatedAt);
    }

    private static TenantAdminBadgeItem ToBadgeItem(Badge item)
    {
        return new TenantAdminBadgeItem(
            item.Id,
            item.LegacyId,
            item.Name,
            item.Description,
            item.Category,
            item.IconUrl,
            item.Level,
            item.UnlockType,
            item.ConditionField,
            item.ConditionOperator,
            item.ConditionValue,
            item.ConditionExtra,
            item.SortOrder,
            item.IsActive,
            item.CreatedAt,
            item.UpdatedAt);
    }

    private static TenantAdminBadgeGrantItem ToBadgeGrantItem(UserBadge grant, User? user, User? grantedBy, Badge? badge)
    {
        return new TenantAdminBadgeGrantItem(
            grant.Id,
            grant.LegacyId,
            grant.UserId,
            user?.Name ?? user?.UserName,
            user?.Phone,
            grant.BadgeId,
            badge?.Name,
            badge?.Category,
            badge?.IconUrl,
            badge?.Level,
            grant.GrantedBy,
            grantedBy?.Name ?? grantedBy?.UserName,
            grant.Note,
            grant.GrantedAt,
            grant.CreatedAt,
            grant.UpdatedAt);
    }

    private static TenantAdminNotificationItem ToNotificationItem(UserNotification item)
    {
        return new TenantAdminNotificationItem(
            item.Id,
            item.UserId,
            item.NotificationType,
            item.Status,
            item.Severity,
            item.Title,
            item.Message,
            item.ActionLabel,
            item.ActionPath,
            item.SourceType,
            item.SourceId,
            item.DedupeKey,
            item.Metadata,
            item.CreatedBy,
            item.ReadAt,
            item.CreatedAt,
            item.UpdatedAt);
    }

    private static TenantAdminFeedbackItem ToFeedbackItem(Report report, User? user)
    {
        return new TenantAdminFeedbackItem(
            report.Id,
            report.UserId,
            user?.Name ?? user?.UserName,
            user?.Phone,
            report.QuestionId,
            report.Type,
            report.Title,
            report.Category,
            report.Description,
            report.Status,
            report.Priority,
            report.HandledBy,
            report.HandledAt,
            report.Resolution,
            report.Contact,
            report.Attachments,
            report.Metadata,
            report.CreatedAt,
            report.UpdatedAt);
    }

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

    private static string? Normalize(string? value)
    {
        return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
    }

    private static string NormalizeCode(string value)
    {
        return value.Trim().ToLowerInvariant();
    }

    private static string? MaskPhone(string? phone)
    {
        var value = Normalize(phone);
        return value is { Length: >= 7 }
            ? $"{value[..3]}****{value[^4..]}"
            : value;
    }

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

    private static JsonElement PermissionObject(JsonElement value)
    {
        var result = new Dictionary<string, bool>(StringComparer.Ordinal);
        if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
        {
            return JsonDefaults.Object();
        }

        if (value.ValueKind != JsonValueKind.Object)
        {
            throw new TenantAdminDirectException("Permissions must be an object.", "invalid_permission_value");
        }

        foreach (var property in value.EnumerateObject())
        {
            if (property.Value.ValueKind != JsonValueKind.True && property.Value.ValueKind != JsonValueKind.False)
            {
                throw new TenantAdminDirectException("Permission values must be boolean.", "invalid_permission_value");
            }

            if (property.Name != "*" && !IsPermissionKey(property.Name))
            {
                throw new TenantAdminDirectException("Permission key was invalid.", "invalid_permission_key");
            }

            result[property.Name] = property.Value.GetBoolean();
        }

        return JsonSerializer.SerializeToElement(result);
    }

    private static JsonElement AccessMap(JsonElement value, string codePrefix)
    {
        var result = new Dictionary<string, bool>(StringComparer.Ordinal);
        if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
        {
            return JsonDefaults.Object();
        }

        if (value.ValueKind != JsonValueKind.Object)
        {
            throw new TenantAdminDirectException("Access map must be an object.", $"invalid_{codePrefix}");
        }

        foreach (var property in value.EnumerateObject())
        {
            if (property.Value.ValueKind != JsonValueKind.True && property.Value.ValueKind != JsonValueKind.False)
            {
                throw new TenantAdminDirectException("Access map values must be boolean.", $"invalid_{codePrefix}");
            }

            if (!IsAccessKey(property.Name))
            {
                throw new TenantAdminDirectException("Access map key was invalid.", $"invalid_{codePrefix}_key");
            }

            result[property.Name] = property.Value.GetBoolean();
        }

        return JsonSerializer.SerializeToElement(result);
    }

    private static JsonElement DataScope(JsonElement value)
    {
        if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
        {
            return JsonDefaults.Object();
        }

        if (value.ValueKind != JsonValueKind.Object)
        {
            throw new TenantAdminDirectException("Data scope must be an object.", "invalid_data_scope");
        }

        var allowed = new HashSet<string>(StringComparer.Ordinal)
        {
            "mode",
            "regionIds",
            "contentNodeIds",
            "classIds",
            "ownLeadsOnly",
            "teamScope",
            "metadata"
        };
        foreach (var property in value.EnumerateObject())
        {
            if (!allowed.Contains(property.Name))
            {
                throw new TenantAdminDirectException("Data scope key was invalid.", "invalid_data_scope_key");
            }
        }

        return value.Clone();
    }

    private async Task AssertGrantableAsync(
        TenantAdminActor actor,
        TenantRole role,
        CancellationToken cancellationToken)
    {
        if (role is not (TenantRole.TenantOwner or TenantRole.TenantAdmin))
        {
            return;
        }

        var isOwnerRoleHolder = await (
            from binding in dbContext.TenantBackendUserRoles.AsNoTracking()
            join backendRole in dbContext.TenantBackendRoles.AsNoTracking()
                on new { binding.TenantId, binding.RoleId } equals new { backendRole.TenantId, RoleId = backendRole.Id }
            where binding.TenantId == actor.TenantId &&
                  binding.UserId == actor.UserId &&
                  backendRole.Code == "tenant_owner" &&
                  backendRole.IsSystem &&
                  backendRole.Status == BackendRoleStatus.Active
            select binding.Id)
            .AnyAsync(cancellationToken);
        if (!isOwnerRoleHolder)
        {
            throw new TenantAdminDirectException("Only tenant owner can grant owner/admin permissions.", "tenant_owner_required");
        }
    }

    private static string RoleToPrimaryRole(TenantRole role)
    {
        return role switch
        {
            TenantRole.Student => "student",
            TenantRole.Teacher => "teacher",
            TenantRole.Sales => "sales",
            TenantRole.Agent => "agent",
            TenantRole.TenantOperator => "tenant_operator",
            TenantRole.TenantAdmin => "tenant_admin",
            TenantRole.TenantOwner => "tenant_owner",
            _ => "student"
        };
    }

    private static string NormalizeRoleCode(string value)
    {
        var code = new string(value.Trim().ToLowerInvariant()
            .Select(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-' ? character : '-')
            .ToArray())
            .Trim('-');
        while (code.Contains("--", StringComparison.Ordinal))
        {
            code = code.Replace("--", "-", StringComparison.Ordinal);
        }

        if (code.Length is < 2 or > 64 || !char.IsAsciiLetter(code[0]))
        {
            throw new TenantAdminDirectException("Role template code was invalid.", "invalid_role_template_code");
        }

        return code;
    }

    private static string NormalizeDomain(string value)
    {
        var host = Normalize(value)?.ToLowerInvariant()
            .TrimEnd('.') ?? throw new TenantAdminDirectException("Domain host is required.", "domain_host_required");
        if (host.Length > 253 || host.Contains('/', StringComparison.Ordinal) || host.Contains(':', StringComparison.Ordinal) || !host.Contains('.', StringComparison.Ordinal))
        {
            throw new TenantAdminDirectException("Domain host was invalid.", "invalid_domain_host");
        }

        return host;
    }

    private static JsonElement MergeJsonObjects(JsonElement first, JsonElement second)
    {
        var result = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
        if (first.ValueKind == JsonValueKind.Object)
        {
            foreach (var property in first.EnumerateObject())
            {
                result[property.Name] = property.Value.Clone();
            }
        }

        if (second.ValueKind == JsonValueKind.Object)
        {
            foreach (var property in second.EnumerateObject())
            {
                result[property.Name] = property.Value.Clone();
            }
        }

        return JsonSerializer.SerializeToElement(result);
    }

    private static void AssertNoSecrets(JsonElement value, string code)
    {
        if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
        {
            return;
        }

        if (value.ValueKind != JsonValueKind.Object)
        {
            throw new TenantAdminDirectException("Public config must be an object.", $"invalid_{code}");
        }

        foreach (var property in value.EnumerateObject())
        {
            var key = property.Name.ToLowerInvariant();
            if (key.Contains("secret", StringComparison.Ordinal) ||
                key.Contains("password", StringComparison.Ordinal) ||
                key.Contains("token", StringComparison.Ordinal) ||
                key.Contains("private", StringComparison.Ordinal) ||
                key is "appsecret" or "app_secret" or "accesskeysecret")
            {
                throw new TenantAdminDirectException("Public config cannot contain secrets.", "public_config_contains_secret");
            }
        }
    }

    private static bool IsPermissionKey(string key)
    {
        return key.Length <= 100 &&
               key.Contains(':', StringComparison.Ordinal) &&
               key.All(character => char.IsAsciiLetterOrDigit(character) || character is ':' or '*');
    }

    private static bool IsAccessKey(string key)
    {
        return key.Length <= 100 &&
               key.Length > 0 &&
               char.IsAsciiLetter(key[0]) &&
               key.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '.' or ':' or '-');
    }

    private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback, string code)
        where TEnum : struct, Enum
    {
        return string.IsNullOrWhiteSpace(value) ? fallback : ParseEnum<TEnum>(value, code);
    }

    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);
        foreach (var candidate in Enum.GetValues<TEnum>())
        {
            if (string.Equals(candidate.ToString(), normalized, StringComparison.OrdinalIgnoreCase))
            {
                return candidate;
            }
        }

        throw new TenantAdminDirectException("Enum value was invalid.", code);
    }
}
