using System.ComponentModel.DataAnnotations;
using System.Data;
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Experiments;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Jiaowu.Api.Infrastructure.Timetables;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace Jiaowu.Api.Controllers;

[ApiController]
[Authorize]
[Route("api/experiments")]
public sealed class ExperimentsController(
    AppDbContext db,
    ICurrentUserDataScope currentUserDataScope,
    ClassroomReservationAvailabilityService classroomAvailability) : ControllerBase
{
    private const string Managers =
        SystemRoles.SuperAdmin + "," +
        SystemRoles.AcademicAdmin + "," +
        SystemRoles.CollegeAdmin + "," +
        SystemRoles.Teacher;

    [HttpGet("options")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetOptions(
        Guid? academicTermId,
        Guid? offeringCollegeId,
        string? courseKeyword,
        CancellationToken cancellationToken)
    {
        var tasks = AccessibleTeachingTasks().AsNoTracking()
            .Where(x => x.Status == TeachingTaskStatus.Published);
        if (academicTermId.HasValue)
            tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
        if (offeringCollegeId.HasValue)
            tasks = tasks.Where(x => x.Course!.CollegeId == offeringCollegeId);
        var normalizedCourseKeyword = Normalize(courseKeyword);
        if (normalizedCourseKeyword is not null)
            tasks = tasks.Where(x =>
                x.Course!.Code.Contains(normalizedCourseKeyword) ||
                x.Course.Name.Contains(normalizedCourseKeyword));

        var periods = db.ScheduleTimeSlots.AsNoTracking()
            .Where(x => x.IsEnabled);
        if (academicTermId.HasValue)
            periods = periods.Where(x => x.AcademicTermId == academicTermId);
        var periodItems = await periods
            .OrderBy(x => x.PeriodNumber)
            .Select(x => new ExperimentPeriodOption(
                x.AcademicTermId,
                x.PeriodNumber,
                x.Name,
                x.StartsAt.ToString("HH:mm"),
                x.EndsAt.ToString("HH:mm")))
            .ToListAsync(cancellationToken);
        if (academicTermId.HasValue && periodItems.Count == 0)
        {
            periodItems = Enumerable.Range(1, 12)
                .Select(period => new ExperimentPeriodOption(
                    academicTermId.Value,
                    period,
                    $"第 {period} 节",
                    "",
                    ""))
                .ToList();
        }

        return Ok(new
        {
            Tasks = await tasks
                .OrderByDescending(x => x.AcademicTerm!.StartDate)
                .ThenBy(x => x.Course!.Code)
                .ThenBy(x => x.TaskNumber)
                .Select(x => new
                {
                    x.Id,
                    x.TaskNumber,
                    x.Name,
                    x.AcademicTermId,
                    x.CourseId,
                    x.SchedulingMode,
                    TermName = x.AcademicTerm!.Name,
                    TermStartDate = x.AcademicTerm.StartDate,
                    TermEndDate = x.AcademicTerm.EndDate,
                    CourseCode = x.Course!.Code,
                    CourseName = x.Course.Name,
                    CollegeName = x.Course.College!.Name,
                    TeacherNames = x.Teachers
                        .OrderByDescending(item => item.IsPrimary)
                        .ThenBy(item => item.Teacher!.TeacherNumber)
                        .Select(item => item.Teacher!.Name),
                    ClassNames = x.Classes
                        .OrderBy(item => item.AdministrativeClass!.Code)
                        .Select(item => item.AdministrativeClass!.Name)
                })
                .Take(200)
                .ToListAsync(cancellationToken),
            Colleges = await AccessibleTeachingTasks().AsNoTracking()
                .Where(x => !academicTermId.HasValue || x.AcademicTermId == academicTermId.Value)
                .Select(x => new { x.Course!.CollegeId, CollegeName = x.Course.College!.Name })
                .Distinct()
                .OrderBy(x => x.CollegeName)
                .ToListAsync(cancellationToken),
            ScheduleEntries = await db.ScheduleEntries.AsNoTracking()
                .Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
                            x.Kind == ScheduleEntryKind.Experiment &&
                            x.ClassroomId.HasValue &&
                            AccessibleTeachingTasks().Select(task => task.Id)
                                .Contains(x.TeachingTaskId))
                .Where(x => !academicTermId.HasValue ||
                            x.SchedulePlan!.AcademicTermId == academicTermId.Value)
                .Where(x => !offeringCollegeId.HasValue ||
                            x.TeachingTask!.Course!.CollegeId == offeringCollegeId.Value)
                .Where(x => normalizedCourseKeyword == null ||
                            x.TeachingTask!.Course!.Code.Contains(normalizedCourseKeyword) ||
                            x.TeachingTask.Course.Name.Contains(normalizedCourseKeyword))
                .OrderBy(x => x.TeachingTask!.Course!.Code)
                .ThenBy(x => x.TeachingTask!.TaskNumber)
                .ThenBy(x => x.DayOfWeek)
                .ThenBy(x => x.StartPeriod)
                .Select(x => new
                {
                    x.Id,
                    x.TeachingTaskId,
                    TaskNumber = x.TeachingTask!.TaskNumber,
                    CourseCode = x.TeachingTask.Course!.Code,
                    CourseName = x.TeachingTask.Course.Name,
                    x.DayOfWeek,
                    x.StartPeriod,
                    x.PeriodCount,
                    x.StartWeek,
                    x.EndWeek,
                    x.WeekPattern,
                    ClassNames = x.TeachingTask!.Classes
                        .OrderBy(item => item.AdministrativeClass!.Code)
                        .Select(item => item.AdministrativeClass!.Name),
                    ClassroomName = x.Classroom!.Name,
                    BuildingName = x.Classroom.Building!.Name,
                    CampusName = x.Classroom.Building.Campus!.Name
                })
                .Take(500)
                .ToListAsync(cancellationToken),
            Classrooms = await db.Classrooms.AsNoTracking()
                .Where(x => x.IsEnabled)
                .OrderBy(x => x.Building!.Campus!.SortOrder)
                .ThenBy(x => x.Building!.SortOrder)
                .ThenBy(x => x.SortOrder)
                .Select(x => new
                {
                    x.Id,
                    x.Name,
                    BuildingName = x.Building!.Name,
                    CampusName = x.Building.Campus!.Name,
                    x.Capacity
                    ,x.TeachingVenueNature
                })
                .ToListAsync(cancellationToken),
            Teachers = await AccessibleTeachingTasks().AsNoTracking()
                .SelectMany(x => x.Teachers)
                .Where(x => x.Teacher != null && x.Teacher.Status == TeacherStatus.Active)
                .Select(x => new
                {
                    x.TeachingTaskId,
                    x.TeacherId,
                    TeacherNumber = x.Teacher!.TeacherNumber,
                    TeacherName = x.Teacher.Name
                })
                .Distinct()
                .OrderBy(x => x.TeacherNumber)
                .ToListAsync(cancellationToken),
            Periods = periodItems
        });
    }

    [HttpGet("management")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetManagementProjects(
        Guid? academicTermId,
        ExperimentArrangementMode? arrangementMode,
        ExperimentProjectStatus? status,
        Guid? offeringCollegeId,
        string? courseKeyword,
        string? classKeyword,
        string? teacherKeyword,
        string? taskKeyword,
        CancellationToken cancellationToken,
        int page = 1,
        int pageSize = 20)
    {
        var source = ScopedProjects().AsNoTracking();
        var scope = currentUserDataScope.Current;
        if (scope.Scope == DataScope.Self && scope.IsInRole(SystemRoles.Teacher))
            source = source.Where(x => x.Sessions.Any(session =>
                session.Instructors.Any(instructor =>
                    instructor.Teacher!.UserId == scope.UserId)));
        if (academicTermId.HasValue)
            source = source.Where(x =>
                x.TeachingTask!.AcademicTermId == academicTermId);
        if (arrangementMode.HasValue)
            source = source.Where(x =>
                x.ArrangementMode == arrangementMode);
        if (status.HasValue)
            source = source.Where(x => x.Status == status);

        var normalizedCourseKeyword = Normalize(courseKeyword);
        var normalizedClassKeyword = Normalize(classKeyword);
        var normalizedTeacherKeyword = Normalize(teacherKeyword);
        var normalizedTaskKeyword = Normalize(taskKeyword);
        var taskSource = AccessibleTeachingTasks().AsNoTracking()
            .Where(task => source.Select(project => project.TeachingTaskId).Contains(task.Id));
        if (academicTermId.HasValue)
            taskSource = taskSource.Where(x => x.AcademicTermId == academicTermId);
        if (offeringCollegeId.HasValue)
            taskSource = taskSource.Where(x => x.Course!.CollegeId == offeringCollegeId);
        if (normalizedCourseKeyword is not null)
            taskSource = taskSource.Where(x =>
                x.Course!.Code.Contains(normalizedCourseKeyword) ||
                x.Course.Name.Contains(normalizedCourseKeyword));
        if (normalizedClassKeyword is not null)
            taskSource = taskSource.Where(x => x.Classes.Any(item =>
                item.AdministrativeClass!.Code.Contains(normalizedClassKeyword) ||
                item.AdministrativeClass.Name.Contains(normalizedClassKeyword)));
        if (normalizedTeacherKeyword is not null)
            taskSource = taskSource.Where(x => x.Teachers.Any(item =>
                item.Teacher!.TeacherNumber.Contains(normalizedTeacherKeyword) ||
                item.Teacher.Name.Contains(normalizedTeacherKeyword)));
        if (normalizedTaskKeyword is not null)
            taskSource = taskSource.Where(x =>
                x.TaskNumber.Contains(normalizedTaskKeyword) ||
                x.Name.Contains(normalizedTaskKeyword));

        page = Math.Max(1, page);
        pageSize = Math.Clamp(pageSize, 10, 100);
        var total = await taskSource.CountAsync(cancellationToken);
        var taskIds = await taskSource
            .OrderByDescending(x => x.AcademicTerm!.StartDate)
            .ThenBy(x => x.Course!.Code)
            .ThenBy(x => x.TaskNumber)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Select(x => x.Id)
            .ToListAsync(cancellationToken);

        var items = await source
            .Where(x => taskIds.Contains(x.TeachingTaskId))
            .OrderByDescending(x => x.Status == ExperimentProjectStatus.Published)
            .ThenBy(x => x.StartDate)
            .ThenBy(x => x.Code)
            .Select(x => new
            {
                x.Id,
                x.TeachingTaskId,
                x.ScheduleEntryId,
                x.ScheduleWeek,
                x.Code,
                x.Name,
                x.ArrangementMode,
                x.Description,
                x.Requirements,
                x.StartDate,
                x.EndDate,
                x.SelectionStartsAt,
                x.SelectionEndsAt,
                x.Status,
                x.PublishedAt,
                x.ClosedAt,
                AcademicTermId = x.TeachingTask!.AcademicTermId,
                TermName = x.TeachingTask.AcademicTerm!.Name,
                TaskNumber = x.TeachingTask.TaskNumber,
                CourseCode = x.TeachingTask.Course!.Code,
                CourseName = x.TeachingTask.Course.Name,
                CollegeName = x.TeachingTask.Course.College!.Name,
                TeacherNames = x.TeachingTask.Teachers
                    .OrderByDescending(item => item.IsPrimary)
                    .Select(item => item.Teacher!.Name),
                ClassNames = x.TeachingTask.Classes
                    .OrderBy(item => item.AdministrativeClass!.Code)
                    .Select(item => item.AdministrativeClass!.Name),
                ScheduleEntry = x.ScheduleEntryId == null ? null : new
                {
                    x.ScheduleEntry!.DayOfWeek,
                    x.ScheduleEntry.StartPeriod,
                    x.ScheduleEntry.PeriodCount,
                    x.ScheduleEntry.StartWeek,
                    x.ScheduleEntry.EndWeek,
                    x.ScheduleEntry.WeekPattern,
                    ProjectWeek = x.ScheduleWeek,
                    ClassroomName = x.ScheduleEntry.Classroom!.Name,
                    BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
                    CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
                },
                Sessions = x.Sessions
                    .OrderBy(item => item.SessionDate)
                    .ThenBy(item => item.StartPeriod)
                    .Select(item => new
                    {
                        item.Id,
                        item.SessionDate,
                        item.StartPeriod,
                        item.PeriodCount,
                        item.Capacity,
                        item.ReservedCount,
                        item.Notes,
                        item.Status,
                        item.ClassroomId,
                        ClassroomName = item.Classroom!.Name,
                        BuildingName = item.Classroom.Building!.Name,
                        CampusName = item.Classroom.Building.Campus!.Name
                        ,InstructorNames = item.Instructors
                            .OrderBy(instructor => instructor.Teacher!.TeacherNumber)
                            .Select(instructor => instructor.Teacher!.Name)
                        ,InstructorTeacherIds = item.Instructors
                            .Select(instructor => instructor.TeacherId)
                        ,CanManage = scope.Scope != DataScope.Self ||
                            item.Instructors.Any(instructor =>
                                instructor.Teacher!.UserId == scope.UserId)
                    })
            })
            .ToListAsync(cancellationToken);

        return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
    }

    [HttpGet("student")]
    [Authorize(Roles = SystemRoles.Student)]
    public async Task<ActionResult> GetStudentProjects(
        Guid? academicTermId,
        CancellationToken cancellationToken)
    {
        var student = await CurrentStudentAsync(cancellationToken);
        if (student is null)
            return ConflictProblem("当前账号未关联有效学生档案。");

        var taskIds = TeachingTaskRosterQuery.TaskIdsForStudent(db, student.Id);
        var source = db.ExperimentProjects.AsNoTracking()
            .Where(x =>
                taskIds.Contains(x.TeachingTaskId) &&
                (x.Status == ExperimentProjectStatus.Published ||
                 x.Status == ExperimentProjectStatus.Closed));
        if (academicTermId.HasValue)
            source = source.Where(x =>
                x.TeachingTask!.AcademicTermId == academicTermId);

        return Ok(await source
            .OrderByDescending(x => x.Status == ExperimentProjectStatus.Published)
            .ThenBy(x => x.EndDate)
            .ThenBy(x => x.Code)
            .Select(x => new
            {
                x.Id,
                x.Code,
                x.Name,
                x.ArrangementMode,
                x.ScheduleEntryId,
                x.ScheduleWeek,
                x.Description,
                x.Requirements,
                x.StartDate,
                x.EndDate,
                x.SelectionStartsAt,
                x.SelectionEndsAt,
                x.Status,
                AcademicTermId = x.TeachingTask!.AcademicTermId,
                TermName = x.TeachingTask.AcademicTerm!.Name,
                TaskNumber = x.TeachingTask.TaskNumber,
                CourseCode = x.TeachingTask.Course!.Code,
                CourseName = x.TeachingTask.Course.Name,
                TeacherNames = x.TeachingTask.Teachers
                    .OrderByDescending(item => item.IsPrimary)
                    .Select(item => item.Teacher!.Name),
                ScheduleEntry = x.ScheduleEntryId == null ? null : new
                {
                    x.ScheduleEntry!.DayOfWeek,
                    x.ScheduleEntry.StartPeriod,
                    x.ScheduleEntry.PeriodCount,
                    x.ScheduleEntry.StartWeek,
                    x.ScheduleEntry.EndWeek,
                    x.ScheduleEntry.WeekPattern,
                    ProjectWeek = x.ScheduleWeek,
                    ClassroomName = x.ScheduleEntry.Classroom!.Name,
                    BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
                    CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
                },
                Sessions = x.Sessions
                    .Where(item => item.Status == ExperimentSessionStatus.Scheduled)
                    .OrderBy(item => item.SessionDate)
                    .ThenBy(item => item.StartPeriod)
                    .Select(item => new
                    {
                        item.Id,
                        item.SessionDate,
                        item.StartPeriod,
                        item.PeriodCount,
                        item.Capacity,
                        item.ReservedCount,
                        RemainingCount = item.Capacity - item.ReservedCount,
                        item.Notes,
                        ClassroomName = item.Classroom!.Name,
                        BuildingName = item.Classroom.Building!.Name,
                        CampusName = item.Classroom.Building.Campus!.Name
                        ,InstructorNames = item.Instructors
                            .OrderBy(instructor => instructor.Teacher!.TeacherNumber)
                            .Select(instructor => instructor.Teacher!.Name)
                    }),
                MyBooking = x.Bookings
                    .Where(item =>
                        item.StudentId == student.Id &&
                        item.Status == ExperimentBookingStatus.Booked)
                    .Select(item => new
                    {
                        item.Id,
                        item.ExperimentSessionId,
                        item.BookedAt
                    })
                    .FirstOrDefault()
            })
            .ToListAsync(cancellationToken));
    }

    [HttpPost]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> CreateProject(
        ExperimentProjectRequest request,
        CancellationToken cancellationToken)
    {
        var task = await AccessibleTeachingTasks().AsNoTracking()
            .Include(x => x.AcademicTerm)
            .FirstOrDefaultAsync(x =>
                x.Id == request.TeachingTaskId &&
                x.Status == TeachingTaskStatus.Published,
                cancellationToken);
        if (task is null)
            return ValidationProblem("教学任务不存在、未发布或不在当前管理范围内。");

        var problem = ValidateProjectRequest(request, task.AcademicTerm!);
        if (problem is not null) return ValidationProblem(problem);
        var scheduleEntry = await ValidateScheduleEntryAsync(
            request, task.Id, cancellationToken);
        if (scheduleEntry.Problem is not null) return ValidationProblem(scheduleEntry.Problem);

        var code = request.Code.Trim();
        if (await db.ExperimentProjects.AnyAsync(x =>
                x.TeachingTaskId == request.TeachingTaskId &&
                x.Code == code,
                cancellationToken))
            return ConflictProblem("该教学任务下已存在相同实验项目编码。");

        var project = new ExperimentProject
        {
            TeachingTaskId = request.TeachingTaskId,
            ScheduleEntryId = scheduleEntry.Entry?.Id,
            Code = code,
            Name = request.Name.Trim(),
            ArrangementMode = request.ArrangementMode,
            Description = Normalize(request.Description),
            Requirements = Normalize(request.Requirements),
            StartDate = request.StartDate,
            EndDate = request.EndDate,
            SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt),
            SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt)
        };
        db.ExperimentProjects.Add(project);
        await db.SaveChangesAsync(cancellationToken);
        return Created(string.Empty, new { project.Id });
    }

    [HttpPost("batch")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> CreateProjects(
        ExperimentProjectBatchRequest request,
        CancellationToken cancellationToken)
    {
        var taskIds = request.TeachingTaskIds
            .Where(x => x != Guid.Empty)
            .Distinct()
            .ToList();
        if (taskIds.Count == 0)
            return ValidationProblem("请至少选择一个教学任务。");
        if (taskIds.Count > 100)
            return ValidationProblem("单次最多为 100 个教学任务创建实验项目。");

        var tasks = await AccessibleTeachingTasks().AsNoTracking()
            .Include(x => x.AcademicTerm)
            .WhereIn(taskIds, x => x.Id)
            .Where(x => x.Status == TeachingTaskStatus.Published)
            .OrderBy(x => x.TaskNumber)
            .ToListAsync(cancellationToken);
        if (tasks.Count != taskIds.Count)
            return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。");

        var first = tasks[0];
        if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled && tasks.Any(x =>
                x.AcademicTermId != first.AcademicTermId ||
                x.CourseId != first.CourseId))
            return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");

        var code = request.Code.Trim();
        if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
        {
            var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
                .WhereIn(taskIds, x => x.TeachingTaskId)
                .Where(x => x.Code == code)
                .Select(x => x.TeachingTask!.TaskNumber)
                .OrderBy(x => x)
                .ToListAsync(cancellationToken);
            if (conflictingTaskNumbers.Count > 0)
                return ConflictProblem(
                    $"以下教学任务已存在实验项目编码 {code}：{string.Join("、", conflictingTaskNumbers)}。");
        }

        var scheduledEntries = request.ArrangementMode == ExperimentArrangementMode.Centralized
            ? await db.ScheduleEntries.AsNoTracking()
                .Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
                            x.Kind == ScheduleEntryKind.Experiment &&
                            x.ClassroomId.HasValue &&
                            taskIds.Contains(x.TeachingTaskId))
                .OrderBy(x => x.TeachingTaskId)
                .ThenBy(x => x.DayOfWeek)
                .ThenBy(x => x.StartPeriod)
                .ToListAsync(cancellationToken)
            : [];
        if (request.ArrangementMode == ExperimentArrangementMode.Centralized &&
            tasks.Any(task => scheduledEntries.All(entry => entry.TeachingTaskId != task.Id)))
            return ValidationProblem("所选教学班中包含未排入实验室的已发布实验课，请先完成课表安排。");

        var scheduledOccurrences = scheduledEntries
            .SelectMany(entry => Enumerable.Range(
                    entry.StartWeek,
                    entry.EndWeek - entry.StartWeek + 1)
                .Where(week => FreeClassroomRules.MatchesWeek(entry.WeekPattern, week))
                .Select(week => (Entry: entry, Week: week)))
            .ToList();

        var legacyProjects = scheduledEntries.Count > 0
            ? await db.ExperimentProjects
                .Where(x => x.Code == code && x.ScheduleEntryId.HasValue &&
                            x.ScheduleWeek == null &&
                            taskIds.Contains(x.TeachingTaskId))
                .ToListAsync(cancellationToken)
            : [];
        if (legacyProjects.Any(x => x.Status != ExperimentProjectStatus.Draft))
            return ConflictProblem("存在旧版已发布实验项目，不能自动拆分为每周项目；请先关闭后重新设置。");

        if (scheduledOccurrences.Count > 0)
        {
            var entryIds = scheduledOccurrences.Select(x => x.Entry.Id).Distinct().ToList();
            var existingOccurrences = await db.ExperimentProjects.AsNoTracking()
                .Where(x => x.Code == code && x.ScheduleEntryId.HasValue &&
                            x.ScheduleWeek.HasValue && entryIds.Contains(x.ScheduleEntryId.Value))
                .Select(x => new { ScheduleEntryId = x.ScheduleEntryId!.Value, ScheduleWeek = x.ScheduleWeek!.Value })
                .ToListAsync(cancellationToken);
            var existingKeys = existingOccurrences
                .Select(x => (x.ScheduleEntryId, x.ScheduleWeek))
                .ToHashSet();
            if (scheduledOccurrences.Any(x => existingKeys.Contains((x.Entry.Id, x.Week))))
                return ConflictProblem("所选实验课中已存在相同实验项目编码和周次，不能重复生成。");
        }

        var projects = new List<ExperimentProject>(tasks.Count);
        foreach (var task in tasks)
        {
            var item = request.ForTeachingTask(task.Id);
            var problem = ValidateProjectRequest(item, task.AcademicTerm!);
            if (problem is not null) return ValidationProblem(problem);

            IEnumerable<(ScheduleEntry? Entry, int? Week)> taskOccurrences =
                request.ArrangementMode == ExperimentArrangementMode.Centralized
                    ? scheduledOccurrences
                        .Where(item => item.Entry.TeachingTaskId == task.Id)
                        .Select(item => ((ScheduleEntry?)item.Entry, (int?)item.Week))
                    : [(null, null)];
            foreach (var occurrence in taskOccurrences)
            {
                var scheduleEntry = occurrence.Entry;
                var legacy = scheduleEntry is null ? null : legacyProjects
                    .SingleOrDefault(x => x.ScheduleEntryId == scheduleEntry.Id);
                if (legacy is not null)
                {
                    var firstWeek = scheduledOccurrences
                        .Where(item => item.Entry.Id == scheduleEntry!.Id)
                        .Min(item => item.Week);
                    if (occurrence.Week == firstWeek)
                    {
                        legacy.ScheduleWeek = occurrence.Week;
                        continue;
                    }
                }
                projects.Add(new ExperimentProject
                {
                    TeachingTaskId = task.Id,
                    ScheduleEntryId = occurrence.Entry?.Id,
                    ScheduleWeek = occurrence.Week,
                    Code = code,
                    Name = request.Name.Trim(),
                    ArrangementMode = request.ArrangementMode,
                    Description = Normalize(request.Description),
                    Requirements = Normalize(request.Requirements),
                    StartDate = request.StartDate,
                    EndDate = request.EndDate,
                    SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt),
                    SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt)
                });
            }
        }

        var names = NormalizeBatchProjectNames(request.Names);
        if (request.GenerateSequentialNames)
        {
            if (names is not null)
                return ValidationProblem("自动生成顺序号时无需填写逐行项目名称。");
            var width = Math.Max(2, projects.Count.ToString().Length);
            if (request.Name.Trim().Length + width + 1 > 120)
                return ValidationProblem("项目名称前缀过长，无法追加顺序号。");
            for (var index = 0; index < projects.Count; index++)
                projects[index].Name = $"{request.Name.Trim()} {(index + 1).ToString($"D{width}")}";
        }
        else if (names is not null)
        {
            if (names.Any(name => string.IsNullOrWhiteSpace(name) || name.Length > 120))
                return ValidationProblem("每个实验项目名称均不能为空，且不能超过 120 个字符。");
            if (names.Count != projects.Count)
                return ValidationProblem($"已填写 {names.Count} 个项目名称，但本次将生成 {projects.Count} 个实验项目；请确保每行对应一个项目。");
            for (var index = 0; index < projects.Count; index++)
                projects[index].Name = names[index];
        }

        db.ExperimentProjects.AddRange(projects);
        await db.SaveChangesAsync(cancellationToken);
        return Created(string.Empty, new
        {
            Count = projects.Count,
            ProjectIds = projects.Select(x => x.Id)
        });
    }

    [HttpPut("{id:guid}")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> UpdateProject(
        Guid id,
        ExperimentProjectRequest request,
        CancellationToken cancellationToken)
    {
        var project = await ScopedProjects()
            .Include(x => x.TeachingTask)
            .ThenInclude(x => x!.AcademicTerm)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (project is null) return NotFound();
        if (project.Status != ExperimentProjectStatus.Draft)
            return ConflictProblem("只有草稿实验项目可以修改。");
        if (project.TeachingTaskId != request.TeachingTaskId)
            return ValidationProblem("创建后不能更换实验项目所属教学任务。");

        var problem = ValidateProjectRequest(
            request,
            project.TeachingTask!.AcademicTerm!);
        if (problem is not null) return ValidationProblem(problem);
        var scheduleEntry = await ValidateScheduleEntryAsync(
            request, project.TeachingTaskId, cancellationToken);
        if (scheduleEntry.Problem is not null) return ValidationProblem(scheduleEntry.Problem);

        var code = request.Code.Trim();
        if (await db.ExperimentProjects.AnyAsync(x =>
                x.Id != id &&
                x.TeachingTaskId == request.TeachingTaskId &&
                x.Code == code,
                cancellationToken))
            return ConflictProblem("该教学任务下已存在相同实验项目编码。");

        project.Code = code;
        project.Name = request.Name.Trim();
        project.ArrangementMode = request.ArrangementMode;
        project.ScheduleEntryId = scheduleEntry.Entry?.Id;
        project.Description = Normalize(request.Description);
        project.Requirements = Normalize(request.Requirements);
        project.StartDate = request.StartDate;
        project.EndDate = request.EndDate;
        project.SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt);
        project.SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt);
        await db.SaveChangesAsync(cancellationToken);
        return NoContent();
    }

    [HttpDelete("{id:guid}")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> DeleteProject(
        Guid id,
        CancellationToken cancellationToken)
    {
        var project = await ScopedProjects()
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (project is null) return NotFound();
        if (project.Status != ExperimentProjectStatus.Draft)
            return ConflictProblem("只有草稿实验项目可以删除。");

        db.ExperimentProjects.Remove(project);
        await db.SaveChangesAsync(cancellationToken);
        return NoContent();
    }

    [HttpPut("{id:guid}/published-details")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> CorrectPublishedProjectDetails(
        Guid id,
        PublishedExperimentProjectCorrectionRequest request,
        CancellationToken cancellationToken)
    {
        var project = await ScopedProjects()
            .Include(x => x.TeachingTask)
            .ThenInclude(x => x!.Teachers)
            .ThenInclude(x => x.Teacher)
            .Include(x => x.TeachingTask)
            .ThenInclude(x => x!.Course)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (project is null) return NotFound();
        if (project.Status != ExperimentProjectStatus.Published)
            return ConflictProblem("只有已发布实验项目可以修正教学内容。");
        if (!CanCorrectPublishedProject(project.TeachingTask!)) return Forbid();
        if (string.IsNullOrWhiteSpace(request.Name))
            return ValidationProblem("请填写实验项目名称。");

        var changed = project.Name != request.Name.Trim() ||
                      project.Description != Normalize(request.Description) ||
                      project.Requirements != Normalize(request.Requirements);
        project.Name = request.Name.Trim();
        project.Description = Normalize(request.Description);
        project.Requirements = Normalize(request.Requirements);
        await db.SaveChangesAsync(cancellationToken);
        if (changed)
            await NotifyProjectCorrectionAsync(project, cancellationToken);
        return NoContent();
    }

    [HttpPut("batch/names")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> UpdateProjectNames(
        ExperimentProjectBatchNamesRequest request,
        CancellationToken cancellationToken)
    {
        var ids = ValidateBulkProjectIds(request.ProjectIds);
        if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");

        var names = NormalizeBatchProjectNames(request.Names);
        if (names is null || names.Count != ids.Count || names.Any(name => string.IsNullOrWhiteSpace(name) || name.Length > 120))
            return ValidationProblem("请为每个已选实验项目填写一个名称，每行一个且数量必须一致。");

        var projects = await ScopedProjects()
            .Where(x => ids.Contains(x.Id))
            .ToListAsync(cancellationToken);
        if (projects.Count != ids.Count) return NotFound();
        if (projects.Any(x => x.Status != ExperimentProjectStatus.Draft))
            return ConflictProblem("批量修改名称只能包含草稿实验项目。");

        var projectsById = projects.ToDictionary(x => x.Id);
        for (var index = 0; index < ids.Count; index++)
            projectsById[ids[index]].Name = names[index];

        await db.SaveChangesAsync(cancellationToken);
        return NoContent();
    }

    [HttpDelete("batch")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> DeleteProjects(
        ExperimentProjectBulkRequest request,
        CancellationToken cancellationToken)
    {
        var ids = ValidateBulkProjectIds(request.ProjectIds);
        if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
        var projects = await ScopedProjects()
            .Where(x => ids.Contains(x.Id))
            .ToListAsync(cancellationToken);
        if (projects.Count != ids.Count) return NotFound();
        if (projects.Any(x => x.Status != ExperimentProjectStatus.Draft))
            return ConflictProblem("批量删除只能包含草稿实验项目。");

        db.ExperimentProjects.RemoveRange(projects);
        await db.SaveChangesAsync(cancellationToken);
        return NoContent();
    }

    [HttpPost("{id:guid}/publish")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> PublishProject(
        Guid id,
        CancellationToken cancellationToken)
    {
        var project = await ScopedProjects()
            .Include(x => x.Sessions)
            .ThenInclude(x => x.Instructors)
            .Include(x => x.TeachingTask)
            .ThenInclude(x => x!.Course)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (project is null) return NotFound();
        if (project.Status != ExperimentProjectStatus.Draft)
            return ConflictProblem("只有草稿实验项目可以发布。");
        var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized
            ? project.ScheduleEntryId.HasValue || project.Sessions.Any(x =>
                x.Status == ExperimentSessionStatus.Scheduled)
            : project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled);
        if (!hasSchedule)
            return ConflictProblem(project.ArrangementMode == ExperimentArrangementMode.Centralized
                ? "请先绑定已发布课表中的实验课后再发布。"
                : "请至少安排一个有效实验场次后再发布。");
        if (project.Sessions.Any(x =>
                x.Status == ExperimentSessionStatus.Scheduled &&
                (x.SessionDate < project.StartDate ||
                 x.SessionDate > project.EndDate)))
            return ConflictProblem("存在不在项目开放日期范围内的实验场次。");
        if (project.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
            (project.SelectionStartsAt is null || project.SelectionEndsAt is null ||
             project.SelectionStartsAt >= project.SelectionEndsAt))
            return ConflictProblem("请先设置有效的开始选课和截至选课时间。");
        if (project.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
            project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
                                      x.Instructors.Count == 0))
            return ConflictProblem("请为每个有效实验场次至少指定一位指导老师。");

        project.Status = ExperimentProjectStatus.Published;
        project.PublishedAt = DateTime.UtcNow;
        await db.SaveChangesAsync(cancellationToken);
        await NotifyProjectPublishedAsync(project, cancellationToken);
        return NoContent();
    }

    [HttpPost("batch/publish")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> PublishProjects(
        ExperimentProjectBulkRequest request,
        CancellationToken cancellationToken)
    {
        var ids = ValidateBulkProjectIds(request.ProjectIds);
        if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
        if (await ScopedProjects().CountAsync(x => ids.Contains(x.Id), cancellationToken) != ids.Count)
            return NotFound();
        var userId = currentUserDataScope.Current.UserId;
        var job = new ExamPublishJob
        {
            Kind = ExamPublishJobKind.ExperimentProjects,
            PlanId = ids[0],
            RequestedByUserId = userId == Guid.Empty ? null : userId,
            ProjectIdsJson = JsonSerializer.Serialize(ids),
            CurrentStep = "等待后台校验"
        };
        db.ExamPublishJobs.Add(job);
        db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
            BackgroundJobKind.ExamPublish, job.Id));
        await db.SaveChangesAsync(cancellationToken);
        return Accepted(new { JobId = job.Id, Status = job.Status, Message = "实验项目发布任务已提交。" });
    }

    [HttpGet("batch/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.ExperimentProjects,
            cancellationToken);
        if (job is null) return NotFound();
        return Ok(new { job.Id, job.Status, job.CurrentStep, job.ErrorMessage, job.StartedAt, job.CompletedAt });
    }

    [HttpPost("{id:guid}/close")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> CloseProject(
        Guid id,
        CancellationToken cancellationToken)
    {
        var project = await ScopedProjects()
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (project is null) return NotFound();
        if (project.Status != ExperimentProjectStatus.Published)
            return ConflictProblem("只有已发布实验项目可以关闭。");
        project.Status = ExperimentProjectStatus.Closed;
        project.ClosedAt = DateTime.UtcNow;
        await db.SaveChangesAsync(cancellationToken);
        return NoContent();
    }

    [HttpPost("{projectId:guid}/sessions")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> CreateSession(
        Guid projectId,
        ExperimentSessionRequest request,
        CancellationToken cancellationToken)
    {
        var project = await ScopedProjects()
            .Include(x => x.TeachingTask)
            .ThenInclude(x => x!.AcademicTerm)
            .FirstOrDefaultAsync(x => x.Id == projectId, cancellationToken);
        if (project is null) return NotFound();
        if (project.Status == ExperimentProjectStatus.Closed)
            return ConflictProblem("已关闭实验项目不能再增加场次。");
        if (project.ArrangementMode == ExperimentArrangementMode.Centralized)
            return ConflictProblem("集中安排的实验项目直接使用已发布课表中的实验课，不能在此重复排时派地点。");

        var problem = await ValidateSessionAsync(
            project,
            request,
            cancellationToken);
        if (problem is not null) return ConflictProblem(problem);

        var session = new ExperimentSession
        {
            ExperimentProjectId = project.Id,
            ClassroomId = request.ClassroomId,
            SessionDate = request.SessionDate,
            StartPeriod = request.StartPeriod,
            PeriodCount = request.PeriodCount,
            Capacity = await ResolveSessionCapacityAsync(
                project,
                request.Capacity,
                cancellationToken),
            Notes = Normalize(request.Notes)
        };
        session.Instructors = request.InstructorTeacherIds!
            .Distinct()
            .Select(teacherId => new ExperimentSessionInstructor { TeacherId = teacherId })
            .ToList();
        db.ExperimentSessions.Add(session);
        await db.SaveChangesAsync(cancellationToken);

        if (project.Status == ExperimentProjectStatus.Published)
        {
            var userIds = await RosterUserIdsAsync(
                project.TeachingTaskId,
                cancellationToken);
            if (userIds.Count > 0)
            {
                await NotificationService.SendToUserIdsAsync(
                    db,
                    userIds,
                    "新增实验场次",
                    $"“{project.Name}”新增 {session.SessionDate:yyyy-MM-dd} 第 {session.StartPeriod}—{session.StartPeriod + session.PeriodCount - 1} 节场次，请查看实验安排。",
                    "/experiments",
                    cancellationToken,
                    NotificationCategory.Schedule);
            }
        }
        return Created(string.Empty, new { session.Id });
    }

    [HttpPost("sessions/batch")]
    [Authorize(Roles = Managers)]
    public Task<ActionResult> CreateSessions(
        ExperimentSessionBatchRequest request,
        CancellationToken cancellationToken)
    {
        if (request.Items.Count == 0)
            return Task.FromResult<ActionResult>(
                ValidationProblem("请至少添加一条实验排课。"));
        if (request.Items.Count > 100)
            return Task.FromResult<ActionResult>(
                ValidationProblem("单次最多安排 100 条实验场次。"));
        if (request.Items.Any(x => x.ProjectId == Guid.Empty))
            return Task.FromResult<ActionResult>(
                ValidationProblem("实验项目不能为空。"));

        return db.ExecuteInRetriableTransactionAsync<ActionResult>(
            async transaction =>
            {
                var projectIds = request.Items.Select(x => x.ProjectId).Distinct().ToList();
                var projects = await ScopedProjects()
                    .Include(x => x.TeachingTask)
                    .ThenInclude(x => x!.AcademicTerm)
                    .WhereIn(projectIds, x => x.Id)
                    .ToDictionaryAsync(x => x.Id, cancellationToken);
                if (projects.Count != projectIds.Count)
                    return ValidationProblem(
                        "部分实验项目不存在或不在当前管理范围内。");

                var createdSessions = new List<(ExperimentProject Project, ExperimentSession Session)>(
                    request.Items.Count);
                foreach (var item in request.Items)
                {
                    var project = projects[item.ProjectId];
                    if (project.Status == ExperimentProjectStatus.Closed)
                        return ConflictProblem(
                            $"实验项目“{project.Name}”已关闭，不能再增加场次。");
                    if (project.ArrangementMode == ExperimentArrangementMode.Centralized)
                        return ConflictProblem(
                            $"实验项目“{project.Name}”为集中安排，请直接使用已发布课表中的实验课。");

                    var sessionRequest = item.ToSessionRequest();
                    var problem = await ValidateSessionAsync(
                        project,
                        sessionRequest,
                        cancellationToken);
                    if (problem is not null)
                        return ConflictProblem(
                            $"实验项目“{project.Name}”：{problem}");

                    var session = new ExperimentSession
                    {
                        ExperimentProjectId = project.Id,
                        ClassroomId = item.ClassroomId,
                        SessionDate = item.SessionDate,
                        StartPeriod = item.StartPeriod,
                        PeriodCount = item.PeriodCount,
                        Capacity = await ResolveSessionCapacityAsync(
                            project,
                            item.Capacity,
                            cancellationToken),
                        Notes = Normalize(item.Notes)
                    };
                    session.Instructors = item.InstructorTeacherIds!
                        .Distinct()
                        .Select(teacherId => new ExperimentSessionInstructor { TeacherId = teacherId })
                        .ToList();
                    db.ExperimentSessions.Add(session);
                    await db.SaveChangesAsync(cancellationToken);
                    createdSessions.Add((project, session));
                }

                foreach (var (project, session) in createdSessions.Where(x =>
                             x.Project.Status == ExperimentProjectStatus.Published))
                {
                    var userIds = await RosterUserIdsAsync(
                        project.TeachingTaskId,
                        cancellationToken);
                    if (userIds.Count == 0) continue;
                    await NotificationService.SendToUserIdsAsync(
                        db,
                        userIds,
                        "新增实验场次",
                        $"“{project.Name}”新增 {session.SessionDate:yyyy-MM-dd} 第 {session.StartPeriod}—{session.StartPeriod + session.PeriodCount - 1} 节场次，请查看实验安排。",
                        "/experiments",
                        cancellationToken,
                        NotificationCategory.Schedule);
                }

                await transaction.CommitAsync(cancellationToken);
                return Created(string.Empty, new
                {
                    Count = createdSessions.Count,
                    SessionIds = createdSessions.Select(x => x.Session.Id)
                });
            },
            cancellationToken,
            IsolationLevel.Serializable);
    }

    [HttpDelete("sessions/{id:guid}")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> CancelSession(
        Guid id,
        CancellationToken cancellationToken)
    {
        var session = await db.ExperimentSessions
            .Include(x => x.ExperimentProject)
            .ThenInclude(x => x!.TeachingTask)
            .ThenInclude(x => x!.Course)
            .Include(x => x.Instructors)
            .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (session is null) return NotFound();
        if (!CanManageSession(session)) return Forbid();
        if (session.Status == ExperimentSessionStatus.Cancelled)
            return NoContent();

        if (session.ExperimentProject!.Status == ExperimentProjectStatus.Draft)
        {
            db.ExperimentSessions.Remove(session);
            await db.SaveChangesAsync(cancellationToken);
            return NoContent();
        }

        var affectedUserIds =
            session.ExperimentProject.ArrangementMode ==
                ExperimentArrangementMode.Centralized
                ? await RosterUserIdsAsync(
                    session.ExperimentProject.TeachingTaskId,
                    cancellationToken)
                : await db.ExperimentBookings
                    .Where(x =>
                        x.ExperimentSessionId == id &&
                        x.Status == ExperimentBookingStatus.Booked &&
                        x.Student!.UserId.HasValue)
                    .Select(x => x.Student!.UserId!.Value)
                    .Distinct()
                    .ToListAsync(cancellationToken);
        var bookings = await db.ExperimentBookings
            .Where(x =>
                x.ExperimentSessionId == id &&
                x.Status == ExperimentBookingStatus.Booked)
            .ToListAsync(cancellationToken);
        foreach (var booking in bookings)
        {
            booking.Status = ExperimentBookingStatus.Cancelled;
            booking.CancelledAt = DateTime.UtcNow;
        }
        session.Status = ExperimentSessionStatus.Cancelled;
        session.CancelledAt = DateTime.UtcNow;
        session.ReservedCount = 0;
        await db.SaveChangesAsync(cancellationToken);

        if (affectedUserIds.Count > 0)
        {
            await NotificationService.SendToUserIdsAsync(
                db,
                affectedUserIds,
                "实验场次已取消",
                $"“{session.ExperimentProject.Name}”原定于 {session.SessionDate:yyyy-MM-dd} 的实验场次已取消，请重新查看安排。",
                "/experiments",
                cancellationToken,
                NotificationCategory.Schedule);
        }
        return NoContent();
    }

    [HttpGet("sessions/{id:guid}/participants")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetParticipants(
        Guid id,
        CancellationToken cancellationToken)
    {
        var session = await db.ExperimentSessions.AsNoTracking()
            .Include(x => x.ExperimentProject)
            .Include(x => x.Instructors)
            .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (session is null) return NotFound();
        if (!CanManageSession(session)) return Forbid();

        if (session.ExperimentProject!.ArrangementMode ==
            ExperimentArrangementMode.Centralized)
        {
            var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
                db,
                [session.ExperimentProject.TeachingTaskId],
                cancellationToken);
            return Ok(roster.Select(x => new
            {
                x.StudentId,
                x.StudentNumber,
                x.Name,
                x.ClassName,
                ParticipationType = "Centralized"
            }));
        }

        return Ok(await db.ExperimentBookings.AsNoTracking()
            .Where(x =>
                x.ExperimentSessionId == id &&
                x.Status == ExperimentBookingStatus.Booked)
            .OrderBy(x => x.Student!.StudentNumber)
            .Select(x => new
            {
                x.StudentId,
                x.Student!.StudentNumber,
                x.Student.Name,
                ClassName = x.Student.AdministrativeClass!.Name,
                ParticipationType = "Booked",
                x.BookedAt
            })
            .ToListAsync(cancellationToken));
    }

    [HttpPost("sessions/{id:guid}/book")]
    [Authorize(Roles = SystemRoles.Student)]
    public async Task<ActionResult> Book(
        Guid id,
        CancellationToken cancellationToken)
    {
        return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
            async transaction =>
            {
                db.ChangeTracker.Clear();
                var student = await CurrentStudentAsync(cancellationToken);
                if (student is null)
                    return ConflictProblem("当前账号未关联有效学生档案。");

                var session = await db.ExperimentSessions
                    .Include(x => x.ExperimentProject)
                    .ThenInclude(x => x!.TeachingTask)
                    .ThenInclude(x => x!.AcademicTerm)
                    .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
                if (session is null ||
                    session.Status != ExperimentSessionStatus.Scheduled ||
                    session.ExperimentProject!.Status !=
                        ExperimentProjectStatus.Published)
                    return NotFound();
                if (session.ExperimentProject.ArrangementMode !=
                    ExperimentArrangementMode.SelfScheduled)
                    return ConflictProblem("集中安排实验无需学生预约。");
                var now = DateTime.UtcNow;
                if (session.ExperimentProject.SelectionStartsAt is null ||
                    session.ExperimentProject.SelectionEndsAt is null ||
                    now < session.ExperimentProject.SelectionStartsAt ||
                    now > session.ExperimentProject.SelectionEndsAt)
                    return ConflictProblem("当前不在该实验项目的选课时间范围内。");
                if (!await TeachingTaskRosterQuery
                        .TaskIdsForStudent(db, student.Id)
                        .ContainsAsync(
                            session.ExperimentProject.TeachingTaskId,
                            cancellationToken))
                    return Forbid();
                if (session.SessionDate <
                    DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)))
                    return ConflictProblem("该实验场次已经结束，不能预约。");

                var existing = await db.ExperimentBookings
                    .FirstOrDefaultAsync(x =>
                        x.ExperimentProjectId ==
                            session.ExperimentProjectId &&
                        x.StudentId == student.Id,
                        cancellationToken);
                if (existing?.Status == ExperimentBookingStatus.Booked)
                {
                    return existing.ExperimentSessionId == session.Id
                        ? NoContent()
                        : ConflictProblem("该实验项目已有预约，请先取消原预约。");
                }

                var scheduleProblem = await StudentConflictAsync(
                    student.Id,
                    session,
                    cancellationToken);
                if (scheduleProblem is not null)
                    return ConflictProblem(scheduleProblem);

                var reservedCount = await db.ExperimentBookings
                    .CountAsync(x =>
                        x.ExperimentSessionId == session.Id &&
                        x.Status == ExperimentBookingStatus.Booked,
                        cancellationToken);
                if (reservedCount >= session.Capacity)
                    return ConflictProblem("该实验场次名额已满，请选择其他时间。");

                if (existing is null)
                {
                    db.ExperimentBookings.Add(new ExperimentBooking
                    {
                        ExperimentProjectId = session.ExperimentProjectId,
                        ExperimentSessionId = session.Id,
                        StudentId = student.Id
                    });
                }
                else
                {
                    existing.ExperimentSessionId = session.Id;
                    existing.Status = ExperimentBookingStatus.Booked;
                    existing.BookedAt = DateTime.UtcNow;
                    existing.CancelledAt = null;
                }
                session.ReservedCount = reservedCount + 1;
                await db.SaveChangesAsync(cancellationToken);
                await transaction.CommitAsync(cancellationToken);
                return NoContent();
            },
            cancellationToken,
            IsolationLevel.Serializable);
    }

    [HttpDelete("bookings/{id:guid}")]
    [Authorize(Roles = SystemRoles.Student)]
    public async Task<ActionResult> CancelBooking(
        Guid id,
        CancellationToken cancellationToken)
    {
        return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
            async transaction =>
            {
                db.ChangeTracker.Clear();
                var student = await CurrentStudentAsync(cancellationToken);
                if (student is null)
                    return ConflictProblem("当前账号未关联有效学生档案。");

                var booking = await db.ExperimentBookings
                    .Include(x => x.ExperimentSession)
                    .FirstOrDefaultAsync(x =>
                        x.Id == id &&
                        x.StudentId == student.Id,
                        cancellationToken);
                if (booking is null) return NotFound();
                if (booking.Status == ExperimentBookingStatus.Cancelled)
                    return NoContent();

                booking.Status = ExperimentBookingStatus.Cancelled;
                booking.CancelledAt = DateTime.UtcNow;
                booking.ExperimentSession!.ReservedCount = Math.Max(
                    0,
                    await db.ExperimentBookings.CountAsync(x =>
                        x.ExperimentSessionId ==
                            booking.ExperimentSessionId &&
                        x.Status == ExperimentBookingStatus.Booked &&
                        x.Id != booking.Id,
                        cancellationToken));
                await db.SaveChangesAsync(cancellationToken);
                await transaction.CommitAsync(cancellationToken);
                return NoContent();
            },
            cancellationToken,
            IsolationLevel.Serializable);
    }

    private async Task<string?> ValidateInstructorTeacherIdsAsync(
        ExperimentProject project,
        IReadOnlyList<Guid>? instructorTeacherIds,
        CancellationToken cancellationToken)
    {
        var ids = instructorTeacherIds?.Where(x => x != Guid.Empty).Distinct().ToList() ?? [];
        if (ids.Count == 0) return "请至少选择一位指导老师。";
        if (ids.Count > 20) return "单个实验场次最多选择 20 位指导老师。";
        var validCount = await db.TeachingTaskTeachers.AsNoTracking()
            .Where(x => x.TeachingTaskId == project.TeachingTaskId &&
                        ids.Contains(x.TeacherId) &&
                        x.Teacher!.Status == TeacherStatus.Active)
            .Select(x => x.TeacherId)
            .Distinct()
            .CountAsync(cancellationToken);
        return validCount == ids.Count
            ? null
            : "指导老师必须是该教学任务的在职任课教师。";
    }

    private async Task<string?> ValidateSessionAsync(
        ExperimentProject project,
        ExperimentSessionRequest request,
        CancellationToken cancellationToken)
    {
        var instructorProblem = await ValidateInstructorTeacherIdsAsync(
            project, request.InstructorTeacherIds, cancellationToken);
        if (instructorProblem is not null) return instructorProblem;
        if (request.SessionDate < project.StartDate ||
            request.SessionDate > project.EndDate)
            return "实验场次日期必须在项目开放日期范围内。";

        var configuredPeriodCount = await db.ScheduleTimeSlots.AsNoTracking()
            .CountAsync(x =>
                x.AcademicTermId ==
                    project.TeachingTask!.AcademicTermId &&
                x.IsEnabled,
                cancellationToken);
        var validPeriodCount = await db.ScheduleTimeSlots.AsNoTracking()
            .CountAsync(x =>
                x.AcademicTermId ==
                    project.TeachingTask!.AcademicTermId &&
                x.IsEnabled &&
                x.PeriodNumber >= request.StartPeriod &&
                x.PeriodNumber <
                    request.StartPeriod + request.PeriodCount,
                cancellationToken);
        var usesValidDefaultPeriods =
            configuredPeriodCount == 0 &&
            request.StartPeriod + request.PeriodCount - 1 <= 12;
        if (!usesValidDefaultPeriods &&
            validPeriodCount != request.PeriodCount)
            return "所选实验节次不在该学期启用节次范围内。";

        var classroom = await db.Classrooms.AsNoTracking()
            .FirstOrDefaultAsync(x =>
                x.Id == request.ClassroomId && x.IsEnabled,
                cancellationToken);
        if (classroom is null) return "实验教室不存在或已停用。";
        if (!TeachingVenueNatureRules.SupportsExperiment(classroom.TeachingVenueNature))
            return "所选场地未标注实验教学性质。";
        if (request.Capacity > classroom.Capacity)
            return $"场次容量不能超过教室容量 {classroom.Capacity} 人。";
        if (project.ArrangementMode ==
            ExperimentArrangementMode.Centralized)
        {
            var rosterCount = await TeachingTaskRosterQuery
                .ForTask(db, project.TeachingTaskId)
                .CountAsync(cancellationToken);
            if (rosterCount > classroom.Capacity)
                return $"集中实验应到 {rosterCount} 人，超过教室容量 {classroom.Capacity} 人。";
        }

        var occupiedRooms =
            await classroomAvailability.GetOccupiedClassroomIdsAsync(
                project.TeachingTask!.AcademicTerm!,
                request.SessionDate,
                request.StartPeriod,
                request.PeriodCount,
                null,
                cancellationToken);
        if (occupiedRooms.Contains(request.ClassroomId))
            return "所选教室与已发布课程、考试或已批准借用安排冲突。";

        var experimentRoomConflict = await db.ExperimentSessions.AsNoTracking()
            .AnyAsync(x =>
                x.ClassroomId == request.ClassroomId &&
                x.SessionDate == request.SessionDate &&
                x.Status == ExperimentSessionStatus.Scheduled &&
                x.StartPeriod <
                    request.StartPeriod + request.PeriodCount &&
                request.StartPeriod < x.StartPeriod + x.PeriodCount,
                cancellationToken);
        if (experimentRoomConflict)
            return "所选教室与其他实验场次冲突。";

        if (project.ArrangementMode ==
            ExperimentArrangementMode.Centralized)
        {
            return await CentralizedTaskConflictAsync(
                project.TeachingTaskId,
                project.TeachingTask.AcademicTerm!,
                request.SessionDate,
                request.StartPeriod,
                request.PeriodCount,
                cancellationToken);
        }
        return null;
    }

    private async Task<string?> CentralizedTaskConflictAsync(
        Guid teachingTaskId,
        AcademicTerm term,
        DateOnly date,
        int startPeriod,
        int periodCount,
        CancellationToken cancellationToken)
    {
        var teacherIds = await db.TeachingTaskTeachers.AsNoTracking()
            .Where(x => x.TeachingTaskId == teachingTaskId)
            .Select(x => x.TeacherId)
            .ToListAsync(cancellationToken);
        var classIds = await db.TeachingTaskClasses.AsNoTracking()
            .Where(x => x.TeachingTaskId == teachingTaskId)
            .Select(x => x.AdministrativeClassId)
            .ToListAsync(cancellationToken);
        var (week, dayOfWeek) =
            ClassroomReservationAvailabilityService.ResolveTeachingWeek(
                term,
                date);

        var relatedTaskIds = (await CentralizedExperimentConflictQuery
                .TaskIdsForTeachers(db, teacherIds)
                .ToListAsync(cancellationToken))
            .Concat(await CentralizedExperimentConflictQuery
                .TaskIdsForClasses(db, classIds)
                .ToListAsync(cancellationToken))
            .Distinct()
            .ToArray();

        var scheduleConflicts = await CentralizedExperimentConflictQuery
            .ScheduleEntries(
                db,
                relatedTaskIds,
                term.Id,
                dayOfWeek,
                week,
                startPeriod,
                periodCount)
            .Select(x => new { x.TeachingTaskId, x.WeekPattern })
            .ToListAsync(cancellationToken);
        if (scheduleConflicts.Any(x =>
                FreeClassroomRules.MatchesWeek(x.WeekPattern, week)))
            return "集中实验与相关教师或行政班的已发布课表冲突。";

        var experimentConflict = await CentralizedExperimentConflictQuery
            .ExperimentSessions(
                db,
                relatedTaskIds,
                date,
                startPeriod,
                periodCount)
            .AnyAsync(cancellationToken);
        return experimentConflict
            ? "集中实验与相关教师或行政班的其他实验安排冲突。"
            : null;
    }

    private async Task<string?> StudentConflictAsync(
        Guid studentId,
        ExperimentSession target,
        CancellationToken cancellationToken)
    {
        var project = target.ExperimentProject!;
        var term = project.TeachingTask!.AcademicTerm!;
        var taskIds = await TeachingTaskRosterQuery
            .TaskIdsForStudent(db, studentId)
            .ToListAsync(cancellationToken);
        var (week, dayOfWeek) =
            ClassroomReservationAvailabilityService.ResolveTeachingWeek(
                term,
                target.SessionDate);

        var scheduleEntries = await db.ScheduleEntries.AsNoTracking()
            .Where(x =>
                taskIds.Contains(x.TeachingTaskId) &&
                x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
                x.SchedulePlan.AcademicTermId == term.Id &&
                x.DayOfWeek == dayOfWeek &&
                x.StartWeek <= week &&
                x.EndWeek >= week &&
                x.StartPeriod < target.StartPeriod + target.PeriodCount &&
                target.StartPeriod < x.StartPeriod + x.PeriodCount)
            .Select(x => x.WeekPattern)
            .ToListAsync(cancellationToken);
        if (scheduleEntries.Any(pattern =>
                FreeClassroomRules.MatchesWeek(pattern, week)))
            return "该时间与您的已发布课表冲突。";

        var bookingConflict = await db.ExperimentBookings.AsNoTracking()
            .AnyAsync(x =>
                x.StudentId == studentId &&
                x.Status == ExperimentBookingStatus.Booked &&
                x.ExperimentSessionId != target.Id &&
                x.ExperimentSession!.Status ==
                    ExperimentSessionStatus.Scheduled &&
                x.ExperimentSession.SessionDate == target.SessionDate &&
                x.ExperimentSession.StartPeriod <
                    target.StartPeriod + target.PeriodCount &&
                target.StartPeriod <
                    x.ExperimentSession.StartPeriod +
                    x.ExperimentSession.PeriodCount,
                cancellationToken);
        if (bookingConflict) return "该时间与您已预约的其他实验冲突。";

        var centralizedConflict = await db.ExperimentSessions.AsNoTracking()
            .AnyAsync(x =>
                x.Id != target.Id &&
                x.Status == ExperimentSessionStatus.Scheduled &&
                x.SessionDate == target.SessionDate &&
                x.StartPeriod < target.StartPeriod + target.PeriodCount &&
                target.StartPeriod < x.StartPeriod + x.PeriodCount &&
                x.ExperimentProject!.Status ==
                    ExperimentProjectStatus.Published &&
                x.ExperimentProject.ArrangementMode ==
                    ExperimentArrangementMode.Centralized &&
                taskIds.Contains(x.ExperimentProject.TeachingTaskId),
                cancellationToken);
        return centralizedConflict
            ? "该时间与您的集中实验安排冲突。"
            : null;
    }

    private IQueryable<TeachingTask> AccessibleTeachingTasks()
    {
        var source = db.TeachingTasks.AsQueryable();
        var scope = currentUserDataScope.Current;
        if (scope.Scope == DataScope.All) return source;
        if (scope.Scope == DataScope.College)
            return source.Where(x =>
                x.Course!.CollegeId == scope.RestrictedCollegeId);
        if (scope.IsInRole(SystemRoles.Teacher))
            return source.Where(x =>
                x.Teachers.Any(item =>
                    item.Teacher!.UserId == scope.UserId));
        return source.Where(_ => false);
    }

    private IQueryable<ExperimentProject> ScopedProjects()
    {
        var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
        return db.ExperimentProjects.Where(x =>
            taskIds.Contains(x.TeachingTaskId));
    }

    private bool CanManageSession(ExperimentSession session)
    {
        var scope = currentUserDataScope.Current;
        if (scope.Scope is DataScope.All or DataScope.College)
            return true;
        return scope.IsInRole(SystemRoles.Teacher) &&
               session.Instructors.Any(instructor =>
                   instructor.Teacher?.UserId == scope.UserId);
    }

    private bool CanCorrectPublishedProject(TeachingTask task)
    {
        var scope = currentUserDataScope.Current;
        return scope.IsInRole(SystemRoles.SuperAdmin) ||
               scope.IsInRole(SystemRoles.AcademicAdmin) ||
               scope.IsInRole(SystemRoles.CollegeAdmin) ||
               task.Teachers.Any(item =>
                   item.Teacher?.UserId == scope.UserId);
    }

    private Task<Student?> CurrentStudentAsync(
        CancellationToken cancellationToken) =>
        db.Students.FirstOrDefaultAsync(x =>
            x.UserId == currentUserDataScope.Current.UserId &&
            x.Status == StudentStatus.Active,
            cancellationToken);

    private async Task<int> ResolveSessionCapacityAsync(
        ExperimentProject project,
        int requestedCapacity,
        CancellationToken cancellationToken)
    {
        if (project.ArrangementMode !=
            ExperimentArrangementMode.Centralized)
            return requestedCapacity;

        var rosterCount = await TeachingTaskRosterQuery
            .ForTask(db, project.TeachingTaskId)
            .CountAsync(cancellationToken);
        return rosterCount > 0 ? rosterCount : requestedCapacity;
    }

    private Task<List<Guid>> RosterUserIdsAsync(
        Guid teachingTaskId,
        CancellationToken cancellationToken) =>
        TeachingTaskRosterQuery.ForTask(db, teachingTaskId)
            .Where(x => x.UserId.HasValue)
            .Select(x => x.UserId!.Value)
            .Distinct()
            .ToListAsync(cancellationToken);

    private async Task NotifyProjectPublishedAsync(
        ExperimentProject project,
        CancellationToken cancellationToken)
    {
        var userIds = await RosterUserIdsAsync(project.TeachingTaskId, cancellationToken);
        if (userIds.Count == 0) return;
        var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized
            ? "集中安排"
            : "自行预约";
        await NotificationService.SendToUserIdsAsync(
            db,
            userIds,
            "实验项目已发布",
            $"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”（{mode}），请查看实验安排。",
            "/experiments",
            cancellationToken,
            NotificationCategory.Schedule);
    }

    private async Task NotifyProjectCorrectionAsync(
        ExperimentProject project,
        CancellationToken cancellationToken)
    {
        var userIds = await RosterUserIdsAsync(project.TeachingTaskId, cancellationToken);
        if (userIds.Count == 0) return;
        await NotificationService.SendToUserIdsAsync(
            db,
            userIds,
            "实验项目内容已更新",
            $"《{project.TeachingTask!.Course!.Name}》的“{project.Name}”教学内容或要求已修正，请重新查看。",
            "/experiments",
            cancellationToken,
            NotificationCategory.Schedule);
    }

    private static List<Guid>? ValidateBulkProjectIds(IReadOnlyList<Guid>? projectIds)
    {
        var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList();
        return ids is { Count: > 0 and <= 100 } ? ids : null;
    }

    private static string? ValidateProjectRequest(
        ExperimentProjectRequest request,
        AcademicTerm term)
    {
        if (string.IsNullOrWhiteSpace(request.Code))
            return "请填写实验项目编码。";
        if (string.IsNullOrWhiteSpace(request.Name))
            return "请填写实验项目名称。";
        if (!Enum.IsDefined(request.ArrangementMode))
            return "实验安排方式无效。";
        if (request.StartDate > request.EndDate)
            return "项目开始日期不能晚于结束日期。";
        if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
            request.SelectionStartsAt.HasValue != request.SelectionEndsAt.HasValue)
            return "开始选课和截至选课时间需要同时填写。";
        if (request.SelectionStartsAt.HasValue && request.SelectionEndsAt.HasValue &&
            request.SelectionStartsAt >= request.SelectionEndsAt)
            return "开始选课时间必须早于截至选课时间。";
        if (request.StartDate < term.StartDate ||
            request.EndDate > term.EndDate)
            return "实验项目日期必须在所属学期起止日期内。";
        return null;
    }

    private async Task<(ScheduleEntry? Entry, string? Problem)> ValidateScheduleEntryAsync(
        ExperimentProjectRequest request,
        Guid teachingTaskId,
        CancellationToken cancellationToken)
    {
        if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
            return request.ScheduleEntryId.HasValue
                ? (null, "自行安排的实验项目不能绑定课表实验课。")
                : (null, null);
        if (!request.ScheduleEntryId.HasValue)
            return (null, "集中安排的实验项目必须绑定已发布课表中的实验课。");

        var entry = await db.ScheduleEntries
            .Include(x => x.SchedulePlan)
            .FirstOrDefaultAsync(x => x.Id == request.ScheduleEntryId, cancellationToken);
        if (entry is null || entry.SchedulePlan!.Status != SchedulePlanStatus.Published ||
            entry.Kind != ScheduleEntryKind.Experiment || !entry.ClassroomId.HasValue ||
            entry.TeachingTaskId != teachingTaskId)
            return (null, "只能绑定本教学任务已发布、已安排实验室的实验课。");
        if (!await AccessibleTeachingTasks().AnyAsync(x => x.Id == teachingTaskId, cancellationToken))
            return (null, "该教学任务不在当前管理范围内。");
        return (entry, null);
    }

    private static string? Normalize(string? value) =>
        string.IsNullOrWhiteSpace(value) ? null : value.Trim();

    private static DateTime? NormalizeSelectionTime(DateTime? value)
    {
        if (!value.HasValue) return null;
        if (value.Value.Kind == DateTimeKind.Utc) return value.Value;
        var china = TimeZoneInfo.FindSystemTimeZoneById("China Standard Time");
        return TimeZoneInfo.ConvertTimeToUtc(
            DateTime.SpecifyKind(value.Value, DateTimeKind.Unspecified), china);
    }

    private static List<string>? NormalizeBatchProjectNames(IReadOnlyList<string>? names)
    {
        if (names is null || names.Count == 0) return null;
        return names.Select(name => name.Trim()).ToList();
    }

    private ActionResult ConflictProblem(string detail) =>
        Conflict(new ProblemDetails
        {
            Title = "无法完成操作",
            Detail = detail,
            Status = StatusCodes.Status409Conflict
        });
}

