using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace Jiaowu.Api.Controllers;

[ApiController]
[Authorize]
[Route("api/makeup-exams")]
public sealed class MakeupExamsController(
    AppDbContext db,
    ICurrentUserDataScope currentUserDataScope,
    MakeupExamEligibilityService eligibilityService) : ControllerBase
{
    private const string Managers =
        SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
    private const string ScoreEnterers =
        SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.Teacher;

    // ═══════════════════════════════════════════
    //  Plans
    // ═══════════════════════════════════════════

    [HttpGet("plans")]
    public async Task<ActionResult> GetPlans(
        Guid? academicTermId,
        CancellationToken cancellationToken)
    {
        var source = db.MakeupExamPlans.AsNoTracking().AsQueryable();
        if (academicTermId.HasValue)
            source = source.Where(x => x.AcademicTermId == academicTermId);
        if (!IsManager())
            source = source.Where(x => x.Status == MakeupExamPlanStatus.Published);
        return Ok(await source.OrderByDescending(x => x.AcademicTerm!.StartDate)
            .ThenByDescending(x => x.CreatedAt)
            .Select(x => new
            {
                x.Id,
                x.Name,
                x.AcademicTermId,
                TermName = x.AcademicTerm!.Name,
                TermIsCurrent = x.AcademicTerm.IsCurrent,
                TermIsArchived = x.AcademicTerm.IsArchived,
                x.Status,
                SessionCount = x.Sessions.Count,
                x.Notes,
                x.PublishedAt
            }).ToListAsync(cancellationToken));
    }

    [HttpPost("plans")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> CreatePlan(
        MakeupExamPlanRequest request,
        CancellationToken cancellationToken)
    {
        if (!await db.AcademicTerms.AnyAsync(
                x => x.Id == request.AcademicTermId && x.IsEnabled,
                cancellationToken))
            return ValidationProblem("所选学期不存在或已停用。");
        var plan = new MakeupExamPlan
        {
            AcademicTermId = request.AcademicTermId,
            Name = request.Name.Trim(),
            Notes = Normalize(request.Notes)
        };
        db.MakeupExamPlans.Add(plan);
        return await SaveAsync(plan.Id, true, cancellationToken);
    }

    [HttpGet("plans/{id:guid}")]
    public async Task<ActionResult> GetPlan(Guid id, CancellationToken cancellationToken)
    {
        var manager = IsManager();
        var plan = await db.MakeupExamPlans.AsNoTracking()
            .Where(x => x.Id == id && (manager || x.Status == MakeupExamPlanStatus.Published))
            .Select(x => new
            {
                x.Id,
                x.Name,
                x.AcademicTermId,
                TermName = x.AcademicTerm!.Name,
                x.Status,
                x.Notes,
                x.PublishedAt,
                Sessions = x.Sessions.OrderBy(item => item.ExamDate)
                    .ThenBy(item => item.StartPeriod).Select(item => new
                    {
                        item.Id,
                        item.TeachingTaskId,
                        item.TeachingTask!.TaskNumber,
                        TaskName = item.TeachingTask.Name,
                        CourseCode = item.TeachingTask.Course!.Code,
                        CourseName = item.TeachingTask.Course.Name,
                        item.ClassroomId,
                        ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
                        BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
                        ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null,
                        item.ExamDate,
                        item.StartPeriod,
                        item.PeriodCount,
                        item.StartsAt,
                        item.EndsAt,
                        item.RequiredBuildingId,
                        item.RequiredBuildingIds,
                        RequiredBuildingName = item.RequiredBuilding != null
                        ? item.RequiredBuilding.Name : null,
                        item.RequiredInvigilatorCount,
                        item.Notes,
                        InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
                        InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
                        EnrolledCount = item.Enrollments.Count,
                        Enrollments = item.Enrollments.Select(e => new
                        {
                            e.StudentId,
                            e.Student!.StudentNumber,
                            e.Student.Name,
                            ClassName = e.Student.AdministrativeClass!.Name,
                            e.Reason,
                            e.SourceGradeRecordId,
                            e.SourceDeferredExamId,
                            e.MakeupScore
                        })
                    })
            }).FirstOrDefaultAsync(cancellationToken);
        return plan is null ? NotFound() : Ok(plan);
    }

    [HttpPost("plans/{id:guid}/publish")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
    {
        if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
            return ConflictProblem("补考计划正在后台编排，完成后才能发布。");
        if (await FindActivePublishJobAsync(id, cancellationToken) is not null)
            return ConflictProblem("补考计划正在后台发布，请等待任务完成。");

        var plan = await db.MakeupExamPlans.AsNoTracking()
            .Where(x => x.Id == id)
            .Select(x => new
            {
                x.Status,
                HasSessions = x.Sessions.Any()
            })
            .FirstOrDefaultAsync(cancellationToken);
        if (plan is null) return NotFound();
        if (plan.Status != MakeupExamPlanStatus.Draft)
            return ConflictProblem("只有草稿补考计划可以发布。");
        if (!plan.HasSessions)
            return ConflictProblem("至少安排一个考试场次后才能发布。");

        var userId = currentUserDataScope.Current.UserId;
        var job = new ExamPublishJob
        {
            Kind = ExamPublishJobKind.MakeupExam,
            PlanId = id,
            ActivePlanId = id,
            RequestedByUserId = userId == Guid.Empty ? null : userId,
            CurrentStep = "等待后台校验"
        };
        db.ExamPublishJobs.Add(job);
        db.BackgroundJobOutboxMessages.Add(
            BackgroundJobOutboxMessage.Create(
                BackgroundJobKind.ExamPublish,
                job.Id));
        try
        {
            await db.SaveChangesAsync(cancellationToken);
        }
        catch (DbUpdateException)
        {
            db.ChangeTracker.Clear();
            var existing = await FindActivePublishJobAsync(id, cancellationToken);
            if (existing is not null)
                return AcceptedPublishJob(existing, "该计划已有正在执行的发布任务。");
            throw;
        }

        return AcceptedPublishJob(job, "补考发布任务已提交。");
    }

    [HttpGet("publish-jobs/{jobId:guid}")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetPublishJob(
        Guid jobId,
        CancellationToken cancellationToken)
    {
        var job = await db.ExamPublishJobs.AsNoTracking()
            .FirstOrDefaultAsync(
                x => x.Id == jobId &&
                     x.Kind == ExamPublishJobKind.MakeupExam,
                cancellationToken);
        return job is null ? NotFound() : Ok(ToPublishJobResponse(job));
    }

    [HttpGet("plans/{planId:guid}/publish-job")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetLatestPublishJob(
        Guid planId,
        CancellationToken cancellationToken)
    {
        var job = await db.ExamPublishJobs.AsNoTracking()
            .Where(x =>
                x.PlanId == planId &&
                x.Kind == ExamPublishJobKind.MakeupExam)
            .OrderByDescending(x => x.CreatedAt)
            .FirstOrDefaultAsync(cancellationToken);
        return job is null ? NoContent() : Ok(ToPublishJobResponse(job));
    }

    [HttpPost("plans/{id:guid}/archive")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken)
    {
        if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
            return ConflictProblem("补考计划正在后台编排，暂时不能归档。");
        var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken);
        if (plan is null) return NotFound();
        if (plan.Status != MakeupExamPlanStatus.Published)
            return ConflictProblem("只有已发布的补考计划可以归档。");
        plan.Status = MakeupExamPlanStatus.Archived;
        return await SaveAsync(id, false, cancellationToken);
    }

    // ═══════════════════════════════════════════
    //  Sessions
    // ═══════════════════════════════════════════

    [HttpPost("plans/{planId:guid}/sessions")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> CreateSession(
        Guid planId,
        CreateMakeupExamSessionRequest request,
        CancellationToken cancellationToken)
    {
        if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
            return ConflictProblem("补考计划正在后台编排，暂时不能调整场次。");
        var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
        if (plan is null) return NotFound();
        if (plan.Status != MakeupExamPlanStatus.Draft)
            return ConflictProblem("已发布的补考计划不能调整场次。");

        var timeResult = ResolveExamTime(plan.AcademicTermId,
            request.ExamDate, request.StartPeriod, request.PeriodCount);
        if (timeResult.Error is not null) return timeResult.Error;
        var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);

        var validation = await ValidateSessionAsync(plan, null,
            request.TeachingTaskId, request.ClassroomId, request.InvigilatorIds,
            startsAt, endsAt, cancellationToken);
        if (validation is not null) return validation;

        var session = new MakeupExamSession
        {
            MakeupExamPlanId = planId,
            TeachingTaskId = request.TeachingTaskId,
            ClassroomId = request.ClassroomId,
            ExamDate = request.ExamDate,
            StartPeriod = request.StartPeriod,
            PeriodCount = request.PeriodCount,
            StartsAt = startsAt,
            EndsAt = endsAt,
            RequiredBuildingId = request.RequiredBuildingId,
            RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
            RequiredInvigilatorCount = request.RequiredInvigilatorCount,
            Notes = Normalize(request.Notes),
            Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
                new MakeupExamSessionInvigilator { TeacherId = id }).ToList()
        };
        db.MakeupExamSessions.Add(session);
        return await SaveAsync(session.Id, true, cancellationToken);
    }

    [HttpPost("plans/{planId:guid}/sessions/batch")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> CreateSessionsBatch(
        Guid planId,
        CreateMakeupExamSessionsBatchRequest request,
        CancellationToken cancellationToken)
    {
        if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
            return ConflictProblem("补考计划正在后台编排，暂时不能调整场次。");
        var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
        if (plan is null) return NotFound();
        if (plan.Status != MakeupExamPlanStatus.Draft)
            return ConflictProblem("已发布的补考计划不能调整场次。");

        var taskIds = request.TeachingTaskIds.Distinct().ToArray();
        if (taskIds.Length == 0)
            return ValidationProblem("请至少选择一个教学班。");
        if (taskIds.Length > 100)
            return ValidationProblem("一次最多选择100个教学班。");

        var timeResult = ResolveExamTime(plan.AcademicTermId,
            request.ExamDate, request.StartPeriod, request.PeriodCount);
        if (timeResult.Error is not null) return timeResult.Error;
        var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);

        var validTaskIds = await db.TeachingTasks.AsNoTracking()
            .Where(x => x.AcademicTermId == plan.AcademicTermId &&
                        x.Status == TeachingTaskStatus.Published)
            .WhereIn(taskIds, x => x.Id)
            .Select(x => x.Id)
            .ToListAsync(cancellationToken);
        if (validTaskIds.Count != taskIds.Length)
            return ValidationProblem("所选教学班包含不存在、跨学期或未发布的教学班。");

        var duplicateTaskIds = await db.MakeupExamSessions.AsNoTracking()
            .Where(x => x.MakeupExamPlanId == planId)
            .WhereIn(taskIds, x => x.TeachingTaskId)
            .Select(x => x.TeachingTaskId)
            .ToListAsync(cancellationToken);
        if (duplicateTaskIds.Count > 0)
            return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前补考计划中安排。");

        foreach (var taskId in taskIds)
        {
            var validation = await ValidateSessionAsync(plan, null,
                taskId, null, null, startsAt, endsAt, cancellationToken);
            if (validation is not null) return validation;
        }

        db.MakeupExamSessions.AddRange(taskIds.Select(taskId => new MakeupExamSession
        {
            MakeupExamPlanId = planId,
            TeachingTaskId = taskId,
            ExamDate = request.ExamDate,
            StartPeriod = request.StartPeriod,
            PeriodCount = request.PeriodCount,
            StartsAt = startsAt,
            EndsAt = endsAt,
            RequiredBuildingId = request.RequiredBuildingId,
            RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
            RequiredInvigilatorCount = request.RequiredInvigilatorCount,
            Notes = Normalize(request.Notes)
        }));

        try
        {
            await db.SaveChangesAsync(cancellationToken);
            return Ok(new { createdCount = taskIds.Length });
        }
        catch (DbUpdateException)
        {
            return ConflictProblem("批量创建补考场次失败，关联数据可能已发生变化。");
        }
    }

    [HttpPut("plans/{planId:guid}/sessions/{id:guid}")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> UpdateSession(
        Guid planId,
        Guid id,
        CreateMakeupExamSessionRequest request,
        CancellationToken cancellationToken)
    {
        if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
            return ConflictProblem("补考计划正在后台编排，暂时不能调整场次。");
        var session = await db.MakeupExamSessions
            .Include(x => x.MakeupExamPlan)
            .Include(x => x.Invigilators)
            .FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
        if (session is null) return NotFound();
        if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft)
            return ConflictProblem("已发布的补考计划不能调整场次。");

        var timeResult = ResolveExamTime(session.MakeupExamPlan.AcademicTermId,
            request.ExamDate, request.StartPeriod, request.PeriodCount);
        if (timeResult.Error is not null) return timeResult.Error;
        var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);

        var validation = await ValidateSessionAsync(session.MakeupExamPlan, id,
            request.TeachingTaskId, request.ClassroomId, request.InvigilatorIds,
            startsAt, endsAt, cancellationToken);
        if (validation is not null) return validation;

        session.TeachingTaskId = request.TeachingTaskId;
        session.ClassroomId = request.ClassroomId;
        session.ExamDate = request.ExamDate;
        session.StartPeriod = request.StartPeriod;
        session.PeriodCount = request.PeriodCount;
        session.StartsAt = startsAt;
        session.EndsAt = endsAt;
        session.RequiredBuildingId = request.RequiredBuildingId;
        session.RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds);
        session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
        session.Notes = Normalize(request.Notes);

        db.MakeupExamSessionInvigilators.RemoveRange(session.Invigilators);
        session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid =>
            new MakeupExamSessionInvigilator { TeacherId = tid }).ToList();

        return await SaveAsync(id, false, cancellationToken);
    }

    [HttpDelete("plans/{planId:guid}/sessions/{id:guid}")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> DeleteSession(
        Guid planId,
        Guid id,
        CancellationToken cancellationToken)
    {
        if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
            return ConflictProblem("补考计划正在后台编排，暂时不能调整场次。");
        var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan)
            .FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
        if (session is null) return NotFound();
        if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft)
            return ConflictProblem("已发布的补考计划不能调整场次。");
        db.MakeupExamSessions.Remove(session);
        return await SaveAsync(id, false, cancellationToken);
    }

    // ═══════════════════════════════════════════
    //  Auto-arrange
    // ═══════════════════════════════════════════

    [HttpPost("plans/{planId:guid}/auto-arrange")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> AutoArrange(
        Guid planId,
        ExamAutoArrangeRequest? request,
        CancellationToken cancellationToken)
    {
        request ??= new ExamAutoArrangeRequest();
        if (!request.AssignClassrooms && !request.AssignInvigilators)
            return ValidationProblem("请至少选择分配考场或分配监考教师。");

        var sessionIds = (request.SessionIds ?? []).Distinct().ToArray();
        if (sessionIds.Length > 100)
            return ValidationProblem("一次最多处理100个补考场次。");

        var plan = await db.MakeupExamPlans.AsNoTracking()
            .Where(x => x.Id == planId)
            .Select(x => new
            {
                x.Status,
                TotalSessions = x.Sessions.Count
            })
            .FirstOrDefaultAsync(cancellationToken);
        if (plan is null)
            return NotFound();
        if (plan.Status != MakeupExamPlanStatus.Draft)
            return ConflictProblem("只有草稿状态的补考计划可以自动编排。");
        if (sessionIds.Length > 0)
        {
            var selectedSessionCount = await db.MakeupExamSessions.AsNoTracking()
                .Where(x => x.MakeupExamPlanId == planId)
                .WhereIn(sessionIds, x => x.Id)
                .CountAsync(cancellationToken);
            if (selectedSessionCount != sessionIds.Length)
                return ConflictProblem("所选场次不存在或不属于当前补考计划。");
        }

        var autoCreateActive = await db.MakeupExamAutoJobs.AsNoTracking()
            .AnyAsync(
                x => x.MakeupExamPlanId == planId &&
                     (x.Status == MakeupExamAutoJobStatus.Queued ||
                      x.Status == MakeupExamAutoJobStatus.Running),
                cancellationToken);
        if (autoCreateActive)
            return ConflictProblem("该计划正在自动生成补考场次，请完成后再编排。");

        var existing = await FindActiveArrangementJobAsync(
            planId,
            cancellationToken);
        if (existing is not null)
            return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");

        var userId = currentUserDataScope.Current.UserId;
        var job = new ExamArrangementJob
        {
            Kind = ExamArrangementKind.MakeupExam,
            PlanId = planId,
            ActivePlanId = planId,
            RequestedByUserId = userId == Guid.Empty ? null : userId,
            SessionIdsJson = sessionIds.Length == 0
                ? null
                : System.Text.Json.JsonSerializer.Serialize(sessionIds),
            AssignClassrooms = request.AssignClassrooms,
            AssignInvigilators = request.AssignInvigilators,
            TotalSessions = sessionIds.Length == 0
                ? plan.TotalSessions
                : sessionIds.Length,
            CurrentStep = "等待后台编排"
        };
        db.ExamArrangementJobs.Add(job);
        db.BackgroundJobOutboxMessages.Add(
            BackgroundJobOutboxMessage.Create(
                BackgroundJobKind.ExamArrangement,
                job.Id));
        try
        {
            await db.SaveChangesAsync(cancellationToken);
        }
        catch (DbUpdateException)
        {
            db.ChangeTracker.Clear();
            existing = await FindActiveArrangementJobAsync(
                planId,
                cancellationToken);
            if (existing is not null)
                return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
            throw;
        }

        return AcceptedArrangementJob(job, "补考编排任务已提交。");
    }

    [HttpGet("arrangement-jobs/{jobId:guid}")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetArrangementJob(
        Guid jobId,
        CancellationToken cancellationToken)
    {
        var job = await db.ExamArrangementJobs.AsNoTracking()
            .FirstOrDefaultAsync(
                x => x.Id == jobId &&
                     x.Kind == ExamArrangementKind.MakeupExam,
                cancellationToken);
        return job is null ? NotFound() : Ok(ToArrangementJobResponse(job));
    }

    [HttpGet("plans/{planId:guid}/arrangement-job")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetLatestArrangementJob(
        Guid planId,
        CancellationToken cancellationToken)
    {
        var job = await db.ExamArrangementJobs.AsNoTracking()
            .Where(x =>
                x.PlanId == planId &&
                x.Kind == ExamArrangementKind.MakeupExam)
            .OrderByDescending(x => x.CreatedAt)
            .FirstOrDefaultAsync(cancellationToken);
        return job is null ? NoContent() : Ok(ToArrangementJobResponse(job));
    }

    // ═══════════════════════════════════════════
    //  Auto-create (background job)
    // ═══════════════════════════════════════════

    [HttpPost("plans/{planId:guid}/auto-create")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> AutoCreate(
        Guid planId,
        CancellationToken cancellationToken)
    {
        var plan = await db.MakeupExamPlans
            .FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
        if (plan is null) return NotFound();
        if (plan.Status != MakeupExamPlanStatus.Draft)
            return ConflictProblem("只有草稿状态的补考计划可以自动生成。");
        if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
            return ConflictProblem("该计划正在自动编排，请完成后再自动生成场次。");

        // Check for existing active job
        var existing = await db.MakeupExamAutoJobs.AsNoTracking()
            .Where(x => x.MakeupExamPlanId == planId &&
                        (x.Status == MakeupExamAutoJobStatus.Queued ||
                         x.Status == MakeupExamAutoJobStatus.Running))
            .OrderByDescending(x => x.CreatedAt)
            .FirstOrDefaultAsync(cancellationToken);
        if (existing is not null)
            return Ok(new
            {
                jobId = existing.Id,
                status = existing.Status.ToString(),
                message = "该计划已有正在执行的任务。"
            });

        var job = new MakeupExamAutoJob
        {
            MakeupExamPlanId = planId
        };
        db.MakeupExamAutoJobs.Add(job);
        db.BackgroundJobOutboxMessages.Add(
            BackgroundJobOutboxMessage.Create(
                BackgroundJobKind.MakeupExamAuto,
                job.Id));
        await db.SaveChangesAsync(cancellationToken);

        return AcceptedAtAction(nameof(GetAutoJob), new { jobId = job.Id },
            new { jobId = job.Id, status = job.Status.ToString() });
    }

    [HttpGet("auto-jobs/{jobId:guid}")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetAutoJob(
        Guid jobId,
        CancellationToken cancellationToken)
    {
        var job = await db.MakeupExamAutoJobs.AsNoTracking()
            .FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
        if (job is null) return NotFound();
        return Ok(new
        {
            job.Id,
            job.MakeupExamPlanId,
            Status = job.Status.ToString(),
            job.TotalCourses,
            job.ProcessedCourses,
            job.CreatedSessions,
            job.EnrolledStudents,
            Messages = !string.IsNullOrEmpty(job.MessagesJson)
                ? System.Text.Json.JsonSerializer.Deserialize<List<string>>(job.MessagesJson)
                : new List<string>(),
            job.ErrorMessage,
            job.CreatedAt,
            job.StartedAt,
            job.CompletedAt
        });
    }

    // ═══════════════════════════════════════════
    //  Enrollments
    // ═══════════════════════════════════════════

    [HttpGet("eligible-students")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetEligibleStudents(
        Guid teachingTaskId,
        CancellationToken cancellationToken)
    {
        var students = await eligibilityService.GetEligibleStudentsAsync(
            teachingTaskId, cancellationToken);
        return Ok(students);
    }

    [HttpPost("sessions/{id:guid}/enroll")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> EnrollStudents(
        Guid id,
        EnrollStudentsRequest request,
        CancellationToken cancellationToken)
    {
        var session = await db.MakeupExamSessions
            .Include(x => x.MakeupExamPlan)
            .Include(x => x.Enrollments)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (session is null) return NotFound();
        if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft)
            return ConflictProblem("已发布的补考计划不能调整登记。");

        var eligible = await eligibilityService.GetEligibleStudentsAsync(
            session.TeachingTaskId, cancellationToken);
        var eligibleIds = eligible.Select(e => e.StudentId).ToHashSet();

        var alreadyEnrolled = session.Enrollments.Select(e => e.StudentId).ToHashSet();
        var validIds = request.StudentIds
            .Where(sid => !alreadyEnrolled.Contains(sid))
            .Distinct()
            .ToList();

        if (validIds.Count == 0)
            return ConflictProblem("所选学生均已登记或无效。");

        var invalidIds = validIds.Where(sid => !eligibleIds.Contains(sid)).ToList();
        if (invalidIds.Count > 0)
            return ConflictProblem($"{invalidIds.Count} 名学生不符合补考资格。");

        // Check for time conflicts with other makeup sessions
        var conflictIds = await db.MakeupExamEnrollments.AsNoTracking()
            .Where(x => x.MakeupExamSession!.StartsAt < session.EndsAt &&
                        session.StartsAt < x.MakeupExamSession.EndsAt)
            .WhereIn(validIds, x => x.StudentId)
            .Select(x => x.StudentId)
            .Distinct()
            .ToListAsync(cancellationToken);

        if (conflictIds.Count > 0)
        {
            var conflictNumbers = await db.Students
                .WhereIn(conflictIds, s => s.Id)
                .Select(s => s.StudentNumber)
                .ToListAsync(cancellationToken);
            return ConflictProblem(
                $"以下学生该时段已有其他补考：{string.Join("、", conflictNumbers)}");
        }

        foreach (var studentId in validIds)
        {
            var info = eligible.First(e => e.StudentId == studentId);
            session.Enrollments.Add(new MakeupExamEnrollment
            {
                MakeupExamSessionId = id,
                StudentId = studentId,
                Reason = info.Reason,
                SourceGradeRecordId = info.SourceGradeRecordId,
                SourceDeferredExamId = info.SourceDeferredExamId
            });
        }

        return await SaveAsync(id, false, cancellationToken);
    }

    [HttpDelete("sessions/{id:guid}/enrollments/{studentId:guid}")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> RemoveEnrollment(
        Guid id,
        Guid studentId,
        CancellationToken cancellationToken)
    {
        var enrollment = await db.MakeupExamEnrollments
            .Include(x => x.MakeupExamSession)
                .ThenInclude(x => x!.MakeupExamPlan)
            .FirstOrDefaultAsync(x => x.MakeupExamSessionId == id &&
                                      x.StudentId == studentId, cancellationToken);
        if (enrollment is null) return NotFound();
        if (enrollment.MakeupExamSession!.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft)
            return ConflictProblem("已发布的补考计划不能调整登记。");
        db.MakeupExamEnrollments.Remove(enrollment);
        return await SaveAsync(id, false, cancellationToken);
    }

    // ═══════════════════════════════════════════
    //  Scores
    // ═══════════════════════════════════════════

    [HttpPut("sessions/{id:guid}/scores")]
    [Authorize(Roles = ScoreEnterers)]
    public async Task<ActionResult> RecordScores(
        Guid id,
        List<RecordMakeupScoreRequest> scores,
        CancellationToken cancellationToken)
    {
        var session = await db.MakeupExamSessions
            .Include(x => x.MakeupExamPlan)
            .Include(x => x.Enrollments)
            .Include(x => x.TeachingTask!)
                .ThenInclude(x => x.Teachers)
                .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (session is null) return NotFound();
        if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published)
            return ConflictProblem("只有已发布的补考计划可以录入成绩。");

        // Teachers can only record scores for courses they teach
        if (!IsManager())
        {
            var userId = currentUserDataScope.Current.UserId;
            var teaches = session.TeachingTask!.Teachers
                .Any(t => t.Teacher!.UserId == userId);
            if (!teaches)
                return Forbid();
        }

        var enrollmentByStudent = session.Enrollments.ToDictionary(e => e.StudentId);

        foreach (var entry in scores)
        {
            if (!enrollmentByStudent.TryGetValue(entry.StudentId, out var enrollment))
                continue;

            enrollment.MakeupScore = entry.Score;

            // Update the grade record — 补考合格按60分记
            if (enrollment.SourceGradeRecordId.HasValue)
            {
                var record = await db.GradeRecords
                    .FirstOrDefaultAsync(x => x.Id == enrollment.SourceGradeRecordId.Value,
                        cancellationToken);
                if (record is not null)
                {
                    record.ExamStatus = GradeExamStatus.Makeup;
                    // Cap passing score at 60
                    var cappedScore = entry.Score >= 60 ? 60m : entry.Score;
                    record.TotalScore = cappedScore;
                    record.GradePoint = GradeCalculator.CalculateGradePoint(cappedScore);
                    record.Notes = entry.Score >= 60
                        ? $"补考合格（原始{entry.Score}分，按60分记）"
                        : $"补考不合格（{entry.Score}分）";
                }
            }
        }

        return await SaveAsync(id, false, cancellationToken);
    }

    // ═══════════════════════════════════════════
    //  Roster
    // ═══════════════════════════════════════════

    [HttpGet("sessions/{id:guid}/roster")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken)
    {
        var session = await db.MakeupExamSessions.AsNoTracking()
            .Where(x => x.Id == id)
            .Select(x => new
            {
                x.Id,
                x.TeachingTaskId,
                x.TeachingTask!.TaskNumber,
                CourseName = x.TeachingTask.Course!.Name,
                ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配",
                x.StartsAt
            }).FirstOrDefaultAsync(cancellationToken);
        if (session is null) return NotFound();

        var students = await db.MakeupExamEnrollments.AsNoTracking()
            .Where(e => e.MakeupExamSessionId == id)
            .OrderBy(e => e.Student!.StudentNumber)
            .Select(e => new
            {
                e.StudentId,
                e.Student!.StudentNumber,
                e.Student.Name,
                ClassName = e.Student.AdministrativeClass!.Name,
                e.Reason,
                e.MakeupScore
            })
            .ToListAsync(cancellationToken);

        return Ok(new
        {
            session.Id,
            session.TeachingTaskId,
            session.TaskNumber,
            session.CourseName,
            session.ClassroomName,
            session.StartsAt,
            Students = students.Select((student, index) => new
            {
                student.StudentId,
                student.StudentNumber,
                student.Name,
                student.ClassName,
                student.Reason,
                student.MakeupScore,
                SeatNumber = (index + 1).ToString("D3")
            })
        });
    }

    // ═══════════════════════════════════════════
    //  Query helpers
    // ═══════════════════════════════════════════

    [HttpGet("available-classrooms")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetAvailableClassrooms(
        Guid academicTermId,
        DateOnly examDate,
        int startPeriod,
        int periodCount,
        Guid? requiredBuildingId,
        Guid? planId,
        Guid? excludeSessionId,
        CancellationToken cancellationToken)
    {
        var timeResult = ResolveExamTime(
            academicTermId, examDate, startPeriod, periodCount);
        if (timeResult.Error is not null) return timeResult.Error;
        var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);

        var query = db.Classrooms.AsNoTracking()
            .Where(x => x.IsEnabled);

        if (requiredBuildingId.HasValue)
            query = query.Where(x => x.BuildingId == requiredBuildingId.Value);

        var occupiedQuery = db.MakeupExamSessions.AsNoTracking()
            .Where(x => x.ClassroomId != null &&
                        x.StartsAt < endsAt && startsAt < x.EndsAt);
        if (planId.HasValue)
            occupiedQuery = occupiedQuery.Where(x => x.MakeupExamPlanId == planId.Value);
        if (excludeSessionId.HasValue)
            occupiedQuery = occupiedQuery.Where(x => x.Id != excludeSessionId.Value);

        var occupiedIds = await occupiedQuery.Select(x => x.ClassroomId!.Value)
            .ToListAsync(cancellationToken);

        if (occupiedIds.Count > 0)
            query = query.WhereNotIn(occupiedIds, x => x.Id);

        return Ok(await query.OrderBy(x => x.Building!.Name)
            .ThenBy(x => x.Capacity)
            .Select(x => new
            {
                x.Id,
                x.Name,
                x.Capacity,
                x.RoomType,
                x.BuildingId,
                BuildingName = x.Building!.Name
            }).ToListAsync(cancellationToken));
    }

    [HttpGet("available-teachers")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetAvailableTeachers(
        Guid academicTermId,
        DateOnly examDate,
        int startPeriod,
        int periodCount,
        Guid? planId,
        Guid? excludeSessionId,
        CancellationToken cancellationToken)
    {
        var timeResult = ResolveExamTime(
            academicTermId, examDate, startPeriod, periodCount);
        if (timeResult.Error is not null) return timeResult.Error;
        var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt);

        var busyQuery = db.MakeupExamSessionInvigilators.AsNoTracking()
            .Where(x => x.MakeupExamSession!.StartsAt < endsAt &&
                        startsAt < x.MakeupExamSession!.EndsAt);
        if (planId.HasValue)
            busyQuery = busyQuery.Where(x =>
                x.MakeupExamSession!.MakeupExamPlanId == planId.Value);
        if (excludeSessionId.HasValue)
            busyQuery = busyQuery.Where(x => x.MakeupExamSessionId != excludeSessionId.Value);

        var busyIds = await busyQuery.Select(x => x.TeacherId)
            .ToListAsync(cancellationToken);

        return Ok(await db.Teachers.AsNoTracking()
            .Where(x => x.Status == TeacherStatus.Active)
            .WhereNotIn(busyIds, x => x.Id)
            .OrderBy(x => x.Name)
            .Select(x => new
            {
                x.Id,
                x.TeacherNumber,
                x.Name,
                x.Title,
                CollegeName = x.College!.Name
            }).ToListAsync(cancellationToken));
    }

    [HttpGet("time-slots-for-term")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetTimeSlotsForTerm(
        Guid academicTermId,
        CancellationToken cancellationToken) =>
        Ok(await db.ScheduleTimeSlots.AsNoTracking()
            .Where(x => x.AcademicTermId == academicTermId && x.IsEnabled)
            .OrderBy(x => x.PeriodNumber)
            .Select(x => new
            {
                x.Id,
                x.PeriodNumber,
                x.Name,
                StartsAt = x.StartsAt.ToString("HH:mm"),
                EndsAt = x.EndsAt.ToString("HH:mm"),
                x.IsEnabled
            })
            .ToListAsync(cancellationToken));

    // ═══════════════════════════════════════════
    //  My schedule
    // ═══════════════════════════════════════════

    [HttpGet("my-schedule")]
    public async Task<ActionResult> GetMySchedule(CancellationToken cancellationToken)
    {
        var scope = currentUserDataScope.Current;
        if (scope.IsInRole(SystemRoles.Student))
        {
            var studentId = await db.Students.Where(x => x.UserId == scope.UserId)
                .Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
            if (!studentId.HasValue)
                return ConflictProblem("当前账号未关联学生档案。");
            var schedule = await db.MakeupExamEnrollments.AsNoTracking()
                .Where(x => x.StudentId == studentId &&
                    x.MakeupExamSession!.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published)
                .OrderBy(x => x.MakeupExamSession!.ExamDate)
                .ThenBy(x => x.MakeupExamSession!.StartPeriod)
                .Select(x => new
                {
                    x.MakeupExamSession!.Id,
                    PlanName = x.MakeupExamSession.MakeupExamPlan!.Name,
                    x.MakeupExamSession.ExamDate,
                    x.MakeupExamSession.StartPeriod,
                    x.MakeupExamSession.PeriodCount,
                    x.MakeupExamSession.StartsAt,
                    x.MakeupExamSession.EndsAt,
                    x.MakeupExamSession.TeachingTask!.TaskNumber,
                    CourseCode = x.MakeupExamSession.TeachingTask.Course!.Code,
                    CourseName = x.MakeupExamSession.TeachingTask.Course.Name,
                    ClassroomName = x.MakeupExamSession.Classroom != null
                        ? x.MakeupExamSession.Classroom.Name : null,
                    BuildingName = x.MakeupExamSession.Classroom != null
                        ? x.MakeupExamSession.Classroom.Building!.Name : null,
                    x.Reason,
                    x.MakeupScore,
                    IsMakeup = true
                })
                .ToListAsync(cancellationToken);
            if (schedule.Count == 0) return Ok(schedule);

            var sessionIds = schedule.Select(x => x.Id).Distinct().ToArray();
            var seatRows = await db.MakeupExamEnrollments.AsNoTracking()
                .WhereIn(sessionIds, x => x.MakeupExamSessionId)
                .OrderBy(x => x.Student!.StudentNumber)
                .Select(x => new
                {
                    x.MakeupExamSessionId,
                    x.StudentId,
                    x.Student!.StudentNumber
                })
                .ToListAsync(cancellationToken);
            var seatNumbers = seatRows
                .GroupBy(x => x.MakeupExamSessionId)
                .SelectMany(group => group.Select((row, index) => new
                {
                    row.MakeupExamSessionId,
                    row.StudentId,
                    SeatNumber = (index + 1).ToString("D3")
                }))
                .ToDictionary(x => (x.MakeupExamSessionId, x.StudentId), x => x.SeatNumber);

            return Ok(schedule.Select(x => new
            {
                x.Id,
                x.PlanName,
                x.ExamDate,
                x.StartPeriod,
                x.PeriodCount,
                x.StartsAt,
                x.EndsAt,
                x.TaskNumber,
                x.CourseCode,
                x.CourseName,
                x.ClassroomName,
                x.BuildingName,
                x.Reason,
                x.MakeupScore,
                SeatNumber = seatNumbers.GetValueOrDefault((x.Id, studentId.Value)),
                x.IsMakeup
            }));
        }
        if (scope.IsInRole(SystemRoles.Teacher))
        {
            return Ok(await db.MakeupExamSessions.AsNoTracking()
                .Where(x => x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published &&
                    x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId))
                .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
                .Select(x => new
                {
                    x.Id,
                    PlanName = x.MakeupExamPlan!.Name,
                    x.ExamDate,
                    x.StartPeriod,
                    x.PeriodCount,
                    x.StartsAt,
                    x.EndsAt,
                    x.TeachingTask!.TaskNumber,
                    CourseCode = x.TeachingTask.Course!.Code,
                    CourseName = x.TeachingTask.Course.Name,
                    ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
                    BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
                    InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name),
                    EnrolledCount = x.Enrollments.Count,
                    IsMakeup = true
                })
                .ToListAsync(cancellationToken));
        }
        return Ok(Array.Empty<object>());
    }

    // ═══════════════════════════════════════════
    //  My teaching sessions (for score entry)
    // ═══════════════════════════════════════════

    [HttpGet("my-teaching-sessions")]
    [Authorize(Roles = SystemRoles.Teacher)]
    public async Task<ActionResult> GetMyTeachingSessions(CancellationToken cancellationToken)
    {
        var userId = currentUserDataScope.Current.UserId;
        return Ok(await db.MakeupExamSessions.AsNoTracking()
            .Where(x => x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published &&
                x.TeachingTask!.Teachers.Any(t => t.Teacher!.UserId == userId))
            .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
            .Select(x => new
            {
                x.Id,
                PlanName = x.MakeupExamPlan!.Name,
                x.ExamDate,
                x.StartPeriod,
                x.PeriodCount,
                x.StartsAt,
                x.EndsAt,
                x.TeachingTask!.TaskNumber,
                CourseCode = x.TeachingTask.Course!.Code,
                CourseName = x.TeachingTask.Course.Name,
                ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
                BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null,
                EnrolledCount = x.Enrollments.Count,
                GradedCount = x.Enrollments.Count(e => e.MakeupScore != null),
                Enrollments = x.Enrollments.OrderBy(e => e.Student!.StudentNumber)
                    .Select(e => new
                    {
                        e.StudentId,
                        e.Student!.StudentNumber,
                        e.Student.Name,
                        ClassName = e.Student.AdministrativeClass!.Name,
                        e.Reason,
                        e.MakeupScore
                    }).ToList(),
                IsMakeup = true
            })
            .ToListAsync(cancellationToken));
    }

    // ═══════════════════════════════════════════
    //  Private helpers
    // ═══════════════════════════════════════════

    private (DateTime StartsAt, DateTime EndsAt, ActionResult? Error) ResolveExamTime(
        Guid academicTermId,
        DateOnly examDate,
        int startPeriod,
        int periodCount)
    {
        var slots = db.ScheduleTimeSlots.AsNoTracking()
            .Where(x => x.AcademicTermId == academicTermId && x.IsEnabled)
            .OrderBy(x => x.PeriodNumber)
            .ToList();

        if (slots.Count == 0)
            return (default, default,
                ValidationProblem("当前学期未配置上课时间表，请先在排课设置中配置节次时间。"));

        var slotDict = slots.ToDictionary(x => x.PeriodNumber);
        var startSlot = slotDict.GetValueOrDefault(startPeriod);
        var endSlot = slotDict.GetValueOrDefault(startPeriod + periodCount - 1);

        if (startSlot is null)
            return (default, default,
                ValidationProblem($"起始节次 {startPeriod} 不在已配置的时间表中。"));
        if (endSlot is null)
            return (default, default,
                ValidationProblem(
                    $"结束节次 {startPeriod + periodCount - 1} 不在已配置的时间表中。"));

        return (
            examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc),
            examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc),
            null
        );
    }

    private async Task<ActionResult?> ValidateSessionAsync(
        MakeupExamPlan plan,
        Guid? currentId,
        Guid teachingTaskId,
        Guid? classroomId,
        IReadOnlyCollection<Guid>? invigilatorIds,
        DateTime startsAt,
        DateTime endsAt,
        CancellationToken cancellationToken)
    {
        if (startsAt >= endsAt) return ValidationProblem("考试开始时间必须早于结束时间。");
        var task = await db.TeachingTasks.AsNoTracking()
            .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
        if (task is null || task.AcademicTermId != plan.AcademicTermId)
            return ValidationProblem("教学班与补考计划必须属于同一学期。");

        if (classroomId.HasValue)
        {
            var room = await db.Classrooms.AsNoTracking()
                .FirstOrDefaultAsync(
                    x => x.Id == classroomId.Value && x.IsEnabled, cancellationToken);
            if (room is null) return ValidationProblem("所选考场不存在或已停用。");
        }

        var teacherIds = (invigilatorIds ?? []).Distinct().ToArray();
        if (teacherIds.Length > 0)
        {
            if (await db.Teachers
                    .Where(x => x.Status == TeacherStatus.Active)
                    .WhereIn(teacherIds, x => x.Id)
                    .CountAsync(cancellationToken) != teacherIds.Length)
                return ValidationProblem("存在无效监考教师。");
        }

        var overlaps = db.MakeupExamSessions.Where(x => x.MakeupExamPlanId == plan.Id &&
            x.Id != currentId && x.StartsAt < endsAt && startsAt < x.EndsAt);

        if (classroomId.HasValue)
        {
            if (await overlaps.AnyAsync(
                    x => x.ClassroomId == classroomId.Value, cancellationToken))
                return ConflictProblem("该时段考场已被占用。");
        }

        if (teacherIds.Length > 0)
        {
            if (await db.MakeupExamSessionInvigilators
                    .Where(i => overlaps.Any(x => x.Id == i.MakeupExamSessionId))
                    .WhereIn(teacherIds, i => i.TeacherId)
                    .AnyAsync(cancellationToken))
                return ConflictProblem("监考教师在该时段已有补考任务。");
        }

        return null;
    }

    private bool IsManager() =>
        currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
        currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);

    private Task<ExamArrangementJob?> FindActiveArrangementJobAsync(
        Guid planId,
        CancellationToken cancellationToken) =>
        db.ExamArrangementJobs.AsNoTracking()
            .Where(x =>
                x.Kind == ExamArrangementKind.MakeupExam &&
                x.ActivePlanId == planId &&
                (x.Status == ExamArrangementJobStatus.Queued ||
                 x.Status == ExamArrangementJobStatus.Running))
            .OrderByDescending(x => x.CreatedAt)
            .FirstOrDefaultAsync(cancellationToken);

    private ActionResult AcceptedArrangementJob(
        ExamArrangementJob job,
        string message) =>
        AcceptedAtAction(
            nameof(GetArrangementJob),
            new { jobId = job.Id },
            new
            {
                jobId = job.Id,
                status = job.Status.ToString(),
                message
            });

    private static object ToArrangementJobResponse(ExamArrangementJob job) => new
    {
        job.Id,
        job.PlanId,
        Kind = job.Kind.ToString(),
        Status = job.Status.ToString(),
        job.TotalSessions,
        job.ProcessedSessions,
        job.CurrentStep,
        job.ResultMessage,
        job.ErrorMessage,
        job.AssignClassrooms,
        job.AssignInvigilators,
        job.CreatedAt,
        job.StartedAt,
        job.CompletedAt
    };

    private Task<ExamPublishJob?> FindActivePublishJobAsync(
        Guid planId,
        CancellationToken cancellationToken) =>
        db.ExamPublishJobs.AsNoTracking()
            .Where(x =>
                x.Kind == ExamPublishJobKind.MakeupExam &&
                x.ActivePlanId == planId &&
                (x.Status == ExamPublishJobStatus.Queued ||
                 x.Status == ExamPublishJobStatus.Running))
            .OrderByDescending(x => x.CreatedAt)
            .FirstOrDefaultAsync(cancellationToken);

    private ActionResult AcceptedPublishJob(
        ExamPublishJob job,
        string message) =>
        AcceptedAtAction(
            nameof(GetPublishJob),
            new { jobId = job.Id },
            new
            {
                jobId = job.Id,
                status = job.Status.ToString(),
                message
            });

    private static object ToPublishJobResponse(ExamPublishJob job) => new
    {
        job.Id,
        job.PlanId,
        Kind = job.Kind.ToString(),
        Status = job.Status.ToString(),
        job.CurrentStep,
        job.ErrorMessage,
        job.CreatedAt,
        job.StartedAt,
        job.CompletedAt
    };

    private async Task<ActionResult> SaveAsync(Guid id, bool created,
        CancellationToken token)
    {
        try
        {
            await db.SaveChangesAsync(token);
            return created ? Created(string.Empty, new { id }) : NoContent();
        }
        catch (DbUpdateException)
        {
            return ConflictProblem("补考场次重复，或关联数据已发生变化。");
        }
    }

    private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
    {
        Title = "无法完成补考操作",
        Detail = detail,
        Status = StatusCodes.Status409Conflict
    });
    private static string? Normalize(string? value) =>
        string.IsNullOrWhiteSpace(value) ? null : value.Trim();
    private static string? SerializeBuildingIds(IReadOnlyCollection<Guid>? ids) =>
        ids is { Count: > 0 }
            ? System.Text.Json.JsonSerializer.Serialize(ids)
            : null;
}

