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.Caching;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace Jiaowu.Api.Controllers;

[ApiController]
[Authorize]
[Route("api/exams")]
public sealed class ExamsController(
    AppDbContext db,
    ICurrentUserDataScope currentUserDataScope,
    IAppCache cache) : ControllerBase
{
    private const string Managers =
        SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;

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

    [HttpGet("plans")]
    public async Task<ActionResult> GetPlans(
        Guid? academicTermId,
        CancellationToken cancellationToken)
    {
        var source = db.ExamPlans.AsNoTracking().AsQueryable();
        if (academicTermId.HasValue)
            source = source.Where(x => x.AcademicTermId == academicTermId);
        if (!IsManager())
            source = source.Where(x => x.Status == ExamPlanStatus.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(
        ExamPlanRequest request,
        CancellationToken cancellationToken)
    {
        if (!await db.AcademicTerms.AnyAsync(
                x => x.Id == request.AcademicTermId && x.IsEnabled,
                cancellationToken))
            return ValidationProblem("所选学期不存在或已停用。");
        var plan = new ExamPlan
        {
            AcademicTermId = request.AcademicTermId,
            Name = request.Name.Trim(),
            Notes = Normalize(request.Notes)
        };
        db.ExamPlans.Add(plan);
        return await SaveAsync(plan.Id, true, cancellationToken);
    }

    [HttpDelete("plans/{id:guid}")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> DeletePlan(
        Guid id,
        CancellationToken cancellationToken)
    {
        if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
            return ConflictProblem("考试计划正在后台编排，暂时不能删除。");
        var plan = await db.ExamPlans
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (plan is null) return NotFound();
        if (plan.Status != ExamPlanStatus.Draft)
            return ConflictProblem("只有草稿考试计划可以删除。");

        var rooms = await db.ExamRooms
            .Where(x => x.ExamPlanId == id)
            .ToListAsync(cancellationToken);
        db.ExamRooms.RemoveRange(rooms);
        db.ExamPlans.Remove(plan);
        try
        {
            await db.SaveChangesAsync(cancellationToken);
            await cache.RemoveByTagAsync(
                AppCacheTags.Timetables,
                cancellationToken);
            return NoContent();
        }
        catch (DbUpdateException)
        {
            return ConflictProblem("删除考试计划失败，关联数据可能已发生变化。");
        }
    }

    [HttpGet("plans/{id:guid}")]
    public async Task<ActionResult> GetPlan(
        Guid id,
        int page = 1,
        int pageSize = 20,
        string? keyword = null,
        string? allocation = null,
        CancellationToken cancellationToken = default)
    {
        page = Math.Max(1, page);
        pageSize = Math.Clamp(pageSize, 10, 100);
        keyword = Normalize(keyword);
        allocation = Normalize(allocation)?.ToLowerInvariant();
        if (allocation is not null and not ("room" or "invigilator" or "complete"))
            return ValidationProblem("分配状态筛选值无效。");

        var manager = IsManager();
        var plan = await db.ExamPlans.AsNoTracking()
            .Where(x => x.Id == id && (manager || x.Status == ExamPlanStatus.Published))
            .Select(x => new
            {
                x.Id,
                x.Name,
                x.AcademicTermId,
                TermName = x.AcademicTerm!.Name,
                x.Status,
                x.Notes,
                x.PublishedAt,
                TotalSessionCount = x.Sessions.Count
            }).FirstOrDefaultAsync(cancellationToken);
        if (plan is null) return NotFound();

        var sessionQuery = db.ExamSessions.AsNoTracking()
            .Where(x => x.ExamPlanId == id);
        if (keyword is not null)
        {
            sessionQuery = sessionQuery.Where(x =>
                x.TeachingTask!.TaskNumber.Contains(keyword) ||
                x.TeachingTask.Name.Contains(keyword) ||
                x.TeachingTask.Course!.Code.Contains(keyword) ||
                x.TeachingTask.Course.Name.Contains(keyword));
        }

        sessionQuery = allocation switch
        {
            "room" => sessionQuery.Where(x =>
                !x.RoomLinks.Any() && !x.ClassroomId.HasValue),
            "invigilator" => sessionQuery.Where(x =>
                (x.RoomLinks.Any() && x.RoomLinks.Any(link =>
                    link.ExamRoom!.Invigilators.Count <
                    x.RequiredInvigilatorCount)) ||
                (!x.RoomLinks.Any() &&
                 x.Invigilators.Count < x.RequiredInvigilatorCount)),
            "complete" => sessionQuery.Where(x =>
                (x.RoomLinks.Any() || x.ClassroomId.HasValue) &&
                ((x.RoomLinks.Any() && x.RoomLinks.All(link =>
                      link.ExamRoom!.Invigilators.Count >=
                      x.RequiredInvigilatorCount)) ||
                 (!x.RoomLinks.Any() &&
                  x.Invigilators.Count >= x.RequiredInvigilatorCount))),
            _ => sessionQuery
        };

        var filteredSessionCount = await sessionQuery.CountAsync(cancellationToken);
        var lastPage = Math.Max(1,
            (int)Math.Ceiling(filteredSessionCount / (double)pageSize));
        page = Math.Min(page, lastPage);
        var sessions = await sessionQuery
            .OrderBy(item => item.ExamDate)
            .ThenBy(item => item.StartPeriod)
            .ThenBy(item => item.TeachingTask!.TaskNumber)
            .ThenBy(item => item.Id)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .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)
            })
            .ToListAsync(cancellationToken);
        var pageSessionIds = sessions.Select(x => x.Id).ToArray();
        var scheduledTeachingTaskIds = await db.ExamSessions.AsNoTracking()
            .Where(x => x.ExamPlanId == id)
            .OrderBy(x => x.TeachingTaskId)
            .Select(x => x.TeachingTaskId)
            .ToListAsync(cancellationToken);

        var roomSessionLinks = await db.ExamRoomSessions.AsNoTracking()
            .WhereIn(pageSessionIds, x => x.ExamSessionId)
            .Select(x => new
            {
                x.ExamRoomId,
                x.ExamSessionId,
                SeatCount = x.ExamRoom!.Seats.Count(seat =>
                    seat.ExamSessionId == x.ExamSessionId)
            })
            .ToListAsync(cancellationToken);
        var pageRoomIds = roomSessionLinks.Select(x => x.ExamRoomId).Distinct().ToArray();
        var roomSummaries = await db.ExamRooms.AsNoTracking()
            .WhereIn(pageRoomIds, x => x.Id)
            .OrderBy(x => x.Classroom!.Building!.Name)
            .ThenBy(x => x.Classroom!.Name)
            .ThenBy(x => x.Id)
            .Select(x => new
            {
                ExamRoomId = x.Id,
                x.ClassroomId,
                ClassroomName = x.Classroom!.Name,
                BuildingName = x.Classroom.Building!.Name,
                ClassroomCapacity = x.Classroom.Capacity,
                TotalSeatCount = x.Seats.Count,
                SessionCount = x.SessionLinks.Count
            })
            .ToListAsync(cancellationToken);
        var roomInvigilators = await db.ExamRoomInvigilators.AsNoTracking()
            .WhereIn(pageRoomIds, x => x.ExamRoomId)
            .OrderBy(x => x.Teacher!.TeacherNumber)
            .ThenBy(x => x.Teacher!.Name)
            .Select(x => new
            {
                x.ExamRoomId,
                x.TeacherId,
                TeacherName = x.Teacher!.Name
            })
            .ToListAsync(cancellationToken);

        var linksByRoom = roomSessionLinks.ToLookup(x => x.ExamRoomId);
        var invigilatorsByRoom = roomInvigilators.ToLookup(x => x.ExamRoomId);
        var roomsBySession = roomSummaries
            .SelectMany(room => linksByRoom[room.ExamRoomId].Select(link => new
            {
                link.ExamSessionId,
                Room = new
                {
                    room.ExamRoomId,
                    room.ClassroomId,
                    room.ClassroomName,
                    room.BuildingName,
                    room.ClassroomCapacity,
                    link.SeatCount,
                    room.TotalSeatCount,
                    IsMixed = room.SessionCount > 1,
                    InvigilatorIds = invigilatorsByRoom[room.ExamRoomId]
                        .Select(invigilator => invigilator.TeacherId)
                        .ToList(),
                    InvigilatorNames = invigilatorsByRoom[room.ExamRoomId]
                        .Select(invigilator => invigilator.TeacherName)
                        .ToList()
                }
            }))
            .ToLookup(x => x.ExamSessionId, x => x.Room);
        var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
                db,
                sessions.Select(x => x.TeachingTaskId),
                cancellationToken))
            .GroupBy(x => x.TeachingTaskId)
            .ToDictionary(x => x.Key, x => x.Count());
        return Ok(new
        {
            plan.Id,
            plan.Name,
            plan.AcademicTermId,
            plan.TermName,
            plan.Status,
            plan.Notes,
            plan.PublishedAt,
            plan.TotalSessionCount,
            FilteredSessionCount = filteredSessionCount,
            SessionPage = page,
            SessionPageSize = pageSize,
            ScheduledTeachingTaskIds = scheduledTeachingTaskIds,
            Sessions = sessions.Select(item => new
            {
                item.Id,
                item.TeachingTaskId,
                item.TaskNumber,
                item.TaskName,
                item.CourseCode,
                item.CourseName,
                item.ClassroomId,
                item.ClassroomName,
                item.BuildingName,
                item.ClassroomCapacity,
                item.ExamDate,
                item.StartPeriod,
                item.PeriodCount,
                item.StartsAt,
                item.EndsAt,
                item.RequiredBuildingId,
                item.RequiredBuildingIds,
                item.RequiredBuildingName,
                item.RequiredInvigilatorCount,
                item.Notes,
                item.InvigilatorIds,
                item.InvigilatorNames,
                ExamRooms = roomsBySession[item.Id],
                StudentCount = rosterCounts.GetValueOrDefault(item.TeachingTaskId)
            })
        });
    }

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

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

        // Resolve time from slots
        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 ExamSession
        {
            ExamPlanId = 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 ExamSessionInvigilator { TeacherId = id }).ToList()
        };
        db.ExamSessions.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,
        CreateExamSessionsBatchRequest request,
        CancellationToken cancellationToken)
    {
        if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
            return ConflictProblem("考试计划正在后台编排，暂时不能调整场次。");
        var plan = await db.ExamPlans.FindAsync([planId], cancellationToken);
        if (plan is null) return NotFound();
        if (plan.Status != ExamPlanStatus.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.ExamSessions.AsNoTracking()
            .Where(x => x.ExamPlanId == planId)
            .WhereIn(taskIds, x => x.TeachingTaskId)
            .Select(x => x.TeachingTaskId)
            .ToListAsync(cancellationToken);
        if (duplicateTaskIds.Count > 0)
            return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前计划中安排考试。");

        var rosterPairs = await TeachingTaskRosterQuery.LoadForTasksAsync(
            db,
            taskIds,
            cancellationToken);
        var conflictedStudentCount = rosterPairs
            .GroupBy(x => x.StudentId)
            .Count(group => group.Select(x => x.TeachingTaskId).Distinct().Skip(1).Any());
        if (conflictedStudentCount > 0)
            return ConflictProblem(
                $"所选教学班在同一时段安排会造成 {conflictedStudentCount} 名学生考试冲突，请分批选择不同时间。");

        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.ExamSessions.AddRange(taskIds.Select(taskId => new ExamSession
        {
            ExamPlanId = 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);
            await cache.RemoveByTagAsync(AppCacheTags.Timetables, 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,
        CreateExamSessionRequest request,
        CancellationToken cancellationToken)
    {
        if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
            return ConflictProblem("考试计划正在后台编排，暂时不能调整场次。");
        var session = await db.ExamSessions
            .Include(x => x.ExamPlan)
            .Include(x => x.Invigilators)
            .FirstOrDefaultAsync(x => x.Id == id && x.ExamPlanId == planId, cancellationToken);
        if (session is null) return NotFound();
        if (session.ExamPlan!.Status != ExamPlanStatus.Draft)
            return ConflictProblem("已发布的考试计划不能调整场次。");

        var timeResult = ResolveExamTime(session.ExamPlan.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.ExamPlan, 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);

        await RemoveRoomsForSessionsAsync([id], cancellationToken);
        db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
        session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid =>
            new ExamSessionInvigilator { 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.ExamSessions.Include(x => x.ExamPlan)
            .FirstOrDefaultAsync(x => x.Id == id && x.ExamPlanId == planId, cancellationToken);
        if (session is null) return NotFound();
        if (session.ExamPlan!.Status != ExamPlanStatus.Draft)
            return ConflictProblem("已发布的考试计划不能调整场次。");
        await RemoveRoomsForSessionsAsync([id], cancellationToken);
        db.ExamSessions.Remove(session);
        return await SaveAsync(id, false, cancellationToken);
    }

    [HttpPost("plans/{planId:guid}/sessions/batch-remove")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> RemoveSessionsBatch(
        Guid planId,
        RemoveExamSessionsBatchRequest request,
        CancellationToken cancellationToken)
    {
        if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
            return ConflictProblem("考试计划正在后台编排，暂时不能调整场次。");
        var sessionIds = request.SessionIds.Distinct().ToArray();
        if (sessionIds.Length == 0)
            return ValidationProblem("请至少选择一个考试场次。");
        if (sessionIds.Length > 100)
            return ValidationProblem("一次最多移除100个考试场次。");

        var plan = await db.ExamPlans.AsNoTracking()
            .FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
        if (plan is null) return NotFound();
        if (plan.Status != ExamPlanStatus.Draft)
            return ConflictProblem("已发布的考试计划不能调整场次。");

        var sessions = await db.ExamSessions
            .Where(x => x.ExamPlanId == planId)
            .WhereIn(sessionIds, x => x.Id)
            .ToListAsync(cancellationToken);
        if (sessions.Count != sessionIds.Length)
            return ConflictProblem(
                "所选考试场次包含不存在或不属于当前计划的记录，请刷新后重新选择。");

        await RemoveRoomsForSessionsAsync(sessionIds, cancellationToken);
        db.ExamSessions.RemoveRange(sessions);
        try
        {
            await db.SaveChangesAsync(cancellationToken);
            await cache.RemoveByTagAsync(
                AppCacheTags.Timetables,
                cancellationToken);
            return Ok(new { removedCount = sessions.Count });
        }
        catch (DbUpdateException)
        {
            return ConflictProblem("批量移除考试场次失败，关联数据可能已发生变化。");
        }
    }

    // ═══════════════════════════════════════════
    //  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.ExamPlans.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 != ExamPlanStatus.Draft)
            return ConflictProblem("只有草稿状态的考试计划可以自动编排。");
        if (sessionIds.Length > 0)
        {
            var selectedSessionCount = await db.ExamSessions.AsNoTracking()
                .Where(x => x.ExamPlanId == planId)
                .WhereIn(sessionIds, x => x.Id)
                .CountAsync(cancellationToken);
            if (selectedSessionCount != sessionIds.Length)
                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.FormalExam,
            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.FormalExam,
                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.FormalExam)
            .OrderByDescending(x => x.CreatedAt)
            .FirstOrDefaultAsync(cancellationToken);
        return job is null ? NoContent() : Ok(ToArrangementJobResponse(job));
    }

    // ═══════════════════════════════════════════
    //  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);

        // Exclude classrooms already occupied
        var occupiedQuery = db.ExamSessions.AsNoTracking()
            .Where(x => x.ClassroomId != null &&
                        x.StartsAt < endsAt && startsAt < x.EndsAt);
        if (planId.HasValue)
            occupiedQuery = occupiedQuery.Where(x => x.ExamPlanId == planId.Value);
        if (excludeSessionId.HasValue)
            occupiedQuery = occupiedQuery.Where(x => x.Id != excludeSessionId.Value);

        var occupiedIds = await occupiedQuery.Select(x => x.ClassroomId!.Value)
            .ToListAsync(cancellationToken);
        var occupiedRoomQuery = db.ExamRooms.AsNoTracking()
            .Where(x => x.StartsAt < endsAt && startsAt < x.EndsAt);
        if (planId.HasValue)
            occupiedRoomQuery = occupiedRoomQuery.Where(
                x => x.ExamPlanId == planId.Value);
        if (excludeSessionId.HasValue)
            occupiedRoomQuery = occupiedRoomQuery.Where(
                x => !x.SessionLinks.Any(link =>
                    link.ExamSessionId == excludeSessionId.Value));
        occupiedIds.AddRange(await occupiedRoomQuery
            .Select(x => x.ClassroomId)
            .ToListAsync(cancellationToken));
        occupiedIds = occupiedIds.Distinct().ToList();

        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.ExamSessionInvigilators.AsNoTracking()
            .Where(x => x.ExamSession!.StartsAt < endsAt &&
                        startsAt < x.ExamSession!.EndsAt);
        if (planId.HasValue)
            busyQuery = busyQuery.Where(x => x.ExamSession!.ExamPlanId == planId.Value);
        if (excludeSessionId.HasValue)
            busyQuery = busyQuery.Where(x => x.ExamSessionId != excludeSessionId.Value);

        var busyIds = await busyQuery.Select(x => x.TeacherId)
            .ToListAsync(cancellationToken);
        var roomBusyQuery = db.ExamRoomInvigilators.AsNoTracking()
            .Where(x => x.ExamRoom!.StartsAt < endsAt &&
                        startsAt < x.ExamRoom.EndsAt);
        if (planId.HasValue)
            roomBusyQuery = roomBusyQuery.Where(
                x => x.ExamRoom!.ExamPlanId == planId.Value);
        if (excludeSessionId.HasValue)
            roomBusyQuery = roomBusyQuery.Where(
                x => !x.ExamRoom!.SessionLinks.Any(link =>
                    link.ExamSessionId == excludeSessionId.Value));
        busyIds.AddRange(await roomBusyQuery
            .Select(x => x.TeacherId)
            .ToListAsync(cancellationToken));
        busyIds = busyIds.Distinct().ToList();

        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));

    // ═══════════════════════════════════════════
    //  Publish
    // ═══════════════════════════════════════════

    [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.ExamPlans.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 != ExamPlanStatus.Draft)
            return ConflictProblem("只有草稿考试计划可以发布。");
        if (!plan.HasSessions)
            return ConflictProblem("至少安排一个考试场次后才能发布。");

        var userId = currentUserDataScope.Current.UserId;
        var job = new ExamPublishJob
        {
            Kind = ExamPublishJobKind.FormalExam,
            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.FormalExam,
                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.FormalExam)
            .OrderByDescending(x => x.CreatedAt)
            .FirstOrDefaultAsync(cancellationToken);
        return job is null ? NoContent() : Ok(ToPublishJobResponse(job));
    }

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

    [HttpGet("sessions/{id:guid}/roster")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken)
    {
        var session = await db.ExamSessions.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 assignedStudents = await db.ExamSeats.AsNoTracking()
            .Where(x => x.ExamSessionId == id)
            .OrderBy(x => x.ExamRoom!.Classroom!.Building!.Name)
            .ThenBy(x => x.ExamRoom!.Classroom!.Name)
            .ThenBy(x => x.SeatNumber)
            .Select(x => new
            {
                x.StudentId,
                x.Student!.StudentNumber,
                x.Student.Name,
                ClassName = x.Student.AdministrativeClass!.Name,
                x.ExamRoomId,
                ClassroomName = x.ExamRoom!.Classroom!.Name,
                BuildingName = x.ExamRoom.Classroom.Building!.Name,
                x.SeatNumber
            })
            .ToListAsync(cancellationToken);
        if (assignedStudents.Count > 0)
        {
            return Ok(new
            {
                session.Id,
                session.TeachingTaskId,
                session.TaskNumber,
                session.CourseName,
                ClassroomName = string.Join(
                    "、",
                    assignedStudents
                        .Select(x => $"{x.BuildingName} · {x.ClassroomName}")
                        .Distinct()),
                session.StartsAt,
                Students = assignedStudents.Select(student => new
                {
                    student.StudentId,
                    student.StudentNumber,
                    student.Name,
                    student.ClassName,
                    student.ExamRoomId,
                    student.ClassroomName,
                    student.BuildingName,
                    SeatNumber = student.SeatNumber.ToString("D3")
                })
            });
        }

        var students = await TeachingTaskRosterQuery.LoadForTasksAsync(
            db,
            [session.TeachingTaskId],
            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,
                SeatNumber = (index + 1).ToString("D3")
            })
        });
    }

    [HttpGet("plans/{id:guid}/sign-in-sheets.xlsx")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> ExportSignInSheets(
        Guid id,
        CancellationToken cancellationToken)
    {
        var plan = await db.ExamPlans.AsNoTracking()
            .Where(x => x.Id == id)
            .Select(x => new
            {
                x.Name,
                TermName = x.AcademicTerm!.Name
            })
            .FirstOrDefaultAsync(cancellationToken);
        if (plan is null) return NotFound();

        var rooms = await db.ExamRooms.AsNoTracking()
            .Include(x => x.Course)
            .Include(x => x.Classroom)
                .ThenInclude(x => x!.Building)
            .Include(x => x.Invigilators)
                .ThenInclude(x => x.Teacher)
            .Include(x => x.SessionLinks)
                .ThenInclude(x => x.ExamSession)
                    .ThenInclude(x => x!.TeachingTask)
            .Include(x => x.Seats)
                .ThenInclude(x => x.Student)
                    .ThenInclude(x => x!.AdministrativeClass)
            .Where(x => x.ExamPlanId == id)
            .OrderBy(x => x.ExamDate)
            .ThenBy(x => x.StartPeriod)
            .ThenBy(x => x.Classroom!.Building!.Name)
            .ThenBy(x => x.Classroom!.Name)
            .ToListAsync(cancellationToken);
        var legacySessions = await db.ExamSessions.AsNoTracking()
            .Where(x => x.ExamPlanId == id)
            .Where(x => !x.RoomLinks.Any())
            .OrderBy(x => x.ExamDate)
            .ThenBy(x => x.StartPeriod)
            .Select(session => new
            {
                session.Id,
                session.TeachingTaskId,
                session.ExamDate,
                session.StartsAt,
                session.EndsAt,
                CourseCode = session.TeachingTask!.Course!.Code,
                CourseName = session.TeachingTask.Course.Name,
                session.TeachingTask.TaskNumber,
                BuildingName = session.Classroom != null
                    ? session.Classroom.Building!.Name
                    : null,
                ClassroomName = session.Classroom != null
                    ? session.Classroom.Name
                    : null,
                InvigilatorNames = session.Invigilators
                    .OrderBy(item => item.Teacher!.TeacherNumber)
                    .Select(item => item.Teacher!.Name)
            })
            .ToListAsync(cancellationToken);
        if (rooms.Count == 0 && legacySessions.Count == 0)
            return ConflictProblem("当前考试计划没有可导出的考试场次。");

        var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
            db,
            legacySessions.Select(x => x.TeachingTaskId),
            cancellationToken);
        var studentsByTask = roster.ToLookup(x => x.TeachingTaskId);
        var sheets = rooms.Select(room => new ExamSignInSessionData(
                room.Id,
                room.ExamDate,
                room.StartsAt,
                room.EndsAt,
                room.Course!.Code,
                room.Course.Name,
                string.Join(
                    "、",
                    room.SessionLinks
                        .Select(link => link.ExamSession!.TeachingTask!.TaskNumber)
                        .Distinct()
                        .OrderBy(x => x)),
                room.Classroom!.Building!.Name,
                room.Classroom.Name,
                room.Invigilators
                    .OrderBy(item => item.Teacher!.TeacherNumber)
                    .Select(item => item.Teacher!.Name)
                    .ToList(),
                room.Seats
                    .OrderBy(seat => seat.SeatNumber)
                    .Select(seat => new ExamSignInStudentData(
                        seat.StudentId,
                        seat.Student!.StudentNumber,
                        seat.Student.Name,
                        seat.Student.AdministrativeClass!.Name,
                        seat.SeatNumber))
                    .ToList()))
            .Concat(legacySessions.Select(session => new ExamSignInSessionData(
                session.Id,
                session.ExamDate,
                session.StartsAt,
                session.EndsAt,
                session.CourseCode,
                session.CourseName,
                session.TaskNumber,
                session.BuildingName,
                session.ClassroomName,
                session.InvigilatorNames.ToList(),
                studentsByTask[session.TeachingTaskId]
                    .Select((student, index) => new ExamSignInStudentData(
                        student.StudentId,
                        student.StudentNumber,
                        student.Name,
                        student.ClassName,
                        index + 1))
                    .ToList())))
            .ToList();
        var data = new ExamSignInWorkbookData(
            plan.Name,
            plan.TermName,
            sheets);

        var bytes = ExamSignInWorkbookExporter.Create(data);
        return File(
            bytes,
            ExcelWorkbookHelper.ContentType,
            $"考场签名单-{FileNamePart(plan.Name)}.xlsx");
    }

    // ═══════════════════════════════════════════
    //  Sign-in sheet export (background job)
    // ═══════════════════════════════════════════

    [HttpPost("plans/{id:guid}/sign-in-export")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> StartSignInExport(
        Guid id,
        CancellationToken cancellationToken)
    {
        var planExists = await db.ExamPlans.AsNoTracking()
            .AnyAsync(x => x.Id == id, cancellationToken);
        if (!planExists) return NotFound();

        var userId = currentUserDataScope.Current.UserId;
        var job = new ExamSignInExportJob
        {
            PlanId = id,
            RequestedByUserId = userId == Guid.Empty ? null : userId,
            CurrentStep = "等待后台生成"
        };
        db.ExamSignInExportJobs.Add(job);
        db.BackgroundJobOutboxMessages.Add(
            BackgroundJobOutboxMessage.Create(
                BackgroundJobKind.ExamSignInExport,
                job.Id));
        await db.SaveChangesAsync(cancellationToken);

        return AcceptedAtAction(
            nameof(GetSignInExportJob),
            new { jobId = job.Id },
            new
            {
                jobId = job.Id,
                status = job.Status.ToString(),
                message = "考场签名单导出任务已提交。"
            });
    }

    [HttpGet("sign-in-exports/{jobId:guid}")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetSignInExportJob(
        Guid jobId,
        CancellationToken cancellationToken)
    {
        var job = await db.ExamSignInExportJobs.AsNoTracking()
            .FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
        if (job is null) return NotFound();
        return Ok(new
        {
            job.Id,
            job.PlanId,
            Status = job.Status.ToString(),
            job.FileName,
            job.FileSize,
            job.CurrentStep,
            job.ErrorMessage,
            job.CreatedAt,
            job.StartedAt,
            job.CompletedAt
        });
    }

    [HttpGet("sign-in-exports/{jobId:guid}/download")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> DownloadSignInExport(
        Guid jobId,
        CancellationToken cancellationToken)
    {
        var job = await db.ExamSignInExportJobs.AsNoTracking()
            .FirstOrDefaultAsync(
                x => x.Id == jobId &&
                     x.Status == ExamSignInExportJobStatus.Succeeded,
                cancellationToken);
        if (job is null || job.FileBytes is null || job.FileBytes.Length == 0)
            return NotFound();

        return File(
            job.FileBytes,
            ExcelWorkbookHelper.ContentType,
            job.FileName ?? "考场签名单.xlsx");
    }

    // ═══════════════════════════════════════════
    //  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 assignedSchedule = await db.ExamSeats.AsNoTracking()
                .Where(x =>
                    x.StudentId == studentId.Value &&
                    x.ExamRoom!.ExamPlan!.Status == ExamPlanStatus.Published)
                .Select(x => new
                {
                    x.ExamSessionId,
                    PlanName = x.ExamRoom!.ExamPlan!.Name,
                    x.ExamRoom.ExamDate,
                    x.ExamRoom.StartPeriod,
                    x.ExamRoom.PeriodCount,
                    x.ExamRoom.StartsAt,
                    x.ExamRoom.EndsAt,
                    x.ExamSession!.TeachingTask!.TaskNumber,
                    CourseCode = x.ExamRoom.Course!.Code,
                    CourseName = x.ExamRoom.Course.Name,
                    ClassroomName = x.ExamRoom.Classroom!.Name,
                    BuildingName = x.ExamRoom.Classroom.Building!.Name,
                    InvigilatorNames = x.ExamRoom.Invigilators
                        .Select(i => i.Teacher!.Name),
                    x.SeatNumber
                })
                .ToListAsync(cancellationToken);

            var rosterTaskIds = TeachingTaskRosterQuery.TaskIdsForStudent(
                db,
                studentId.Value);
            var legacySchedule = await db.ExamSessions.AsNoTracking()
                .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
                    !x.RoomLinks.Any() &&
                    rosterTaskIds.Contains(x.TeachingTaskId))
                .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
                .Select(x => new
                {
                    x.Id,
                    PlanName = x.ExamPlan!.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),
                    x.TeachingTaskId,
                    IsExam = true
                })
                .ToListAsync(cancellationToken);
            var taskIds = legacySchedule
                .Select(x => x.TeachingTaskId)
                .Distinct()
                .ToArray();
            var seatRows = await TeachingTaskRosterQuery.LoadForTasksAsync(
                db,
                taskIds,
                cancellationToken);
            var seatNumbers = seatRows
                .GroupBy(x => x.TeachingTaskId)
                .SelectMany(group => group.Select((row, index) => new
                {
                    row.TeachingTaskId,
                    row.StudentId,
                    SeatNumber = (index + 1).ToString("D3")
                }))
                .ToDictionary(x => (x.TeachingTaskId, x.StudentId), x => x.SeatNumber);

            return Ok(assignedSchedule.Select(x => new ExamScheduleResponse(
                    x.ExamSessionId,
                    x.PlanName,
                    x.ExamDate,
                    x.StartPeriod,
                    x.PeriodCount,
                    x.StartsAt,
                    x.EndsAt,
                    x.TaskNumber,
                    x.CourseCode,
                    x.CourseName,
                    x.ClassroomName,
                    x.BuildingName,
                    x.InvigilatorNames.ToList(),
                    x.SeatNumber.ToString("D3"),
                    null,
                    true))
                .Concat(legacySchedule.Select(x => new ExamScheduleResponse(
                    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.InvigilatorNames.ToList(),
                    seatNumbers.GetValueOrDefault(
                        (x.TeachingTaskId, studentId.Value)),
                    null,
                    x.IsExam)))
                .OrderBy(x => x.ExamDate)
                .ThenBy(x => x.StartPeriod));
        }
        if (scope.IsInRole(SystemRoles.Teacher))
        {
            var roomSchedule = await db.ExamRooms.AsNoTracking()
                .Where(x =>
                    x.ExamPlan!.Status == ExamPlanStatus.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.ExamPlan!.Name,
                    x.ExamDate,
                    x.StartPeriod,
                    x.PeriodCount,
                    x.StartsAt,
                    x.EndsAt,
                    TaskNumbers = x.SessionLinks
                        .Select(link =>
                            link.ExamSession!.TeachingTask!.TaskNumber),
                    CourseCode = x.Course!.Code,
                    CourseName = x.Course.Name,
                    ClassroomName = x.Classroom!.Name,
                    BuildingName = x.Classroom.Building!.Name,
                    InvigilatorNames = x.Invigilators
                        .Select(i => i.Teacher!.Name),
                    StudentCount = x.Seats.Count
                })
                .ToListAsync(cancellationToken);

            var legacySchedule = await db.ExamSessions.AsNoTracking()
                .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
                    !x.RoomLinks.Any() &&
                    x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId))
                .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
                .Select(x => new
                {
                    x.Id,
                    PlanName = x.ExamPlan!.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),
                    x.TeachingTaskId,
                    IsExam = true
                })
                .ToListAsync(cancellationToken);
            var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
                    db,
                    legacySchedule.Select(x => x.TeachingTaskId),
                    cancellationToken))
                .GroupBy(x => x.TeachingTaskId)
                .ToDictionary(x => x.Key, x => x.Count());
            return Ok(roomSchedule.Select(x => new ExamScheduleResponse(
                    x.Id,
                    x.PlanName,
                    x.ExamDate,
                    x.StartPeriod,
                    x.PeriodCount,
                    x.StartsAt,
                    x.EndsAt,
                    string.Join("、", x.TaskNumbers.OrderBy(value => value)),
                    x.CourseCode,
                    x.CourseName,
                    x.ClassroomName,
                    x.BuildingName,
                    x.InvigilatorNames.ToList(),
                    null,
                    x.StudentCount,
                    true))
                .Concat(legacySchedule.Select(x => new ExamScheduleResponse(
                    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.InvigilatorNames.ToList(),
                    null,
                    rosterCounts.GetValueOrDefault(x.TeachingTaskId),
                    x.IsExam)))
                .OrderBy(x => x.ExamDate)
                .ThenBy(x => x.StartPeriod));
        }
        return Ok(Array.Empty<object>());
    }

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

    private async Task RemoveRoomsForSessionsAsync(
        IReadOnlyCollection<Guid> sessionIds,
        CancellationToken cancellationToken)
    {
        var ids = sessionIds.Distinct().ToArray();
        if (ids.Length == 0) return;
        var roomIds = await db.ExamRoomSessions.AsNoTracking()
            .WhereIn(ids, x => x.ExamSessionId)
            .Select(x => x.ExamRoomId)
            .Distinct()
            .ToListAsync(cancellationToken);
        if (roomIds.Count == 0) return;
        var rooms = await db.ExamRooms
            .WhereIn(roomIds, x => x.Id)
            .ToListAsync(cancellationToken);
        db.ExamRooms.RemoveRange(rooms);
    }

    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(
        ExamPlan 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("教学班与考试计划必须属于同一学期。");

        var studentCount = await TeachingTaskRosterQuery
            .ForTask(db, task.Id)
            .CountAsync(cancellationToken);

        if (classroomId.HasValue)
        {
            var room = await db.Classrooms.AsNoTracking()
                .FirstOrDefaultAsync(
                    x => x.Id == classroomId.Value && x.IsEnabled, cancellationToken);
            if (room is null) return ValidationProblem("所选考场不存在或已停用。");
            if (studentCount > room.Capacity)
                return ConflictProblem(
                    $"考场容量不足：需容纳 {studentCount} 人，教室容量为 {room.Capacity}。");
        }

        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.ExamSessions.Where(x => x.ExamPlanId == 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 (await db.ExamRooms.AsNoTracking().AnyAsync(
                    room =>
                        room.ClassroomId == classroomId.Value &&
                        room.StartsAt < endsAt &&
                        startsAt < room.EndsAt &&
                        (!currentId.HasValue ||
                         !room.SessionLinks.Any(link =>
                             link.ExamSessionId == currentId.Value)),
                    cancellationToken))
                return ConflictProblem("该时段考场已被混排考试占用。");
        }

        if (teacherIds.Length > 0)
        {
            if (await db.ExamSessionInvigilators
                    .Where(i => overlaps.Any(x => x.Id == i.ExamSessionId))
                    .WhereIn(teacherIds, i => i.TeacherId)
                    .AnyAsync(cancellationToken))
                return ConflictProblem("监考教师在该时段已有考试任务。");
            if (await db.ExamRoomInvigilators.AsNoTracking()
                    .Where(i =>
                        i.ExamRoom!.StartsAt < endsAt &&
                        startsAt < i.ExamRoom.EndsAt &&
                        (!currentId.HasValue ||
                         !i.ExamRoom.SessionLinks.Any(link =>
                             link.ExamSessionId == currentId.Value)))
                    .WhereIn(teacherIds, i => i.TeacherId)
                    .AnyAsync(cancellationToken))
                return ConflictProblem("监考教师在该时段已有混排考场任务。");
        }

        var overlappingTaskIds = await overlaps
            .Select(x => x.TeachingTaskId)
            .Distinct()
            .ToListAsync(cancellationToken);
        if (overlappingTaskIds.Count > 0)
        {
            var rosterRows = await TeachingTaskRosterQuery.LoadForTasksAsync(
                db,
                overlappingTaskIds.Append(task.Id),
                cancellationToken);
            var currentStudentIds = rosterRows
                .Where(x => x.TeachingTaskId == task.Id)
                .Select(x => x.StudentId)
                .ToHashSet();
            if (rosterRows.Any(x =>
                    x.TeachingTaskId != task.Id &&
                    currentStudentIds.Contains(x.StudentId)))
                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.FormalExam &&
                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.FormalExam &&
                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);
            await cache.RemoveByTagAsync(AppCacheTags.Timetables, 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;
    private static string FileNamePart(string value)
    {
        var invalidCharacters = Path.GetInvalidFileNameChars().ToHashSet();
        var normalized = new string(value
            .Select(character =>
                invalidCharacters.Contains(character) ? '-' : character)
            .ToArray()).Trim();
        if (normalized.Length == 0) return "考试计划";
        return normalized.Length <= 80 ? normalized : normalized[..80];
    }
}

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

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

public sealed record CreateExamSessionRequest(
    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 CreateExamSessionsBatchRequest(
    [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 RemoveExamSessionsBatchRequest(
    [Required] IReadOnlyCollection<Guid> SessionIds);

public sealed record ExamAutoArrangeRequest(
    IReadOnlyCollection<Guid>? SessionIds = null,
    bool AssignClassrooms = true,
    bool AssignInvigilators = true);

public sealed record ExamScheduleResponse(
    Guid Id,
    string PlanName,
    DateOnly ExamDate,
    int StartPeriod,
    int PeriodCount,
    DateTime StartsAt,
    DateTime EndsAt,
    string TaskNumber,
    string CourseCode,
    string CourseName,
    string? ClassroomName,
    string? BuildingName,
    IReadOnlyCollection<string> InvigilatorNames,
    string? SeatNumber,
    int? StudentCount,
    bool IsExam);