public sealed record ExperimentProjectRequest(
    Guid TeachingTaskId,
    [Required, MaxLength(40)] string Code,
    [Required, MaxLength(120)] string Name,
    ExperimentArrangementMode ArrangementMode,
    [MaxLength(1000)] string? Description,
    [MaxLength(1000)] string? Requirements,
    DateOnly StartDate,
    DateOnly EndDate,
    Guid? ScheduleEntryId = null,
    DateTime? SelectionStartsAt = null,
    DateTime? SelectionEndsAt = null);

public sealed record PublishedExperimentProjectCorrectionRequest(
    [Required, MaxLength(120)] string Name,
    [MaxLength(1000)] string? Description,
    [MaxLength(1000)] string? Requirements);

public sealed record ExperimentProjectBatchRequest(
    [Required] IReadOnlyList<Guid> TeachingTaskIds,
    [Required, MaxLength(40)] string Code,
    [Required, MaxLength(120)] string Name,
    ExperimentArrangementMode ArrangementMode,
    [MaxLength(1000)] string? Description,
    [MaxLength(1000)] string? Requirements,
    DateOnly StartDate,
    DateOnly EndDate,
    IReadOnlyList<string>? Names = null,
    bool GenerateSequentialNames = false,
    DateTime? SelectionStartsAt = null,
    DateTime? SelectionEndsAt = null)
{
    public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
        new(
            teachingTaskId,
            Code,
            Name,
            ArrangementMode,
            Description,
            Requirements,
            StartDate,
            EndDate,
            null,
            SelectionStartsAt,
            SelectionEndsAt);
}