// ═══════════════════════════════════════════
//  Request/Response models
// ═══════════════════════════════════════════

public sealed record MakeupExamPlanRequest(
    Guid AcademicTermId,
    [Required, MaxLength(120)] string Name,
    [MaxLength(500)] string? Notes);

public sealed record CreateMakeupExamSessionRequest(
    Guid TeachingTaskId,
    Guid? ClassroomId,
    DateOnly ExamDate,
    [Range(1, 30)] int StartPeriod,
    [Range(1, 6)] int PeriodCount,
    Guid? RequiredBuildingId,
    IReadOnlyCollection<Guid>? RequiredBuildingIds,
    [Range(1, 10)] int RequiredInvigilatorCount,
    IReadOnlyCollection<Guid>? InvigilatorIds,
    [MaxLength(500)] string? Notes);

public sealed record CreateMakeupExamSessionsBatchRequest(
    [Required] IReadOnlyCollection<Guid> TeachingTaskIds,
    DateOnly ExamDate,
    [Range(1, 30)] int StartPeriod,
    [Range(1, 6)] int PeriodCount,
    Guid? RequiredBuildingId,
    IReadOnlyCollection<Guid>? RequiredBuildingIds,
    [Range(1, 10)] int RequiredInvigilatorCount,
    [MaxLength(500)] string? Notes);

public sealed record EnrollStudentsRequest(
    [Required] IReadOnlyCollection<Guid> StudentIds);

public sealed record RecordMakeupScoreRequest(
    Guid StudentId,
    [Range(0, 100)] decimal Score);
