using System.ComponentModel.DataAnnotations;
using System.Data;
using System.Globalization;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Grades;
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/experiment-grades")]
public sealed class ExperimentGradesController(
    AppDbContext db,
    ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
    private const string Managers =
        SystemRoles.SuperAdmin + "," +
        SystemRoles.AcademicAdmin + "," +
        SystemRoles.CollegeAdmin + "," +
        SystemRoles.Teacher;

    private const string Reviewers =
        SystemRoles.SuperAdmin + "," +
        SystemRoles.AcademicAdmin + "," +
        SystemRoles.CollegeAdmin;

    private const string Publishers =
        SystemRoles.SuperAdmin + "," +
        SystemRoles.AcademicAdmin;

    [HttpGet("management")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetManagement(
        Guid? academicTermId,
        ExperimentGradeSheetStatus? status,
        Guid? collegeId = null,
        string? keyword = null,
        int page = 1,
        int pageSize = 20,
        CancellationToken cancellationToken = default)
    {
        page = Math.Max(1, page);
        pageSize = Math.Clamp(pageSize, 10, 50);
        keyword = Normalize(keyword);
        var source = ScopedProjects().AsNoTracking()
            .Where(x =>
                x.Status == ExperimentProjectStatus.Published ||
                x.Status == ExperimentProjectStatus.Closed);
        if (academicTermId.HasValue)
            source = source.Where(x =>
                x.TeachingTask!.AcademicTermId == academicTermId);
        if (status.HasValue)
            source = source.Where(x =>
                x.GradeSheet != null &&
                x.GradeSheet.Status == status.Value);
        if (collegeId.HasValue)
            source = source.Where(x =>
                x.TeachingTask!.Course!.CollegeId == collegeId.Value);
        if (keyword is not null)
            source = source.Where(x =>
                x.Code.Contains(keyword) ||
                x.Name.Contains(keyword) ||
                x.TeachingTask!.TaskNumber.Contains(keyword) ||
                x.TeachingTask.Name.Contains(keyword) ||
                x.TeachingTask.Course!.Code.Contains(keyword) ||
                x.TeachingTask.Course.Name.Contains(keyword) ||
                x.TeachingTask.Teachers.Any(item =>
                    item.Teacher!.TeacherNumber.Contains(keyword) ||
                    item.Teacher.Name.Contains(keyword)));

        var total = await source.Select(x => x.TeachingTaskId)
            .Distinct()
            .CountAsync(cancellationToken);
        var taskIds = await source
            .GroupBy(x => new
            {
                x.TeachingTaskId,
                StartDate = x.TeachingTask!.AcademicTerm!.StartDate,
                x.TeachingTask.TaskNumber
            })
            .OrderByDescending(x => x.Key.StartDate)
            .ThenBy(x => x.Key.TaskNumber)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Select(x => x.Key.TeachingTaskId)
            .ToListAsync(cancellationToken);
        var projects = await source
            .Where(x => taskIds.Contains(x.TeachingTaskId))
            .OrderBy(x => x.ScheduleWeek ?? int.MaxValue)
            .ThenBy(x => x.ScheduleEntry == null ? int.MaxValue : x.ScheduleEntry.DayOfWeek)
            .ThenBy(x => x.ScheduleEntry == null ? int.MaxValue : x.ScheduleEntry.StartPeriod)
            .ThenBy(x => x.StartDate)
            .ThenBy(x => x.Code)
            .Select(x => new
            {
                x.Id,
                x.Code,
                x.Name,
                x.ArrangementMode,
                x.ScheduleWeek,
                ProjectStatus = x.Status,
                x.TeachingTaskId,
                x.TeachingTask!.TaskNumber,
                TaskName = x.TeachingTask.Name,
                AcademicTermId = x.TeachingTask.AcademicTermId,
                TermName = x.TeachingTask.AcademicTerm!.Name,
                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),
                ScheduleEntry = x.ScheduleEntry == null
                    ? null
                    : new
                    {
                        x.ScheduleEntry.DayOfWeek,
                        x.ScheduleEntry.StartPeriod,
                        x.ScheduleEntry.PeriodCount
                    },
                Sheet = x.GradeSheet == null
                    ? null
                    : new
                    {
                        x.GradeSheet.Id,
                        x.GradeSheet.Status,
                        x.GradeSheet.ContributionWeight,
                        x.GradeSheet.PassScore,
                        ItemCount = x.GradeSheet.Items.Count,
                        StudentCount = x.GradeSheet.Records.Count,
                        CompletedCount = x.GradeSheet.Records.Count(record =>
                            record.ParticipationStatus !=
                                ExperimentParticipationStatus.Pending),
                        ScoredCount = x.GradeSheet.Records.Count(record =>
                            record.TotalScore != null),
                        PassedCount = x.GradeSheet.Records.Count(record =>
                            record.IsPassed == true),
                        x.GradeSheet.SubmittedAt,
                        x.GradeSheet.ReviewedAt,
                        x.GradeSheet.PublishedAt
                    }
            })
            .ToListAsync(cancellationToken);
        var order = taskIds.Select((id, index) => new { id, index })
            .ToDictionary(x => x.id, x => x.index);
        var items = projects.GroupBy(x => x.TeachingTaskId)
            .OrderBy(group => order[group.Key])
            .Select(group =>
            {
                var first = group.First();
                return new
                {
                    TeachingTaskId = group.Key,
                    first.TaskNumber,
                    first.TaskName,
                    first.AcademicTermId,
                    first.TermName,
                    first.CourseCode,
                    first.CourseName,
                    first.CollegeName,
                    first.TeacherNames,
                    ProjectCount = group.Count(),
                    SheetCount = group.Count(project => project.Sheet != null),
                    ScoredCount = group.Sum(project => project.Sheet?.ScoredCount ?? 0),
                    StudentCount = group.Sum(project => project.Sheet?.StudentCount ?? 0),
                    Projects = group.ToList()
                };
            })
            .ToList();
        return Ok(new
        {
            Items = items,
            Total = total,
            Page = page,
            PageSize = pageSize
        });
    }

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

        var results = await db.ExperimentGradeRecords.AsNoTracking()
            .Where(x =>
                x.StudentId == student.Id &&
                x.ExperimentGradeSheet!.Status ==
                    ExperimentGradeSheetStatus.Published)
            .OrderByDescending(x =>
                x.ExperimentGradeSheet!.PublishedAt)
            .ThenBy(x =>
                x.ExperimentGradeSheet!.ExperimentProject!.Code)
            .Select(x => new
            {
                x.Id,
                x.ExperimentGradeSheetId,
                ProjectId = x.ExperimentGradeSheet!.ExperimentProjectId,
                ProjectCode =
                    x.ExperimentGradeSheet.ExperimentProject!.Code,
                ProjectName =
                    x.ExperimentGradeSheet.ExperimentProject.Name,
                x.ExperimentGradeSheet.ExperimentProject.ArrangementMode,
                TeachingTaskId = x.ExperimentGradeSheet.ExperimentProject
                    .TeachingTaskId,
                AcademicTermId = x.ExperimentGradeSheet.ExperimentProject
                    .TeachingTask!.AcademicTermId,
                TaskNumber = x.ExperimentGradeSheet.ExperimentProject
                    .TeachingTask.TaskNumber,
                CourseCode = x.ExperimentGradeSheet.ExperimentProject
                    .TeachingTask!.Course!.Code,
                CourseName = x.ExperimentGradeSheet.ExperimentProject
                    .TeachingTask.Course.Name,
                TermName = x.ExperimentGradeSheet.ExperimentProject
                    .TeachingTask.AcademicTerm!.Name,
                x.ExperimentGradeSheet.ContributionWeight,
                x.ExperimentGradeSheet.PassScore,
                x.ParticipationStatus,
                x.TotalScore,
                x.IsPassed,
                x.SafetyViolation,
                x.AttemptNumber,
                x.SubmissionReference,
                x.SubmittedAt,
                x.IsLate,
                x.TeacherComment,
                Items = x.ItemScores
                    .OrderBy(score =>
                        score.ExperimentGradeItem!.SortOrder)
                    .Select(score => new
                    {
                        score.ExperimentGradeItemId,
                        score.ExperimentGradeItem!.Name,
                        score.ExperimentGradeItem.Kind,
                        score.ExperimentGradeItem.Weight,
                        score.Score,
                        score.Comment
                    }),
                x.ExperimentGradeSheet.PublishedAt
            })
            .ToListAsync(cancellationToken);
        var taskIds = results.Select(x => x.TeachingTaskId).Distinct().ToArray();
        var aggregates = await db.ExperimentCourseGrades.AsNoTracking()
            .Where(x =>
                x.StudentId == student.Id &&
                taskIds.Contains(x.TeachingTaskId))
            .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
        var courses = results
            .GroupBy(x => x.TeachingTaskId)
            .Select(group =>
            {
                var first = group.First();
                aggregates.TryGetValue(group.Key, out var aggregate);
                return new
                {
                    TeachingTaskId = group.Key,
                    first.AcademicTermId,
                    first.TermName,
                    first.TaskNumber,
                    first.CourseCode,
                    first.CourseName,
                    ExperimentCourseScore = aggregate?.WeightedAverageScore,
                    PublishedProjectCount =
                        aggregate?.PublishedProjectCount ?? group.Count(),
                    TotalWeight = aggregate?.TotalWeight ??
                        group.Sum(x => x.ContributionWeight),
                    RefreshedAt = aggregate?.RefreshedAt,
                    Projects = group.OrderBy(x => x.ProjectCode).ToList()
                };
            })
            .OrderByDescending(x => x.Projects.Max(project => project.PublishedAt))
            .ThenBy(x => x.CourseCode)
            .ToList();
        return Ok(courses);
    }

    [HttpPost("sheets")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> CreateSheet(
        ExperimentGradeSheetRequest request,
        CancellationToken cancellationToken)
    {
        var items = BuildItems(request.Items);
        if (!ExperimentGradeCalculator.AreWeightsValid(
                request.ContributionWeight,
                request.PassScore,
                items))
            return ValidationProblem(
                "实验评分项至少设置一项、权重合计 100%，项目贡献权重应大于 0。");

        var project = await ScopedProjects()
            .Include(x => x.TeachingTask)
            .ThenInclude(x => x!.Teachers)
            .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(x =>
                x.Id == request.ExperimentProjectId,
                cancellationToken);
        if (project is null) return NotFound();
        if (project.Status is not (
                ExperimentProjectStatus.Published or
                ExperimentProjectStatus.Closed))
            return ConflictProblem("只有已发布或已关闭实验项目可以建立成绩单。");
        if (!CanInitialize(project.TeachingTask!)) return Forbid();
        if (await db.ExperimentGradeSheets.AnyAsync(x =>
                x.ExperimentProjectId == project.Id,
                cancellationToken))
            return ConflictProblem("该实验项目已经建立成绩单。");

        var participants = await LoadParticipantsAsync(
            project,
            cancellationToken);
        if (participants.Count == 0)
            return ConflictProblem("实验项目当前没有可评分学生。");

        var sheet = new ExperimentGradeSheet
        {
            ExperimentProjectId = project.Id,
            ContributionWeight = request.ContributionWeight,
            PassScore = request.PassScore,
            Items = items,
            Records = participants.Select(participant =>
                new ExperimentGradeRecord
                {
                    StudentId = participant.StudentId,
                    ExperimentSessionId = participant.SessionId,
                    ItemScores = items.Select(item =>
                        new ExperimentGradeItemScore
                        {
                            ExperimentGradeItemId = item.Id
                        }).ToList()
                }).ToList()
        };
        db.ExperimentGradeSheets.Add(sheet);
        await db.SaveChangesAsync(cancellationToken);
        return Created(string.Empty, new { sheet.Id });
    }

    [HttpGet("sheets/{id:guid}")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> GetSheet(
        Guid id,
        int recordPage = 1,
        int recordPageSize = 50,
        string? studentKeyword = null,
        CancellationToken cancellationToken = default)
    {
        recordPage = Math.Max(1, recordPage);
        recordPageSize = Math.Clamp(recordPageSize, 10, 100);
        studentKeyword = string.IsNullOrWhiteSpace(studentKeyword)
            ? null
            : studentKeyword.Trim();

        var sheet = await ScopedSheets().AsNoTracking()
            .Where(x => x.Id == id)
            .Select(x => new
            {
                x.Id,
                x.ExperimentProjectId,
                ProjectCode = x.ExperimentProject!.Code,
                ProjectName = x.ExperimentProject.Name,
                x.ExperimentProject.ArrangementMode,
                x.ExperimentProject.ScheduleWeek,
                ScheduleEntry = x.ExperimentProject.ScheduleEntry == null
                    ? null
                    : new
                    {
                        x.ExperimentProject.ScheduleEntry.DayOfWeek,
                        x.ExperimentProject.ScheduleEntry.StartPeriod,
                        x.ExperimentProject.ScheduleEntry.PeriodCount
                    },
                x.ExperimentProject.TeachingTaskId,
                x.ExperimentProject.TeachingTask!.TaskNumber,
                TaskName = x.ExperimentProject.TeachingTask.Name,
                AcademicTermId =
                    x.ExperimentProject.TeachingTask.AcademicTermId,
                TermName = x.ExperimentProject.TeachingTask
                    .AcademicTerm!.Name,
                CourseCollegeId = x.ExperimentProject.TeachingTask
                    .Course!.CollegeId,
                CourseCollegeName = x.ExperimentProject.TeachingTask
                    .Course.College!.Name,
                CourseCode =
                    x.ExperimentProject.TeachingTask.Course.Code,
                CourseName =
                    x.ExperimentProject.TeachingTask.Course.Name,
                TeacherNames = x.ExperimentProject.TeachingTask.Teachers
                    .OrderByDescending(item => item.IsPrimary)
                    .Select(item => item.Teacher!.Name),
                x.ContributionWeight,
                x.PassScore,
                Items = x.Items.OrderBy(item => item.SortOrder)
                    .Select(item => new
                    {
                        item.Id,
                        item.Name,
                        item.Kind,
                        item.Weight
                    }),
                x.Status,
                x.ReviewComment,
                x.SubmittedAt,
                x.ReviewedAt,
                x.PublishedAt,
                x.CreatedAt,
                x.UpdatedAt
            })
            .FirstOrDefaultAsync(cancellationToken);
        if (sheet is null) return NotFound();

        var recordsSource = db.ExperimentGradeRecords.AsNoTracking()
            .Where(x => x.ExperimentGradeSheetId == id);
        var scope = currentUserDataScope.Current;
        var isBoundInstructor = scope.Scope == DataScope.Self &&
                                scope.IsInRole(SystemRoles.Teacher) &&
                                sheet.ArrangementMode == ExperimentArrangementMode.SelfScheduled;
        if (isBoundInstructor)
            recordsSource = recordsSource.Where(x =>
                x.ExperimentSession != null && x.ExperimentSession.Instructors.Any(
                    instructor => instructor.Teacher!.UserId == scope.UserId));
        if (studentKeyword is not null)
            recordsSource = recordsSource.Where(x =>
                x.Student!.StudentNumber.Contains(studentKeyword) ||
                x.Student.Name.Contains(studentKeyword) ||
                x.Student.AdministrativeClass!.Name.Contains(studentKeyword));
        var recordTotal = await recordsSource.CountAsync(cancellationToken);
        var records = await recordsSource
            .OrderBy(x => x.Student!.StudentNumber)
            .Skip((recordPage - 1) * recordPageSize)
            .Take(recordPageSize)
            .Select(x => new
            {
                x.Id,
                x.StudentId,
                x.Student!.StudentNumber,
                x.Student.Name,
                ClassName = x.Student.AdministrativeClass!.Name,
                x.ExperimentSessionId,
                Session = x.ExperimentSession == null
                    ? null
                    : new
                    {
                        x.ExperimentSession.SessionDate,
                        x.ExperimentSession.StartPeriod,
                        x.ExperimentSession.PeriodCount,
                        ClassroomName =
                            x.ExperimentSession.Classroom!.Name
                    },
                x.ParticipationStatus,
                x.TotalScore,
                x.IsPassed,
                x.SafetyViolation,
                x.AttemptNumber,
                x.SubmissionReference,
                x.SubmittedAt,
                x.IsLate,
                x.TeacherComment,
                ItemScores = x.ItemScores
                    .OrderBy(score =>
                        score.ExperimentGradeItem!.SortOrder)
                    .Select(score => new
                    {
                        score.ExperimentGradeItemId,
                        score.ExperimentGradeItem!.Name,
                        score.Score,
                        score.Comment
                    }),
                x.UpdatedAt
            })
            .ToListAsync(cancellationToken);

        var task = await db.TeachingTasks.AsNoTracking()
            .Include(x => x.Teachers)
            .ThenInclude(x => x.Teacher)
            .SingleAsync(
                x => x.Id == sheet.TeachingTaskId,
                cancellationToken);
        var canReview = CanReviewCollege(sheet.CourseCollegeId) &&
                        sheet.Status ==
                            ExperimentGradeSheetStatus.Submitted;
        return Ok(new
        {
            Sheet = new
            {
                sheet.Id,
                sheet.ExperimentProjectId,
                sheet.ProjectCode,
                sheet.ProjectName,
                sheet.ArrangementMode,
                sheet.TeachingTaskId,
                sheet.TaskNumber,
                sheet.TaskName,
                sheet.AcademicTermId,
                sheet.TermName,
                sheet.CourseCollegeName,
                sheet.CourseCode,
                sheet.CourseName,
                sheet.TeacherNames,
                sheet.ContributionWeight,
                sheet.PassScore,
                sheet.Items,
                sheet.Status,
                sheet.ReviewComment,
                sheet.SubmittedAt,
                sheet.ReviewedAt,
                sheet.PublishedAt,
                RecordTotal = recordTotal,
                RecordPage = recordPage,
                RecordPageSize = recordPageSize,
                Records = records,
                sheet.CreatedAt,
                sheet.UpdatedAt
            },
            CanEdit = (CanEdit(task) || isBoundInstructor) &&
                sheet.Status is
                    ExperimentGradeSheetStatus.Draft or
                    ExperimentGradeSheetStatus.Returned,
            CanReview = canReview,
            CanPublish = IsPublisher() &&
                sheet.Status == ExperimentGradeSheetStatus.Approved
        });
    }

    [HttpPut("sheets/{id:guid}/settings")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> UpdateSettings(
        Guid id,
        ExperimentGradeSettingsRequest request,
        CancellationToken cancellationToken)
    {
        var replacementItems = BuildItems(request.Items);
        if (!ExperimentGradeCalculator.AreWeightsValid(
                request.ContributionWeight,
                request.PassScore,
                replacementItems))
            return ValidationProblem(
                "实验评分项至少设置一项、权重合计 100%，项目贡献权重应大于 0。");

        var sheet = await ScopedSheets()
            .Include(x => x.Items)
            .Include(x => x.Records)
            .ThenInclude(x => x.ItemScores)
            .Include(x => x.ExperimentProject)
            .ThenInclude(x => x!.TeachingTask)
            .ThenInclude(x => x!.Teachers)
            .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (sheet is null) return NotFound();
        if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
        if (sheet.Status is not (
                ExperimentGradeSheetStatus.Draft or
                ExperimentGradeSheetStatus.Returned))
            return ConflictProblem("当前状态不能修改实验评分方案。");

        sheet.ContributionWeight = request.ContributionWeight;
        sheet.PassScore = request.PassScore;
        db.ExperimentGradeItems.RemoveRange(sheet.Items);
        foreach (var record in sheet.Records)
        {
            record.ItemScores.Clear();
            record.TotalScore = null;
            record.IsPassed = null;
        }
        sheet.Items = replacementItems;
        foreach (var record in sheet.Records)
            foreach (var item in replacementItems)
                record.ItemScores.Add(new ExperimentGradeItemScore
                {
                    ExperimentGradeItemId = item.Id
                });
        await db.SaveChangesAsync(cancellationToken);
        return NoContent();
    }

    [HttpPut("sheets/{id:guid}/records")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> UpdateRecords(
        Guid id,
        ExperimentGradeRecordsRequest request,
        CancellationToken cancellationToken)
    {
        var sheet = await ScopedSheets()
            .Include(x => x.Items)
            .Include(x => x.Records)
            .ThenInclude(x => x.ItemScores)
            .Include(x => x.Records)
            .ThenInclude(x => x.ExperimentSession)
            .ThenInclude(x => x!.Instructors)
            .ThenInclude(x => x.Teacher)
            .Include(x => x.ExperimentProject)
            .ThenInclude(x => x!.TeachingTask)
            .ThenInclude(x => x!.Teachers)
            .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (sheet is null) return NotFound();
        if (!CanEdit(sheet.ExperimentProject!.TeachingTask!) &&
            !CanEditBoundSessionRecords(sheet, request.Records)) return Forbid();
        if (sheet.Status is not (
                ExperimentGradeSheetStatus.Draft or
                ExperimentGradeSheetStatus.Returned))
            return ConflictProblem("成绩单提交后不能继续修改。");

        var records = sheet.Records.ToDictionary(x => x.Id);
        if (request.Records.Select(x => x.Id).Distinct().Count() !=
                request.Records.Count ||
            request.Records.Any(x => !records.ContainsKey(x.Id)))
            return ValidationProblem("包含无效或重复的实验成绩记录。");
        var itemIds = sheet.Items.Select(x => x.Id).ToHashSet();
        foreach (var item in request.Records)
        {
            if (item.AttemptNumber is < 1 or > 20)
                return ValidationProblem("实验尝试次数应在 1—20 次之间。");
            if (item.ItemScores.Select(x => x.ExperimentGradeItemId)
                    .Distinct().Count() != item.ItemScores.Count ||
                item.ItemScores.Any(x =>
                    !itemIds.Contains(x.ExperimentGradeItemId) ||
                    !ValidScore(x.Score)))
                return ValidationProblem("包含无效、重复或超出 0—100 分的评分项。");

            var record = records[item.Id];
            record.ParticipationStatus = item.ParticipationStatus;
            record.SafetyViolation = item.SafetyViolation;
            record.AttemptNumber = item.AttemptNumber;
            record.SubmissionReference =
                Normalize(item.SubmissionReference);
            record.SubmittedAt = item.SubmittedAt;
            record.IsLate = item.IsLate;
            record.TeacherComment = Normalize(item.TeacherComment);
            var scores = record.ItemScores.ToDictionary(
                x => x.ExperimentGradeItemId);
            foreach (var score in item.ItemScores)
            {
                var target = scores[score.ExperimentGradeItemId];
                target.Score = score.Score;
                target.Comment = Normalize(score.Comment);
            }
            Recalculate(sheet, record);
        }
        await db.SaveChangesAsync(cancellationToken);
        return NoContent();
    }

    [HttpGet("sheets/{id:guid}/template")]
    [Authorize(Roles = Managers)]
    public async Task<IActionResult> DownloadTemplate(
        Guid id,
        CancellationToken cancellationToken)
    {
        var sheet = await LoadEditableSheetAsync(id, cancellationToken);
        if (sheet is null) return NotFound();
        if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();

        var headers = ExperimentImportHeaders(sheet.Items);
        var rows = sheet.Records
            .OrderBy(record => record.Student!.StudentNumber)
            .Select(record =>
            {
                var values = new List<object?>
                {
                    record.Student!.StudentNumber,
                    record.Student.Name,
                    record.Student.AdministrativeClass!.Name,
                    ParticipationLabel(record.ParticipationStatus)
                };
                foreach (var item in sheet.Items.OrderBy(item => item.SortOrder))
                    values.Add(record.ItemScores.FirstOrDefault(score =>
                        score.ExperimentGradeItemId == item.Id)?.Score);
                values.Add(record.SafetyViolation);
                values.Add(record.AttemptNumber);
                values.Add(null);
                values.Add(record.TeacherComment);
                return (IReadOnlyList<object?>)values;
            })
            .ToList();
        var itemCount = sheet.Items.Count;
        var totalColumn = 7 + itemCount;
        var scoreColumns = Enumerable.Range(5, itemCount).Append(totalColumn);
        var instructions = new List<string>
        {
            "请勿修改第一行列名；学号、姓名、班级列请勿修改，用于匹配本实验成绩单的学生。",
            "参与状态填写：待登记、已完成、缺席、请假、补做 或 免做。",
            "评分项填写 0—100 的数值，留空表示暂未录入。",
            "安全违规填写 是 或 否；实验次数填写 1—20。",
            "实验总评（自动计算）仅供 Excel 预览；上传时系统会按评分项、参与状态和安全违规重新计算。",
            $"本实验共 {itemCount} 个评分项：{string.Join("、", sheet.Items.OrderBy(item => item.SortOrder).Select(item => item.Name))}。"
        };
        var bytes = ExcelWorkbookHelper.Create(
            "实验成绩导入", headers, rows, instructions,
            (worksheet, rowNumber) =>
            {
                var itemReferences = Enumerable.Range(5, itemCount)
                    .Select(column => $"{ColumnLetter(column)}{rowNumber}")
                    .ToArray();
                var weightedExpression = string.Join("+", sheet.Items
                    .OrderBy(item => item.SortOrder)
                    .Select((item, index) =>
                        $"{ColumnLetter(index + 5)}{rowNumber}*{item.Weight.ToString(CultureInfo.InvariantCulture)}/100"));
                var statusReference = $"D{rowNumber}";
                var safetyReference = $"{ColumnLetter(5 + itemCount)}{rowNumber}";
                worksheet.Cell(rowNumber, totalColumn).FormulaA1 =
                    $"=IF(OR({statusReference}=\"缺席\",{safetyReference}=\"是\"),0,IF(OR({statusReference}=\"待登记\",{statusReference}=\"请假\",{statusReference}=\"免做\"),\"\",IF(COUNT({string.Join(",", itemReferences)})={itemCount},ROUND({weightedExpression},1),\"\")))";
                var totalCell = worksheet.Cell(rowNumber, totalColumn);
                totalCell.Style.NumberFormat.Format = "0.0";
                totalCell.Style.Font.Bold = true;
                totalCell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB");
                foreach (var column in scoreColumns)
                {
                    var format = worksheet.Range(rowNumber, column, rowNumber, column)
                        .AddConditionalFormat().WhenLessThan(60);
                    format.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#FDECEC");
                    format.Font.FontColor = ClosedXML.Excel.XLColor.FromHtml("#B42318");
                }
            });
        return File(bytes, ExcelWorkbookHelper.ContentType,
            $"实验成绩导入模板-{sheet.ExperimentProject!.Code}.xlsx");
    }

    [HttpPost("sheets/{id:guid}/import")]
    [Authorize(Roles = Managers)]
    [RequestSizeLimit(10 * 1024 * 1024)]
    public async Task<ActionResult> ImportRecords(
        Guid id,
        IFormFile file,
        CancellationToken cancellationToken)
    {
        var sheet = await LoadEditableSheetAsync(id, cancellationToken);
        if (sheet is null) return NotFound();
        if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
        if (sheet.Status is not (ExperimentGradeSheetStatus.Draft or ExperimentGradeSheetStatus.Returned))
            return ConflictProblem("只有录入中或已退回实验成绩单可以导入成绩。");

        var headers = ExperimentImportHeaders(sheet.Items);
        IReadOnlyList<ExcelRow> rows;
        try
        {
            rows = await ExcelWorkbookHelper.ReadAsync(file,
                headers.Where(header => header != "实验总评（自动计算）").ToArray(),
                cancellationToken);
        }
        catch (InvalidDataException exception)
        {
            return ValidationProblem(exception.Message);
        }
        if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的实验成绩数据。");

        var records = sheet.Records.ToDictionary(record => record.Student!.StudentNumber,
            StringComparer.OrdinalIgnoreCase);
        var items = sheet.Items.OrderBy(item => item.SortOrder).ToList();
        var errors = new List<string>();
        var updated = 0;
        foreach (var row in rows)
        {
            var studentNumber = row["学号"];
            if (string.IsNullOrWhiteSpace(studentNumber))
            {
                errors.Add($"第 {row.RowNumber} 行：学号不能为空。");
                continue;
            }
            if (!records.TryGetValue(studentNumber, out var record))
            {
                errors.Add($"第 {row.RowNumber} 行：学号“{studentNumber}”不在本实验成绩单中。");
                continue;
            }
            if (!TryParseParticipationStatus(row, out var participationStatus, out var participationError))
            {
                errors.Add($"第 {row.RowNumber} 行：{participationError}");
                continue;
            }
            if (!TryParseYesNo(row["安全违规"], out var safetyViolation))
            {
                errors.Add($"第 {row.RowNumber} 行：“安全违规”请填写是或否。");
                continue;
            }
            if (!int.TryParse(row["实验次数"], out var attemptNumber) || attemptNumber is < 1 or > 20)
            {
                errors.Add($"第 {row.RowNumber} 行：“实验次数”请填写 1—20 的整数。");
                continue;
            }
            var scores = new List<decimal?>();
            foreach (var item in items)
            {
                var score = ParseOptionalDecimal(row, item.Name, 0, 100, errors);
                if (errors.Count > 0 && errors[^1].Contains($"第 {row.RowNumber} 行")) break;
                scores.Add(score);
            }
            if (scores.Count != items.Count) continue;

            record.ParticipationStatus = participationStatus;
            record.SafetyViolation = safetyViolation;
            record.AttemptNumber = attemptNumber;
            record.TeacherComment = Normalize(row["教师评语"]);
            var scoreMap = record.ItemScores.ToDictionary(score => score.ExperimentGradeItemId);
            for (var index = 0; index < items.Count; index++)
                scoreMap[items[index].Id].Score = scores[index];
            Recalculate(sheet, record);
            updated++;
        }
        if (errors.Count > 0) return ImportValidationProblem(errors);
        await db.SaveChangesAsync(cancellationToken);
        return Ok(new { Updated = updated, Total = rows.Count });
    }

    [HttpPost("sheets/{id:guid}/sync-participants")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> SyncParticipants(
        Guid id,
        CancellationToken cancellationToken)
    {
        var sheet = await ScopedSheets()
            .Include(x => x.Items)
            .Include(x => x.Records)
            .ThenInclude(x => x.ItemScores)
            .Include(x => x.ExperimentProject)
            .ThenInclude(x => x!.TeachingTask)
            .ThenInclude(x => x!.Teachers)
            .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (sheet is null) return NotFound();
        if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
        if (sheet.Status is not (
                ExperimentGradeSheetStatus.Draft or
                ExperimentGradeSheetStatus.Returned))
            return ConflictProblem("成绩单提交后不能同步参与学生。");

        var expected = await LoadParticipantsAsync(
            sheet.ExperimentProject,
            cancellationToken);
        var expectedByStudent = expected.ToDictionary(x => x.StudentId);
        var existingByStudent = sheet.Records.ToDictionary(x => x.StudentId);
        var added = 0;
        var removed = 0;
        var retained = 0;
        var recordsToRemove = new List<ExperimentGradeRecord>();
        var recordsToAdd = new List<ExperimentGradeRecord>();

        foreach (var participant in expected)
        {
            if (existingByStudent.TryGetValue(
                    participant.StudentId,
                    out var record))
            {
                if (record.ParticipationStatus ==
                        ExperimentParticipationStatus.Pending &&
                    record.ItemScores.All(x => !x.Score.HasValue))
                    record.ExperimentSessionId = participant.SessionId;
                retained++;
                continue;
            }

            var newRecord = new ExperimentGradeRecord
            {
                ExperimentGradeSheetId = sheet.Id,
                StudentId = participant.StudentId,
                ExperimentSessionId = participant.SessionId
            };
            db.ExperimentGradeRecords.Add(newRecord);
            recordsToAdd.Add(newRecord);
            added++;
        }

        foreach (var record in sheet.Records
                     .Where(x => !expectedByStudent.ContainsKey(x.StudentId))
                     .ToList())
        {
            if (record.ParticipationStatus !=
                    ExperimentParticipationStatus.Pending ||
                record.ItemScores.Any(x => x.Score.HasValue))
            {
                retained++;
                continue;
            }

            db.ExperimentGradeItemScores.RemoveRange(record.ItemScores);
            recordsToRemove.Add(record);
            removed++;
        }

        if (recordsToRemove.Count > 0 || recordsToAdd.Count > 0)
        {
            await db.SaveChangesAsync(cancellationToken);
            foreach (var record in recordsToRemove)
                sheet.Records.Remove(record);
            db.ExperimentGradeRecords.RemoveRange(recordsToRemove);
        }
        foreach (var record in recordsToAdd)
            foreach (var item in sheet.Items)
                record.ItemScores.Add(new ExperimentGradeItemScore
                {
                    ExperimentGradeItemId = item.Id
                });
        await db.SaveChangesAsync(cancellationToken);
        return Ok(new
        {
            Added = added,
            Removed = removed,
            Retained = retained,
            Total = await db.ExperimentGradeRecords.CountAsync(
                x => x.ExperimentGradeSheetId == sheet.Id,
                cancellationToken)
        });
    }

    [HttpPost("sheets/{id:guid}/submit")]
    [Authorize(Roles = Managers)]
    public async Task<ActionResult> Submit(
        Guid id,
        CancellationToken cancellationToken)
    {
        var sheet = await LoadForWorkflowAsync(id, cancellationToken);
        if (sheet is null) return NotFound();
        if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
        if (sheet.Status is not (
                ExperimentGradeSheetStatus.Draft or
                ExperimentGradeSheetStatus.Returned))
            return ConflictProblem("只有草稿或已退回实验成绩单可以提交。");
        if (sheet.Records.Count == 0)
            return ConflictProblem("实验成绩单没有学生记录。");

        foreach (var record in sheet.Records) Recalculate(sheet, record);
        if (sheet.Records.Any(x =>
                x.ParticipationStatus ==
                    ExperimentParticipationStatus.Pending))
            return ConflictProblem("仍有学生未登记实验参与状态。");
        if (sheet.Records.Any(x =>
                x.ParticipationStatus is
                    ExperimentParticipationStatus.Completed or
                    ExperimentParticipationStatus.Makeup &&
                !x.TotalScore.HasValue))
            return ConflictProblem("仍有已完成或补做学生缺少必填评分项。");

        sheet.Status = ExperimentGradeSheetStatus.Submitted;
        sheet.SubmittedAt = DateTime.UtcNow;
        sheet.ReviewComment = null;
        await db.SaveChangesAsync(cancellationToken);
        await NotificationService.SendToRoleAsync(
            db,
            SystemRoles.CollegeAdmin,
            "实验成绩待审核",
            $"“{sheet.ExperimentProject!.Name}”实验成绩已提交，请及时审核。",
            sheet.ExperimentProject.TeachingTask!.Course!.CollegeId,
            "/experiment-grades",
            cancellationToken,
            NotificationCategory.Grade);
        return NoContent();
    }

    [HttpPost("sheets/{id:guid}/approve")]
    [Authorize(Roles = Reviewers)]
    public async Task<ActionResult> Approve(
        Guid id,
        CancellationToken cancellationToken)
    {
        var sheet = await LoadForWorkflowAsync(id, cancellationToken);
        if (sheet is null) return NotFound();
        var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId;
        if (!CanReviewCollege(collegeId)) return Forbid();
        if (sheet.Status != ExperimentGradeSheetStatus.Submitted)
            return ConflictProblem("只有待审核实验成绩单可以通过审核。");

        sheet.Status = ExperimentGradeSheetStatus.Approved;
        sheet.ReviewedAt = DateTime.UtcNow;
        sheet.ReviewComment = null;
        await db.SaveChangesAsync(cancellationToken);
        await NotifyTeachersAsync(
            sheet.ExperimentProject.TeachingTaskId,
            "实验成绩审核通过",
            $"“{sheet.ExperimentProject.Name}”实验成绩已通过学院审核。",
            cancellationToken);
        return NoContent();
    }

    [HttpPost("sheets/{id:guid}/return")]
    [Authorize(Roles = Reviewers)]
    public async Task<ActionResult> Return(
        Guid id,
        ExperimentGradeReviewRequest request,
        CancellationToken cancellationToken)
    {
        var sheet = await LoadForWorkflowAsync(id, cancellationToken);
        if (sheet is null) return NotFound();
        var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId;
        if (!CanReviewCollege(collegeId)) return Forbid();
        if (sheet.Status != ExperimentGradeSheetStatus.Submitted)
            return ConflictProblem("只有待审核实验成绩单可以退回。");
        if (string.IsNullOrWhiteSpace(request.Comment))
            return ValidationProblem("退回时必须填写修改意见。");

        sheet.Status = ExperimentGradeSheetStatus.Returned;
        sheet.ReviewedAt = DateTime.UtcNow;
        sheet.ReviewComment = request.Comment.Trim();
        await db.SaveChangesAsync(cancellationToken);
        await NotifyTeachersAsync(
            sheet.ExperimentProject.TeachingTaskId,
            "实验成绩被退回",
            $"“{sheet.ExperimentProject.Name}”实验成绩被退回：{sheet.ReviewComment}",
            cancellationToken);
        return NoContent();
    }

    [HttpPost("sheets/{id:guid}/publish")]
    [Authorize(Roles = Publishers)]
    public async Task<ActionResult> Publish(
        Guid id,
        CancellationToken cancellationToken)
    {
        var sheet = await LoadForWorkflowAsync(id, cancellationToken);
        if (sheet is null) return NotFound();
        if (!IsPublisher()) return Forbid();
        if (sheet.Status != ExperimentGradeSheetStatus.Approved)
            return ConflictProblem("只有审核通过的实验成绩单可以发布。");

        sheet.Status = ExperimentGradeSheetStatus.Published;
        sheet.PublishedAt = DateTime.UtcNow;
        await db.ExecuteInRetriableTransactionAsync(async transaction =>
        {
            await db.SaveChangesAsync(cancellationToken);
            await ExperimentGradeAggregationService.RefreshTeachingTaskAsync(
                db,
                sheet.ExperimentProject!.TeachingTaskId,
                cancellationToken);
            await transaction.CommitAsync(cancellationToken);
        }, cancellationToken, IsolationLevel.Serializable);
        var userIds = await db.ExperimentGradeRecords
            .Where(x => x.ExperimentGradeSheetId == sheet.Id)
            .Select(x => x.Student!.UserId)
            .Where(x => x != null)
            .Select(x => x!.Value)
            .Distinct()
            .ToListAsync(cancellationToken);
        await NotificationService.SendToUserIdsAsync(
            db,
            userIds,
            "实验成绩已发布",
            $"“{sheet.ExperimentProject!.Name}”实验成绩已发布。",
            "/experiment-grades",
            cancellationToken,
            NotificationCategory.Grade);
        return NoContent();
    }

    private async Task<ExperimentGradeSheet?> LoadForWorkflowAsync(
        Guid id,
        CancellationToken cancellationToken)
    {
        if (!await ScopedSheets().AnyAsync(
                x => x.Id == id,
                cancellationToken))
            return null;
        return await db.ExperimentGradeSheets
            .Include(x => x.Items)
            .Include(x => x.Records)
            .ThenInclude(x => x.ItemScores)
            .Include(x => x.ExperimentProject)
            .ThenInclude(x => x!.TeachingTask)
            .ThenInclude(x => x!.Course)
            .Include(x => x.ExperimentProject)
            .ThenInclude(x => x!.TeachingTask)
            .ThenInclude(x => x!.Teachers)
            .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
    }

    private async Task<ExperimentGradeSheet?> LoadEditableSheetAsync(
        Guid id,
        CancellationToken cancellationToken) =>
        await ScopedSheets()
            .Include(x => x.Items)
            .Include(x => x.Records)
            .ThenInclude(x => x.ItemScores)
            .Include(x => x.Records)
            .ThenInclude(x => x.Student)
            .ThenInclude(x => x!.AdministrativeClass)
            .Include(x => x.ExperimentProject)
            .ThenInclude(x => x!.TeachingTask)
            .ThenInclude(x => x!.Teachers)
            .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);

    private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync(
        ExperimentProject project,
        CancellationToken cancellationToken)
    {
        if (project.ArrangementMode ==
            ExperimentArrangementMode.Centralized)
        {
            return await TeachingTaskRosterQuery
                .ForTask(db, project.TeachingTaskId)
                .AsNoTracking()
                .OrderBy(x => x.StudentNumber)
                .Select(x => new ExperimentParticipantSeed(x.Id, null))
                .ToListAsync(cancellationToken);
        }

        return await db.ExperimentBookings.AsNoTracking()
            .Where(x =>
                x.ExperimentProjectId == project.Id &&
                x.Status == ExperimentBookingStatus.Booked &&
                x.Student!.Status == StudentStatus.Active)
            .OrderBy(x => x.Student!.StudentNumber)
            .Select(x => new ExperimentParticipantSeed(
                x.StudentId,
                x.ExperimentSessionId))
            .ToListAsync(cancellationToken);
    }

    private static List<ExperimentGradeItem> BuildItems(
        IReadOnlyCollection<ExperimentGradeItemRequest>? requests) =>
        requests?.Select((item, index) => new ExperimentGradeItem
        {
            Name = item.Name.Trim(),
            Kind = item.Kind,
            Weight = item.Weight,
            SortOrder = index
        }).ToList() ?? [];

    private static void Recalculate(
        ExperimentGradeSheet sheet,
        ExperimentGradeRecord record)
    {
        record.TotalScore = ExperimentGradeCalculator.CalculateTotal(
            record,
            sheet.Items.ToList());
        record.IsPassed = record.TotalScore.HasValue
            ? record.TotalScore.Value >= sheet.PassScore &&
              !record.SafetyViolation
            : 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 scope = currentUserDataScope.Current;
        if (scope.Scope == DataScope.Self && scope.IsInRole(SystemRoles.Teacher))
            return db.ExperimentProjects.Where(x =>
                (x.ArrangementMode == ExperimentArrangementMode.Centralized &&
                 x.TeachingTask!.Teachers.Any(item =>
                     item.Teacher!.UserId == scope.UserId)) ||
                (x.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
                 x.Sessions.Any(session => session.Instructors.Any(instructor =>
                     instructor.Teacher!.UserId == scope.UserId))));
        var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
        return db.ExperimentProjects.Where(x =>
            taskIds.Contains(x.TeachingTaskId));
    }

    private IQueryable<ExperimentGradeSheet> ScopedSheets()
    {
        var projectIds = ScopedProjects().Select(x => x.Id);
        return db.ExperimentGradeSheets.Where(x =>
            projectIds.Contains(x.ExperimentProjectId));
    }

    private bool CanInitialize(TeachingTask task) =>
        currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
        currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
        currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin) ||
        IsAssignedTeacher(task);

    private bool CanEdit(TeachingTask task) =>
        currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
        IsAssignedTeacher(task);

    private bool CanEditBoundSessionRecords(
        ExperimentGradeSheet sheet,
        IReadOnlyCollection<ExperimentGradeRecordRequest> requestedRecords)
    {
        var scope = currentUserDataScope.Current;
        if (sheet.ExperimentProject!.ArrangementMode != ExperimentArrangementMode.SelfScheduled ||
            scope.Scope != DataScope.Self || !scope.IsInRole(SystemRoles.Teacher) ||
            requestedRecords.Count == 0)
            return false;
        var requestedIds = requestedRecords.Select(x => x.Id).ToHashSet();
        return requestedIds.All(recordId => sheet.Records.Any(record =>
            record.Id == recordId &&
            record.ExperimentSession?.Instructors.Any(instructor =>
                instructor.Teacher?.UserId == scope.UserId) == true));
    }

    private bool IsAssignedTeacher(TeachingTask task) =>
        task.Teachers.Any(x =>
            x.Teacher?.UserId == currentUserDataScope.Current.UserId);

    private bool CanReviewCollege(Guid collegeId)
    {
        var scope = currentUserDataScope.Current;
        if (!scope.IsInRole(SystemRoles.SuperAdmin) &&
            !scope.IsInRole(SystemRoles.AcademicAdmin) &&
            !scope.IsInRole(SystemRoles.CollegeAdmin))
            return false;
        return scope.Scope == DataScope.All ||
               scope.RestrictedCollegeId == collegeId;
    }

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

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

    private async Task NotifyTeachersAsync(
        Guid teachingTaskId,
        string title,
        string content,
        CancellationToken cancellationToken)
    {
        var userIds = await db.TeachingTaskTeachers
            .Where(x => x.TeachingTaskId == teachingTaskId)
            .Select(x => x.Teacher!.UserId)
            .Where(x => x != null)
            .Select(x => x!.Value)
            .Distinct()
            .ToListAsync(cancellationToken);
        await NotificationService.SendToUserIdsAsync(
            db,
            userIds,
            title,
            content,
            "/experiment-grades",
            cancellationToken,
            NotificationCategory.Grade);
    }

    private static bool ValidScore(decimal? score) =>
        !score.HasValue || score.Value is >= 0 and <= 100;

    private static List<string> ExperimentImportHeaders(
        IEnumerable<ExperimentGradeItem> items)
    {
        var headers = new List<string> { "学号", "姓名", "班级", "参与状态" };
        headers.AddRange(items.OrderBy(item => item.SortOrder).Select(item => item.Name));
        headers.AddRange(["安全违规", "实验次数", "实验总评（自动计算）", "教师评语"]);
        return headers;
    }

    private static string ParticipationLabel(
        ExperimentParticipationStatus status) => status switch
    {
        ExperimentParticipationStatus.Pending => "待登记",
        ExperimentParticipationStatus.Completed => "已完成",
        ExperimentParticipationStatus.Absent => "缺席",
        ExperimentParticipationStatus.Excused => "请假",
        ExperimentParticipationStatus.Makeup => "补做",
        ExperimentParticipationStatus.Exempt => "免做",
        _ => "待登记"
    };

    private static bool TryParseParticipationStatus(
        ExcelRow row,
        out ExperimentParticipationStatus status,
        out string error)
    {
        error = string.Empty;
        switch (row["参与状态"])
        {
            case "待登记": status = ExperimentParticipationStatus.Pending; return true;
            case "已完成": status = ExperimentParticipationStatus.Completed; return true;
            case "缺席": status = ExperimentParticipationStatus.Absent; return true;
            case "请假": status = ExperimentParticipationStatus.Excused; return true;
            case "补做": status = ExperimentParticipationStatus.Makeup; return true;
            case "免做": status = ExperimentParticipationStatus.Exempt; return true;
            default:
                status = default;
                error = "“参与状态”请填写待登记、已完成、缺席、请假、补做或免做。";
                return false;
        }
    }

    private static bool TryParseYesNo(string value, out bool result)
    {
        if (value == "是") { result = true; return true; }
        if (value == "否") { result = false; return true; }
        result = false;
        return false;
    }

    private static decimal? ParseOptionalDecimal(
        ExcelRow row,
        string header,
        decimal minimum,
        decimal maximum,
        List<string> errors)
    {
        var value = row[header];
        if (string.IsNullOrWhiteSpace(value)) return null;
        if (decimal.TryParse(value, NumberStyles.Number,
                CultureInfo.InvariantCulture, out var result) &&
            result >= minimum && result <= maximum)
            return result;
        errors.Add($"第 {row.RowNumber} 行：“{header}”请填写 {minimum:0}—{maximum:0} 的数值或留空。");
        return null;
    }

    private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
    {
        foreach (var error in errors.Take(50)) ModelState.AddModelError("file", error);
        if (errors.Count > 50)
            ModelState.AddModelError("file", $"另有 {errors.Count - 50} 条错误未显示。");
        return ValidationProblem(ModelState);
    }

    private static string ColumnLetter(int column)
    {
        var result = string.Empty;
        while (column > 0)
        {
            column--;
            result = (char)('A' + column % 26) + result;
            column /= 26;
        }
        return result;
    }

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

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

    private sealed record ExperimentParticipantSeed(
        Guid StudentId,
        Guid? SessionId);
}

public sealed record ExperimentGradeSheetRequest(
    Guid ExperimentProjectId,
    [Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
    [Range(typeof(decimal), "0", "100")] decimal PassScore,
    IReadOnlyCollection<ExperimentGradeItemRequest> Items);

public sealed record ExperimentGradeSettingsRequest(
    [Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
    [Range(typeof(decimal), "0", "100")] decimal PassScore,
    IReadOnlyCollection<ExperimentGradeItemRequest> Items);

public sealed record ExperimentGradeItemRequest(
    [Required, MaxLength(60)] string Name,
    ExperimentGradeItemKind Kind,
    [Range(typeof(decimal), "0.1", "100")] decimal Weight);

public sealed record ExperimentGradeRecordsRequest(
    IReadOnlyCollection<ExperimentGradeRecordRequest> Records);

public sealed record ExperimentGradeRecordRequest(
    Guid Id,
    ExperimentParticipationStatus ParticipationStatus,
    bool SafetyViolation,
    [Range(1, 20)] int AttemptNumber,
    [MaxLength(500)] string? SubmissionReference,
    DateTime? SubmittedAt,
    bool IsLate,
    [MaxLength(500)] string? TeacherComment,
    IReadOnlyCollection<ExperimentGradeItemScoreRequest> ItemScores);

public sealed record ExperimentGradeItemScoreRequest(
    Guid ExperimentGradeItemId,
    decimal? Score,
    [MaxLength(300)] string? Comment);

public sealed record ExperimentGradeReviewRequest(
    [MaxLength(500)] string? Comment);