public sealed record ExperimentProjectBulkRequest(
    [Required] IReadOnlyList<Guid> ProjectIds);

public sealed record ExperimentProjectBatchNamesRequest(
    [Required] IReadOnlyList<Guid> ProjectIds,
    [Required] IReadOnlyList<string> Names);

public sealed record ExperimentSessionRequest(
    Guid ClassroomId,
    DateOnly SessionDate,
    [Range(1, 30)] int StartPeriod,
    [Range(1, 30)] int PeriodCount,
    [Range(1, 10000)] int Capacity,
    [MaxLength(500)] string? Notes,
    [Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null);

public sealed record ExperimentSessionBatchRequest(
    [Required] IReadOnlyList<ExperimentSessionBatchItem> Items);

public sealed record ExperimentSessionBatchItem(
    Guid ProjectId,
    Guid ClassroomId,
    DateOnly SessionDate,
    [Range(1, 30)] int StartPeriod,
    [Range(1, 30)] int PeriodCount,
    [Range(1, 10000)] int Capacity,
    [MaxLength(500)] string? Notes,
    [Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null)
{
    public ExperimentSessionRequest ToSessionRequest() =>
        new(
            ClassroomId,
            SessionDate,
            StartPeriod,
            PeriodCount,
            Capacity,
            Notes,
            InstructorTeacherIds);
}

public sealed record ExperimentPeriodOption(
    Guid AcademicTermId,
    int PeriodNumber,
    string Name,
    string StartsAt,
    string EndsAt);
