using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.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/grades")]
public sealed class GradesController(
    AppDbContext db,
    ICurrentUserDataScope currentUserDataScope,
    IAppCache? cache = null) : ControllerBase
{
    private const string SheetUsers =
        SystemRoles.SuperAdmin + "," +
        SystemRoles.AcademicAdmin + "," +
        SystemRoles.CollegeAdmin + "," +
        SystemRoles.Counselor + "," +
        SystemRoles.Teacher;

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

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

    private const string StatisticsUsers =
        SheetUsers + "," + SystemRoles.Leader + "," + SystemRoles.Student;

    [HttpGet("sheets")]
    [Authorize(Roles = SheetUsers)]
    public async Task<ActionResult> GetSheets(
        Guid? academicTermId,
        GradeSheetStatus? status,
        string? keyword = null,
        int page = 1,
        int pageSize = 20,
        CancellationToken cancellationToken = default)
    {
        page = Math.Max(1, page);
        pageSize = Math.Clamp(pageSize, 10, 50);
        var source = AccessibleTasks().AsNoTracking()
            .Where(x =>
                x.Status == TeachingTaskStatus.Published ||
                x.Status == TeachingTaskStatus.Closed);
        if (academicTermId.HasValue)
            source = source.Where(x => x.AcademicTermId == academicTermId);
        if (status.HasValue)
            source = source.Where(x => db.GradeSheets.Any(sheet =>
                sheet.TeachingTaskId == x.Id && sheet.Status == status.Value));
        if (!string.IsNullOrWhiteSpace(keyword))
        {
            keyword = keyword.Trim();
            source = source.Where(x =>
                x.TaskNumber.Contains(keyword) ||
                x.Name.Contains(keyword) ||
                x.Course!.Code.Contains(keyword) ||
                x.Course.Name.Contains(keyword));
        }

        var total = await source.CountAsync(cancellationToken);
        var items = await source
            .OrderByDescending(x => x.AcademicTerm!.StartDate)
            .ThenBy(x => x.TaskNumber)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Select(x => new
            {
                x.Id,
                x.TaskNumber,
                TaskName = x.Name,
                x.AcademicTermId,
                TermName = x.AcademicTerm!.Name,
                CourseCode = x.Course!.Code,
                CourseName = x.Course.Name,
                CollegeName = x.Course.College!.Name,
                x.Course.Credits,
                TeacherNames = x.Teachers
                    .OrderByDescending(item => item.IsPrimary)
                    .Select(item => item.Teacher!.Name),
                ClassNames = x.Classes.Select(item => item.AdministrativeClass!.Name),
                Sheet = db.GradeSheets
                    .Where(sheet => sheet.TeachingTaskId == x.Id)
                    .Select(sheet => new
                    {
                        sheet.Id,
                        sheet.Status,
                        sheet.RegularWeight,
                        sheet.FinalWeight,
                        Items = sheet.Items.OrderBy(item => item.SortOrder).Select(item => new
                        {
                            item.Id,
                            item.Name,
                            item.Weight,
                            item.SourceType,
                            item.SourceSnapshotAt
                        }),
                        StudentCount = sheet.Records.Count,
                        CompletedCount = sheet.Records.Count(record =>
                            record.TotalScore != null ||
                            record.ExamStatus != GradeExamStatus.Normal),
                        PassedCount = sheet.Records.Count(record =>
                            record.TotalScore >= 60),
                        sheet.SubmittedAt,
                        sheet.ReviewedAt,
                        sheet.PublishedAt,
                        sheet.UpdatedAt
                    })
                    .FirstOrDefault()
            })
            .ToListAsync(cancellationToken);
        return Ok(new PagedResult<object>(items, total, page, pageSize));
    }

    [HttpPost("sheets")]
    [Authorize(Roles = SheetUsers)]
    public async Task<ActionResult> CreateSheet(
        GradeSheetRequest request,
        CancellationToken cancellationToken)
    {
        var items = request.Items?
            .Select((item, index) => new GradeItem
            {
                Name = item.Name.Trim(),
                 Weight = item.Weight,
                 SortOrder = index
            })
            .ToList() ?? [];

        if (!GradeCalculator.AreWeightsValid(
                request.RegularWeight,
                request.FinalWeight,
                items))
            return ValidationProblem("平时、期末与所有分项的比例必须合计 100%。");

        var task = await AccessibleTasks()
            .Include(x => x.Teachers)
            .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
        if (task is null) return NotFound();
        if (task.Status is not (TeachingTaskStatus.Published or TeachingTaskStatus.Closed))
            return ConflictProblem("只有已发布或已结课的教学班可以建立成绩单。");
        if (!CanInitialize(task)) return Forbid();
        if (await db.GradeSheets.AnyAsync(
                x => x.TeachingTaskId == task.Id,
                cancellationToken))
            return ConflictProblem("该教学班已经建立成绩单。");

        var studentIds = await TeachingTaskRosterQuery
            .ForTask(db, task.Id)
            .AsNoTracking()
            .Select(x => x.Id)
            .ToListAsync(cancellationToken);
        if (studentIds.Count == 0)
            return ConflictProblem("教学班当前没有有效学生，无法建立成绩单。");

        var sheet = new GradeSheet
        {
            TeachingTaskId = task.Id,
            RegularWeight = request.RegularWeight,
            FinalWeight = request.FinalWeight,
            Items = items,
            Records = studentIds.Select(studentId => new GradeRecord
            {
                StudentId = studentId,
                ItemScores = items.Select(item => new GradeItemScore
                {
                    GradeItemId = item.Id
                }).ToList()
            }).ToList()
        };
        db.GradeSheets.Add(sheet);
        return await SaveAsync(sheet.Id, true, cancellationToken);
    }

    [HttpGet("sheets/{id:guid}")]
    [Authorize(Roles = SheetUsers)]
    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 AccessibleSheets().AsNoTracking()
            .Where(x => x.Id == id)
            .Select(x => new
            {
                x.Id,
                x.TeachingTaskId,
                x.TeachingTask!.TaskNumber,
                TaskName = x.TeachingTask.Name,
                x.TeachingTask.AcademicTermId,
                TermName = x.TeachingTask.AcademicTerm!.Name,
                CourseCode = x.TeachingTask.Course!.Code,
                CourseName = x.TeachingTask.Course.Name,
                CourseCollegeId = x.TeachingTask.Course.CollegeId,
                CourseCollegeName = x.TeachingTask.Course.College!.Name,
                x.TeachingTask.Course.Credits,
                TeacherNames = x.TeachingTask.Teachers
                    .OrderByDescending(item => item.IsPrimary)
                    .Select(item => item.Teacher!.Name),
                ClassNames = x.TeachingTask.Classes
                    .Select(item => item.AdministrativeClass!.Name),
                x.RegularWeight,
                x.FinalWeight,
                Items = x.Items.OrderBy(item => item.SortOrder).Select(item => new
                {
                    item.Id,
                    item.Name,
                    item.Weight,
                    item.SourceType,
                    item.SourceSnapshotAt
                }),
                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.GradeRecords.AsNoTracking()
            .Where(record => record.GradeSheetId == id);
        if (studentKeyword is not null)
            recordsSource = recordsSource.Where(record =>
                record.Student!.StudentNumber.Contains(studentKeyword) ||
                record.Student.Name.Contains(studentKeyword) ||
                record.Student.AdministrativeClass!.Name.Contains(studentKeyword));
        var recordTotal = await recordsSource.CountAsync(cancellationToken);
        var records = await recordsSource
            .OrderBy(record => record.Student!.StudentNumber)
            .Skip((recordPage - 1) * recordPageSize)
            .Take(recordPageSize)
            .Select(record => new
            {
                record.Id,
                record.StudentId,
                record.Student!.StudentNumber,
                record.Student.Name,
                ClassName = record.Student.AdministrativeClass!.Name,
                record.RegularScore,
                record.FinalScore,
                ItemScores = record.ItemScores
                    .OrderBy(itemScore => itemScore.GradeItem!.SortOrder)
                    .Select(itemScore => new
                    {
                        itemScore.GradeItemId,
                        itemScore.GradeItem!.Name,
                        itemScore.Score
                    }),
                record.TotalScore,
                record.GradePoint,
                record.ExamStatus,
                record.Notes,
                record.UpdatedAt
            })
            .ToListAsync(cancellationToken);

        var task = await db.TeachingTasks.AsNoTracking()
            .Include(x => x.Teachers)
            .ThenInclude(x => x.Teacher)
            .SingleAsync(x => x.Id == sheet.TeachingTaskId, cancellationToken);

        // Determine if current user is from the course's college
        var scope = currentUserDataScope.Current;
        var isCourseCollegeReviewer = IsReviewer() &&
            (scope.Scope == DataScope.All ||
             scope.RestrictedCollegeId == sheet.CourseCollegeId);

        return Ok(new
        {
            Sheet = new
            {
                sheet.Id,
                sheet.TeachingTaskId,
                sheet.TaskNumber,
                sheet.TaskName,
                sheet.AcademicTermId,
                sheet.TermName,
                sheet.CourseCode,
                sheet.CourseName,
                sheet.CourseCollegeId,
                sheet.CourseCollegeName,
                sheet.Credits,
                sheet.TeacherNames,
                sheet.ClassNames,
                sheet.RegularWeight,
                sheet.FinalWeight,
                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 = CanEditScores(task) &&
                sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned,
            CanReview = isCourseCollegeReviewer && sheet.Status == GradeSheetStatus.Submitted,
            CanPublish = IsPublisher() && sheet.Status == GradeSheetStatus.Approved,
            NeedsCollegeReview = sheet.CourseCollegeName
        });
    }

    [HttpPut("sheets/{id:guid}/weights")]
    [Authorize(Roles = SheetUsers)]
    public async Task<ActionResult> UpdateWeights(
        Guid id,
        GradeWeightsRequest request,
        CancellationToken cancellationToken)
    {
        var items = request.Items?
            .Select((item, index) => new GradeItem
            {
                Name = item.Name.Trim(),
                 Weight = item.Weight,
                 SortOrder = index
            })
            .ToList() ?? [];

        if (!GradeCalculator.AreWeightsValid(
                request.RegularWeight,
                request.FinalWeight,
                items))
            return ValidationProblem("平时、期末与所有分项的比例必须合计 100%。");

        var sheet = await AccessibleSheets()
            .Include(x => x.Items)
            .Include(x => x.Records)
            .ThenInclude(x => x.ItemScores)
            .Include(x => x.TeachingTask)
            .ThenInclude(x => x!.Teachers)
            .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (sheet is null) return NotFound();
        if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
        if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
            return ConflictProblem("当前状态不能修改成绩构成。");

        sheet.RegularWeight = request.RegularWeight;
        sheet.FinalWeight = request.FinalWeight;

        db.GradeItems.RemoveRange(sheet.Items);
        var itemIdsToRemove = sheet.Items.Select(item => item.Id).ToHashSet();
        foreach (var record in sheet.Records)
        {
            var toRemove = record.ItemScores
                .Where(itemScore => itemIdsToRemove.Contains(itemScore.GradeItemId))
                .ToList();
            foreach (var removed in toRemove)
                record.ItemScores.Remove(removed);
        }

        sheet.Items = items;
        foreach (var record in sheet.Records)
        {
            foreach (var item in items)
            {
                record.ItemScores.Add(new GradeItemScore { GradeItemId = item.Id });
            }
            Recalculate(sheet, record);
        }

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

    [HttpPut("sheets/{id:guid}/records")]
    [Authorize(Roles = SheetUsers)]
    public async Task<ActionResult> UpdateRecords(
        Guid id,
        GradeRecordsRequest request,
        CancellationToken cancellationToken)
    {
        var sheet = await AccessibleSheets()
            .Include(x => x.Items)
            .Include(x => x.Records)
            .ThenInclude(x => x.ItemScores)
            .Include(x => x.TeachingTask)
            .ThenInclude(x => x!.Teachers)
            .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (sheet is null) return NotFound();
        if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
        if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.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 itemsById = sheet.Items.ToDictionary(item => item.Id);
        foreach (var item in request.Records)
        {
            if (!ValidScore(item.RegularScore) ||
                !ValidScore(item.FinalScore))
                return ValidationProblem("成绩必须在 0—100 分之间。");

            var record = records[item.Id];
            record.RegularScore = item.RegularScore;
            record.FinalScore = item.FinalScore;
            record.ExamStatus = item.ExamStatus;
            record.Notes = Normalize(item.Notes);

            if (item.ItemScores is not null)
            {
                var scoreMap = record.ItemScores.ToDictionary(s => s.GradeItemId);
                foreach (var scoreEntry in item.ItemScores)
                {
                    if (!ValidScore(scoreEntry.Score))
                        return ValidationProblem("分项成绩必须在 0—100 分之间。");
                    if (!itemsById.TryGetValue(
                            scoreEntry.GradeItemId,
                            out var gradeItem) ||
                        !scoreMap.TryGetValue(
                            scoreEntry.GradeItemId,
                            out var existing))
                        continue;
                    if (gradeItem.SourceType ==
                            GradeItemSourceType.ExperimentSummary &&
                        existing.Score != scoreEntry.Score)
                        return ConflictProblem(
                            $"“{gradeItem.Name}”来自实验成绩快照，不能手工修改；请重新导入实验成绩。");
                    existing.Score = scoreEntry.Score;
                }
            }

            Recalculate(sheet, record);
        }
        return await SaveAsync(id, false, cancellationToken);
    }

    [HttpPost("sheets/{id:guid}/import-experiment-scores")]
    [Authorize(Roles = SheetUsers)]
    public async Task<ActionResult> ImportExperimentScores(
        Guid id,
        ImportExperimentScoresRequest request,
        CancellationToken cancellationToken)
    {
        var sheet = await AccessibleSheets()
            .Include(x => x.Items)
            .Include(x => x.Records)
            .ThenInclude(x => x.ItemScores)
            .Include(x => x.TeachingTask)
            .ThenInclude(x => x!.Teachers)
            .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (sheet is null) return NotFound();
        if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
        if (sheet.Status is not (
                GradeSheetStatus.Draft or
                GradeSheetStatus.Returned))
            return ConflictProblem("成绩单提交后不能导入实验成绩。");

        var targetItem = sheet.Items.FirstOrDefault(x =>
            x.Id == request.GradeItemId);
        if (targetItem is null)
            return ValidationProblem("请选择当前成绩单中的有效分项。");

        var aggregate = await ExperimentGradeAggregationService.CalculateAsync(
            db,
            sheet.TeachingTaskId,
            sheet.Records.Select(x => x.StudentId).ToArray(),
            cancellationToken);
        if (aggregate.PublishedProjectCount == 0)
            return ConflictProblem(
                "该教学任务尚无已发布的实验项目成绩，无法导入。");

        var imported = 0;
        var missing = 0;
        foreach (var record in sheet.Records)
        {
            var targetScore = record.ItemScores.First(x =>
                x.GradeItemId == targetItem.Id);
            targetScore.Score = aggregate.Scores[record.StudentId];
            if (targetScore.Score.HasValue) imported++;
            else missing++;
            Recalculate(sheet, record);
        }
        targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
        targetItem.SourceSnapshotAt = DateTime.UtcNow;
        await db.SaveChangesAsync(cancellationToken);
        return Ok(new
        {
            aggregate.PublishedProjectCount,
            aggregate.Projects,
            ImportedCount = imported,
            MissingCount = missing,
            targetItem.SourceSnapshotAt
        });
    }

    [HttpPost("sheets/{id:guid}/submit")]
    [Authorize(Roles = SheetUsers)]
    public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
    {
        var sheet = await AccessibleSheets()
            .Include(x => x.Items)
            .Include(x => x.Records)
            .ThenInclude(x => x.ItemScores)
            .Include(x => x.TeachingTask)
            .ThenInclude(x => x!.Teachers)
            .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (sheet is null) return NotFound();
        if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
        if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
            return ConflictProblem("只有草稿或已退回成绩单可以提交。");
        if (sheet.Records.Count == 0)
            return ConflictProblem("成绩单没有学生记录。");

        // Final recalculation pass before submit
        foreach (var record in sheet.Records)
            Recalculate(sheet, record);

        if (sheet.Records.Any(x =>
                x.ExamStatus == GradeExamStatus.Normal &&
                !x.TotalScore.HasValue))
            return ConflictProblem("仍有学生缺少必填成绩分项，请补充后再提交。");
        sheet.Status = GradeSheetStatus.Submitted;
        sheet.SubmittedAt = DateTime.UtcNow;
        sheet.ReviewComment = null;
        await db.SaveChangesAsync(cancellationToken);

        // Notify college admins
        var courseName = sheet.TeachingTask!.Course!.Name;
        var collegeId = sheet.TeachingTask.Course.CollegeId;
        await NotificationService.SendToRoleAsync(db,
            SystemRoles.CollegeAdmin,
            "成绩单待审核",
            $"《{courseName}》成绩已提交，请及时审核。",
            collegeId,
            "/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 db.GradeSheets
            .Include(x => x.TeachingTask)
            .ThenInclude(x => x!.Course)
            .ThenInclude(x => x!.College)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (sheet is null) return NotFound();
        if (!IsReviewer()) return Forbid();

        // College-specific check: CollegeAdmin must belong to the course's college
        var scope = currentUserDataScope.Current;
        if (scope.Scope == DataScope.College &&
            sheet.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId)
            return ConflictProblem(
                $"该课程属于{sheet.TeachingTask.Course.College!.Name}，" +
                "您只能审核本学院课程的成绩单。");

        if (sheet.Status != GradeSheetStatus.Submitted)
            return ConflictProblem("只有待审核成绩单可以通过审核。");
        sheet.Status = GradeSheetStatus.Approved;
        sheet.ReviewedAt = DateTime.UtcNow;
        sheet.ReviewComment = null;
        await db.SaveChangesAsync(cancellationToken);

        // Notify teachers
        var teacherUserIds = await db.TeachingTaskTeachers
            .Where(x => x.TeachingTaskId == sheet.TeachingTaskId)
            .Select(x => x.Teacher!.UserId)
            .Where(id => id != null)
            .Select(id => id!.Value)
            .ToListAsync(cancellationToken);
        await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
            "成绩审核通过",
            $"《{sheet.TeachingTask!.Course!.Name}》成绩已通过学院审核，等待校级发布。",
            "/grades", cancellationToken, NotificationCategory.Grade);
        return NoContent();
    }

    [HttpPost("sheets/{id:guid}/return")]
    [Authorize(Roles = Reviewers)]
    public async Task<ActionResult> Return(
        Guid id,
        GradeReviewRequest request,
        CancellationToken cancellationToken)
    {
        var sheet = await db.GradeSheets
            .Include(x => x.TeachingTask)
            .ThenInclude(x => x!.Course)
            .ThenInclude(x => x!.College)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (sheet is null) return NotFound();
        if (!IsReviewer()) return Forbid();

        // College-specific check
        var scope = currentUserDataScope.Current;
        if (scope.Scope == DataScope.College &&
            sheet.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId)
            return ConflictProblem(
                $"该课程属于{sheet.TeachingTask.Course.College!.Name}，" +
                "您只能审核本学院课程的成绩单。");

        if (sheet.Status != GradeSheetStatus.Submitted)
            return ConflictProblem("只有待审核成绩单可以退回。");
        if (string.IsNullOrWhiteSpace(request.Comment))
            return ValidationProblem("退回时必须填写修改意见。");
        sheet.Status = GradeSheetStatus.Returned;
        sheet.ReviewedAt = DateTime.UtcNow;
        sheet.ReviewComment = request.Comment.Trim();
        await db.SaveChangesAsync(cancellationToken);

        var teacherUserIds = await db.TeachingTaskTeachers
            .Where(x => x.TeachingTaskId == sheet.TeachingTaskId)
            .Select(x => x.Teacher!.UserId)
            .Where(id => id != null)
            .Select(id => id!.Value)
            .ToListAsync(cancellationToken);
        await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
            "成绩被退回",
            $"《{sheet.TeachingTask!.Course!.Name}》成绩被退回修改：{sheet.ReviewComment}",
            "/grades", cancellationToken, NotificationCategory.Grade);
        return NoContent();
    }

    [HttpPost("sheets/{id:guid}/publish")]
    [Authorize(Roles = Publishers)]
    public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
    {
        var sheet = await AccessibleSheets()
            .Include(x => x.TeachingTask)
            .ThenInclude(x => x!.Course)
            .FirstOrDefaultAsync(
            x => x.Id == id,
            cancellationToken);
        if (sheet is null) return NotFound();
        if (!IsPublisher()) return Forbid();
        if (sheet.Status != GradeSheetStatus.Approved)
            return ConflictProblem("只有审核通过的成绩单可以发布。");
        sheet.Status = GradeSheetStatus.Published;
        sheet.PublishedAt = DateTime.UtcNow;

        // The grade sheet roster is authoritative at publication time. This also
        // covers students added through approved roster corrections.
        var studentUserIds = await db.GradeRecords
            .Where(x => x.GradeSheetId == sheet.Id)
            .Select(x => x.Student!.UserId)
            .Where(id => id != null)
            .Select(id => id!.Value)
            .Distinct()
            .ToListAsync(cancellationToken);
        await NotificationService.SendToUserIdsAsync(db, studentUserIds,
            "成绩已发布",
            $"《{sheet.TeachingTask!.Course!.Name}》成绩已正式发布，请前往成绩单查看。",
            "/grades", cancellationToken, NotificationCategory.Grade);
        return NoContent();
    }

    [HttpGet("sheets/{id:guid}/template")]
    [Authorize(Roles = SheetUsers)]
    public async Task<IActionResult> DownloadTemplate(
        Guid id,
        CancellationToken cancellationToken)
    {
        var sheet = await AccessibleSheets()
            .Include(x => x.Items.OrderBy(i => i.SortOrder))
            .Include(x => x.Records.OrderBy(r => r.Student!.StudentNumber))
            .ThenInclude(x => x.Student)
            .ThenInclude(x => x!.AdministrativeClass)
            .Include(x => x.TeachingTask)
            .ThenInclude(x => x!.Teachers)
            .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (sheet is null) return NotFound();
        if (!CanEditScores(sheet.TeachingTask!)) return Forbid();

        var itemNames = sheet.Items.Select(i => i.Name).ToList();
        var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
        headers.AddRange(itemNames);
        headers.AddRange(["期末成绩", "总分（自动计算）", "考试状态", "备注"]);

        var rows = sheet.Records.Select(record =>
        {
            var values = new List<object?>
            {
                record.Student!.StudentNumber,
                record.Student.Name,
                record.Student.AdministrativeClass!.Name,
                record.RegularScore
            };
            foreach (var item in sheet.Items)
            {
                var score = record.ItemScores
                    .FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
                values.Add(score);
            }
            values.Add(record.FinalScore);
            values.Add(null);
            values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
                record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
                record.ExamStatus == GradeExamStatus.Deferred ? "缓考" :
                record.ExamStatus == GradeExamStatus.Exempt ? "免修" : "正常");
            values.Add(record.Notes);
            return (IReadOnlyList<object?>)values;
        }).ToList();

        var instructions = new List<string>
        {
            "请勿修改第一行列名；学号、姓名、班级列请勿修改，用于匹配学生。",
            "成绩列填写 0—100 的数值，留空表示暂未录入。",
            "总分（自动计算）列由 Excel 按各部分比例自动计算，仅供填写时预览；上传时系统不会采用该列结果。",
            "考试状态填写：正常、缺考、缓考 或 免修，留空默认为正常。",
            $"本成绩单共 {sheet.Items.Count} 个分项：{string.Join("、", itemNames)}。",
            "导入后会自动重新计算总评成绩和绩点。"
        };

        var regularColumn = 4;
        var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
        var finalColumn = regularColumn + itemColumns.Length + 1;
        var totalColumn = finalColumn + 1;
        var statusColumn = totalColumn + 1;
        var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) };
        weightedColumns.AddRange(sheet.Items.Select((item, index) =>
            (itemColumns[index], item.Weight)));
        weightedColumns.Add((finalColumn, sheet.FinalWeight));
        var requiredColumns = weightedColumns.Where(x => x.Weight > 0).ToArray();
        var scoreColumns = weightedColumns.Select(x => x.Column)
            .Append(totalColumn)
            .ToArray();

        var bytes = ExcelWorkbookHelper.Create(
            "成绩导入", headers, rows, instructions,
            (worksheet, rowNumber) =>
            {
                var componentReferences = requiredColumns
                    .Select(x => $"{ColumnLetter(x.Column)}{rowNumber}")
                    .ToArray();
                var weightedExpression = string.Join("+", weightedColumns.Select(x =>
                    $"{ColumnLetter(x.Column)}{rowNumber}*{x.Weight.ToString(CultureInfo.InvariantCulture)}/100"));
                var statusReference = $"{ColumnLetter(statusColumn)}{rowNumber}";
                var formula =
                    $"=IF(OR({statusReference}=\"缺考\",{statusReference}=\"缓考\",{statusReference}=\"免修\"),\"\",IF(COUNT({string.Join(",", componentReferences)})={requiredColumns.Length},ROUND({weightedExpression},1),\"\"))";
                var cell = worksheet.Cell(rowNumber, totalColumn);
                cell.FormulaA1 = formula;
                cell.Style.NumberFormat.Format = "0.0";
                cell.Style.Font.Bold = true;
                cell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB");

                foreach (var scoreColumn in scoreColumns)
                {
                    var conditionalFormat = worksheet
                        .Range(rowNumber, scoreColumn, rowNumber, scoreColumn)
                        .AddConditionalFormat();
                    var failingScoreFormat = conditionalFormat.WhenLessThan(60);
                    failingScoreFormat.Fill.BackgroundColor =
                        ClosedXML.Excel.XLColor.FromHtml("#FDECEC");
                    failingScoreFormat.Font.FontColor =
                        ClosedXML.Excel.XLColor.FromHtml("#B42318");
                }
            });
        var taskName = sheet.TeachingTask!.Name;
        return File(bytes, ExcelWorkbookHelper.ContentType,
            $"成绩导入模板-{taskName}.xlsx");
    }

    [HttpPost("sheets/{id:guid}/import")]
    [Authorize(Roles = SheetUsers)]
    [RequestSizeLimit(10 * 1024 * 1024)]
    public async Task<ActionResult> ImportGrades(
        Guid id,
        IFormFile file,
        CancellationToken cancellationToken)
    {
        var sheet = await AccessibleSheets()
            .Include(x => x.Items.OrderBy(i => i.SortOrder))
            .Include(x => x.Records)
            .ThenInclude(x => x.ItemScores)
            .Include(x => x.Records)
            .ThenInclude(x => x.Student)
            .ThenInclude(x => x!.AdministrativeClass)
            .Include(x => x.TeachingTask)
            .ThenInclude(x => x!.Teachers)
            .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
        if (sheet is null) return NotFound();
        if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
        if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
            return ConflictProblem("只有草稿或已退回成绩单可以导入成绩。");

        var itemNames = sheet.Items.Select(i => i.Name).ToList();
        var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
        headers.AddRange(itemNames);
        headers.AddRange(["期末成绩", "总分（自动计算）", "考试状态", "备注"]);

        IReadOnlyList<ExcelRow> rows;
        try
        {
            rows = await ExcelWorkbookHelper.ReadAsync(
                file,
                headers.Where(x => x != "总分（自动计算）").ToArray(),
                cancellationToken);
        }
        catch (InvalidDataException exception)
        {
            return ValidationProblem(exception.Message);
        }

        if (rows.Count == 0)
            return ValidationProblem("Excel 中没有可导入的成绩数据。");

        var studentMap = sheet.Records.ToDictionary(
            x => x.Student!.StudentNumber,
            StringComparer.OrdinalIgnoreCase);
        var itemMap = sheet.Items.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase);

        var errors = new List<string>();
        var updated = 0;

        foreach (var row in rows)
        {
            var studentNumber = row["学号"]?.Trim();
            if (string.IsNullOrWhiteSpace(studentNumber))
            {
                errors.Add($"第 {row.RowNumber} 行：学号不能为空。");
                continue;
            }
            if (!studentMap.TryGetValue(studentNumber, out var record))
            {
                errors.Add($"第 {row.RowNumber} 行：学号“{studentNumber}”不在本成绩单中。");
                continue;
            }

            // Parse regular score
            var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors);
            if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;

            // Parse final score. The formula-driven total column is intentionally ignored.
            var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors);
            if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;

            // Parse item scores
            var itemScores = new List<(Guid GradeItemId, decimal? Score)>();
            foreach (var itemName in itemNames)
            {
                if (itemMap.TryGetValue(itemName, out var item))
                {
                    var score = ParseOptionalDecimal(row, itemName, 0, 100, errors);
                    if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString()))
                        break;
                    itemScores.Add((item.Id, score));
                }
            }
            if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;

            // Parse exam status
            var examStatusText = row["考试状态"]?.Trim();
            var examStatus = GradeExamStatus.Normal;
            if (!string.IsNullOrWhiteSpace(examStatusText))
            {
                if (examStatusText == "缺考") examStatus = GradeExamStatus.Absent;
                else if (examStatusText == "缓考") examStatus = GradeExamStatus.Deferred;
                else if (examStatusText == "免修") examStatus = GradeExamStatus.Exempt;
                else if (examStatusText != "正常")
                {
                    errors.Add($"第 {row.RowNumber} 行：考试状态“{examStatusText}”无效，请填写正常、缺考、缓考或免修。");
                    continue;
                }
            }

            // Apply
            record.RegularScore = regularScore;
            record.FinalScore = finalScore;
            record.ExamStatus = examStatus;
            record.Notes = Normalize(row["备注"]);

            foreach (var (itemId, score) in itemScores)
            {
                var existingScore = record.ItemScores
                    .FirstOrDefault(s => s.GradeItemId == itemId);
                if (existingScore != null)
                    existingScore.Score = score;
            }

            Recalculate(sheet, record);
            updated++;
        }

        if (errors.Count > 0)
            return ImportValidationProblem(errors);

        await db.SaveChangesAsync(cancellationToken);
        return Ok(new { updated, total = rows.Count });
    }

    [HttpGet("student/transcript")]
    [Authorize(Roles = SystemRoles.Student)]
    public async Task<ActionResult> GetStudentTranscript(
        Guid? academicTermId,
        CancellationToken cancellationToken)
    {
        var userId = currentUserDataScope.Current.UserId;
        var student = await db.Students.AsNoTracking()
            .Where(x => x.UserId == userId)
            .Select(x => new
            {
                x.Id,
                x.StudentNumber,
                x.Name,
                ClassName = x.AdministrativeClass!.Name,
                MajorName = x.AdministrativeClass.Major!.Name
            })
            .FirstOrDefaultAsync(cancellationToken);
        if (student is null)
            return ConflictProblem("当前账号未关联有效学生档案。");

        var source = db.GradeRecords.AsNoTracking()
            .Where(x =>
                x.StudentId == student.Id &&
                x.GradeSheet!.Status == GradeSheetStatus.Published);
        if (academicTermId.HasValue)
            source = source.Where(x =>
                x.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId);
        var records = await source
            .OrderByDescending(x => x.GradeSheet!.TeachingTask!.AcademicTerm!.StartDate)
            .ThenBy(x => x.GradeSheet!.TeachingTask!.Course!.Code)
            .Select(x => new
            {
                x.Id,
                GradeSheetId = x.GradeSheetId,
                AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
                TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
                x.GradeSheet.TeachingTaskId,
                x.GradeSheet.TeachingTask.TaskNumber,
                CourseCode = x.GradeSheet.TeachingTask.Course!.Code,
                CourseName = x.GradeSheet.TeachingTask.Course.Name,
                x.GradeSheet.TeachingTask.Course.Credits,
                x.TotalScore,
                x.GradePoint,
                x.ExamStatus,
                x.GradeSheet.PublishedAt
            })
            .ToListAsync(cancellationToken);
        return Ok(new { Student = student, Records = records });
    }

    [HttpGet("sheets/{id:guid}/statistics")]
    [Authorize(Roles = StatisticsUsers)]
    public async Task<ActionResult> GetCourseStatistics(
        Guid id,
        CancellationToken cancellationToken)
    {
        var scope = currentUserDataScope.Current;
        var sheet = await db.GradeSheets.AsNoTracking()
            .Where(x => x.Id == id)
            .Select(x => new
            {
                x.Id,
                x.Status,
                x.TeachingTask!.CourseId,
                x.TeachingTask.AcademicTermId,
                CourseName = x.TeachingTask.Course!.Name,
                CourseCode = x.TeachingTask.Course.Code,
                TermName = x.TeachingTask.AcademicTerm!.Name
            })
            .FirstOrDefaultAsync(cancellationToken);
        if (sheet is null) return NotFound();

        Guid? classId = null;
        Guid? majorId = null;
        Guid? collegeId = null;
        if (scope.IsInRole(SystemRoles.Student))
        {
            if (sheet.Status != GradeSheetStatus.Published)
                return NotFound();
            var student = await db.GradeRecords.AsNoTracking()
                .Where(x => x.GradeSheetId == id && x.Student!.UserId == scope.UserId)
                .Select(x => new
                {
                    x.Student!.AdministrativeClassId,
                    MajorId = x.Student.AdministrativeClass!.MajorId,
                    CollegeId = x.Student.AdministrativeClass.Major!.CollegeId
                })
                .FirstOrDefaultAsync(cancellationToken);
            if (student is null) return Forbid();
            classId = student.AdministrativeClassId;
            majorId = student.MajorId;
            collegeId = student.CollegeId;
        }
        else if (scope.Scope == DataScope.College)
        {
            collegeId = scope.RestrictedCollegeId;
            if (collegeId == Guid.Empty || !scope.CanAccessCollege(
                    await db.Courses.Where(x => x.Id == sheet.CourseId)
                        .Select(x => x.CollegeId).FirstAsync(cancellationToken)))
                return Forbid();
        }
        else if (scope.IsInRole(SystemRoles.Counselor))
        {
            var allowedClassIds = await db.AdministrativeClasses.AsNoTracking()
                .Where(x => x.CounselorUserId == scope.UserId)
                .Select(x => x.Id)
                .ToListAsync(cancellationToken);
            if (allowedClassIds.Count == 0) return Forbid();
            // A counselor sees their classes plus the matching major/college
            // benchmarks, never an unrelated class-level statistic.
            classId = allowedClassIds.First();
            majorId = await db.AdministrativeClasses.Where(x => x.Id == classId)
                .Select(x => x.MajorId).FirstAsync(cancellationToken);
            collegeId = await db.Majors.Where(x => x.Id == majorId)
                .Select(x => x.CollegeId).FirstAsync(cancellationToken);
        }

        var cacheKey = AppCacheKeys.CourseGradeStatistics(id);
        var statistics = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(cacheKey, async token =>
        {
            var source = db.CourseGradeStatistics.AsNoTracking()
                .Where(x => x.CourseId == sheet.CourseId &&
                            x.AcademicTermId == sheet.AcademicTermId);
            return await source.Select(x => new
            {
                x.Scope, x.ScopeEntityId, x.StudentCount, x.PassedCount,
                x.Below60Count, x.From60To69Count, x.From70To79Count,
                x.From80To89Count, x.From90To100Count,
                x.HighestScore, x.AverageScore, x.LowestScore, x.PassRate,
                x.CalculatedAt
            }).ToListAsync(token);
        }, AppCacheProfile.Analytics,
        [AppCacheTags.CourseGradeStatistics], cancellationToken);

        object? Find(CourseGradeStatisticScope statisticScope, Guid? entityId)
        {
            var item = statistics.FirstOrDefault(x => x.Scope == statisticScope &&
                x.ScopeEntityId == entityId);
            return item is null ? null : new
            {
                item.Scope, item.ScopeEntityId, item.StudentCount, item.PassedCount,
                item.HighestScore, item.AverageScore, item.LowestScore, item.PassRate,
                item.CalculatedAt,
                Distribution = new[]
                {
                    new { Range = "0–59", Count = item.Below60Count },
                    new { Range = "60–69", Count = item.From60To69Count },
                    new { Range = "70–79", Count = item.From70To79Count },
                    new { Range = "80–89", Count = item.From80To89Count },
                    new { Range = "90–100", Count = item.From90To100Count }
                }
            };
        }
        return Ok(new
        {
            sheet.CourseName, sheet.CourseCode, sheet.TermName,
            IsRefreshing = !statistics.Any(),
            Class = classId.HasValue ? Find(CourseGradeStatisticScope.AdministrativeClass, classId) : null,
            Major = majorId.HasValue ? Find(CourseGradeStatisticScope.Major, majorId) : null,
            College = collegeId.HasValue ? Find(CourseGradeStatisticScope.College, collegeId) : null,
            University = scope.Scope == DataScope.All || scope.IsInRole(SystemRoles.Student)
                ? Find(CourseGradeStatisticScope.University, null) : null
        });
    }

    private IQueryable<TeachingTask> AccessibleTasks()
    {
        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.Counselor))
        {
            // Counselor sees grades for courses in their managed classes' college
            var collegeIds = db.AdministrativeClasses
                .Where(c => c.CounselorUserId == scope.UserId)
                .Select(c => c.Major!.CollegeId)
                .Distinct();
            return source.Where(x => collegeIds.Contains(x.Course!.CollegeId));
        }
        if (scope.IsInRole(SystemRoles.Teacher))
            return source.Where(x =>
                x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId));
        return source.Where(_ => false);
    }

    private IQueryable<GradeSheet> AccessibleSheets() =>
        db.GradeSheets.Where(x => AccessibleTasks().Any(task =>
            task.Id == x.TeachingTaskId));

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

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

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

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

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

    private static void Recalculate(GradeSheet sheet, GradeRecord record)
    {
        record.TotalScore = GradeCalculator.CalculateTotal(
            record.RegularScore,
            record.FinalScore,
            record.ItemScores.ToList(),
            sheet.RegularWeight,
            sheet.FinalWeight,
            sheet.Items.ToList(),
            record.ExamStatus);
        record.GradePoint = GradeCalculator.CalculateGradePoint(record.TotalScore);
    }

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

    private async Task<ActionResult> SaveAsync(
        Guid id,
        bool created,
        CancellationToken cancellationToken)
    {
        try
        {
            await db.SaveChangesAsync(cancellationToken);
            return created ? Created(string.Empty, new { id }) : NoContent();
        }
        catch (DbUpdateException)
        {
            return ConflictProblem("成绩单已存在，或关联学生与教学班已发生变化。");
        }
    }

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

    private 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 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 static string? Normalize(string? value) =>
        string.IsNullOrWhiteSpace(value) ? null : value.Trim();

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

public sealed record GradeSheetRequest(
    Guid TeachingTaskId,
    [Range(typeof(decimal), "0", "100")] decimal RegularWeight,
    [Range(typeof(decimal), "0", "100")] decimal FinalWeight,
    IReadOnlyCollection<GradeItemRequest>? Items);

public sealed record GradeItemRequest(
    [MaxLength(60)] string Name,
    [Range(typeof(decimal), "0", "100")] decimal Weight);

public sealed record GradeWeightsRequest(
    [Range(typeof(decimal), "0", "100")] decimal RegularWeight,
    [Range(typeof(decimal), "0", "100")] decimal FinalWeight,
    IReadOnlyCollection<GradeItemRequest>? Items);

public sealed record GradeRecordsRequest(
    IReadOnlyCollection<GradeRecordRequest> Records);

public sealed record GradeRecordRequest(
    Guid Id,
    decimal? RegularScore,
    decimal? FinalScore,
    IReadOnlyCollection<GradeItemScoreRequest>? ItemScores,
    GradeExamStatus ExamStatus,
    [MaxLength(300)] string? Notes);

public sealed record GradeItemScoreRequest(
    Guid GradeItemId,
    decimal? Score);

public sealed record ImportExperimentScoresRequest(Guid GradeItemId);

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